Why This Exists
GTO (Game Theory Optimal) poker is the study of unexploitable mixed strategies — the mathematical equilibrium your decisions should approximate. The problem isn't that the theory is inaccessible; it's that every existing resource assumes you already know how to learn it. Solvers output raw EV trees. Courses are lecture videos. Charts are static PDFs. None of them close the feedback loop.
The thing that actually builds intuition is retrieval practice: being forced to produce an answer, getting immediate feedback, and revisiting what you got wrong. That's the Duolingo model, and I thought it was a genuinely better fit for GTO theory than anything in the existing poker training market. So I built it.
Great Teacher Onigiri is a 5-world progressive curriculum with a Practice Hub for drills, a Boss Battle poker engine, and a Player Profile that tracks 7 session metrics and surfaces your actual leaks. It runs on iOS, Android, and web from a single React codebase.
System Design: Three Layers With a Hard Constraint
The architectural constraint that shaped every other decision: the app needed to ship as a native iOS/Android binary while remaining a React web app at its core. That constraint ruled out React Native (different component model, different ecosystem) and pointed directly at Capacitor — write the app as a web app, Capacitor wraps it in a native shell.
I ended up with three clean layers:
- Content layer — static JS modules. All curriculum data (lesson slides, quiz questions, GTO charts, drill scenarios) ships with the app bundle. Zero backend for content reads. This was a deliberate call: content changes should be fast to ship, offline play should work, and the content schema should be stable enough to not need a CMS.
- State layer —
GameContext, a React Context wrapping ~35KB of session state. XP, hearts, current world/lesson position, drill metrics, Player Profile data. Persisted to Supabase on session close, hydrated on login. - Platform layer — Supabase (auth + Postgres), RevenueCat (IAP entitlements), Capacitor (native bridge). These are the only things that care whether you're running on iOS or web.
The key design payoff: the content layer and state layer have no native dependencies. They work identically in the browser and in the Capacitor shell. The platform layer is the only thing I have to think about when a Capacitor or Supabase API changes.
Why Supabase Over a Custom Backend
I didn't want to run a backend server for v1. Supabase gave me Postgres with Row Level Security, which meant I could enforce user data isolation at the DB layer without writing middleware. The schema is straightforward: a user_progress table keyed by user_id storing serialized GameContext state, and a session_hands table storing the raw hand history that feeds the Player Profile metrics. OAuth via Google and Apple is handled by Supabase Auth, which reduced the auth surface area considerably.
RevenueCat and the IAP Complexity Tax
Building IAP directly against StoreKit (iOS) and Google Play Billing means: two different APIs, server-side receipt validation to prevent spoofing, webhook infrastructure to sync subscription state into your DB, and a management UI for refunds and cancellations. RevenueCat abstracts all of that. The integration is a single SDK call to fetch entitlements, and a webhook to update Supabase when subscription status changes. The price is another vendor in the stack; the payoff is not building subscription infrastructure.
The Freemium Gate: Velocity, Not Visibility
Onigiri Pro unlocks all five worlds, unlimited hearts, and unlimited daily drills. The free tier gets World 1, 5 hearts (one regenerates every 30 minutes), and 10 drills per day. The gate is on velocity, not on seeing what's there.
This was a deliberate design choice. Gating content visibility means the user hits the paywall before they've gotten value. Gating velocity means they complete the hook — World 1, their first Boss Battle, seeing their Player Profile archetype — before they ever see a subscription prompt. That sequence matters a lot for conversion.
The Content Delivery Architecture
Every lesson is a typed JS object: a type field ('text' | 'visual' | 'quiz' | 'drill'), a content payload, and optional hint and feedback strings. A shared LessonViewer component renders the appropriate UI for each type. Quiz slides carry an options array and a correctIndex; the component handles scoring and feedback display.
What this means operationally: adding a new lesson is a PR that appends an object to a data file. No CMS, no content API, no migration. The tradeoff is that the bundle grows with curriculum size — this is fine until it isn't, and I've flagged it as a concern around World 4–5 content completion.
The Boss Battle Engine
Each world ends with a Boss Battle: a multi-hand poker session against an AI opponent, with per-hand coaching feedback. Building this was the most technically interesting part of the project.
The Hand State Machine
A poker hand has a natural state machine: dealing → preflop → flop → turn → river → showdown → scoring. Each transition fires when the active player's action is resolved. The tricky part is that some streets can be skipped (if someone goes all-in preflop, there's no postflop action), and the machine needs to handle those cleanly without a tangle of conditionals.
I modeled this as an explicit reducer — each state has a defined set of valid transitions and actions. The reducer returns a new state object on each action; the UI is a pure function of that state. This made the engine straightforward to reason about and much easier to debug when early hands produced nonsensical bet sequences.
Hand Evaluation
The evaluator finds the best 5-card hand from 7 cards (2 hole + 5 community). The naive approach — enumerate all C(7,5) = 21 combinations, score each, return the max — works fine at this scale. Each combination is checked against the hand ranking hierarchy in order, with kicker comparison for ties. It runs in the browser with no perceptible latency. I looked at lookup-table approaches for speed, decided it was premature, and moved on.
AI Opponents as Configurable Exploits
The opponents are decision trees, not neural nets. Each has a configurable aggression profile, a positional awareness weight, and a range model that approximates how a real player at their skill tier constructs ranges. The three boss tiers — calling station, TAG (tight-aggressive), GTO-approximator — are intentionally exploitable in specific, teachable ways. The point of the game is to identify and punish those patterns. A perfectly GTO-optimal bot would be unbeatable, which is useless for learning.
The coaching feedback system compares the player's action sequence to the optimal line for that spot and translates the delta into plain English. "You called the flop with 28% equity — you needed 33% to break even. Consider folding or raising here." Getting that feedback calibration right took more tuning than the evaluation logic.
Player Profile: Derived State at the Session Level
Seven metrics are tracked across every Boss Battle and drill session:
- VPIP — % of hands you voluntarily put money in preflop
- PFR — preflop raise %
- Aggression Factor — (bets + raises) / calls
- WTSD — went to showdown rate
- W$SD — won money at showdown rate
- 3-Bet % — re-raise frequency preflop
- Fold to 3-Bet — how often you fold to a re-raise
These are computed from the raw session_hands table via SQL aggregates on session close, then cached in the GameContext. The leak detection system cross-references metric ranges against known leak archetypes: VPIP > 30% + low AF = calling station, low VPIP + low PFR = too passive, low WTSD + high W$SD = not getting paid off enough. "Top 3 Leaks" surfaces the most actionable gaps and links directly to the drill targeting each one.
The design goal was that the Player Profile tells you what your hands have already decided about your game — not what you should study next. The diagnostic framing is more useful than a checklist because it reflects actual behavior, not stated intentions.
Three Things I Had to Figure Out the Hard Way
SPAs on Static Hosts Need a Catch-All Rewrite
After deploying to Vercel, navigating directly to /worlds/1 returned a 404. Works fine from the home screen. Dead on any direct URL or hard refresh. This is a well-known SPA deployment footgun — Vercel serves static files by matching URL paths to disk, and /worlds/1 isn't a file — it's a client-side route.
// vercel.json
{
"rewrites": [{ "source": "/(.*)", "destination": "/index.html" }]
}
This tells Vercel to serve index.html for any path that doesn't match a real file, handing control to React Router. The frustrating part: local dev servers handle this automatically, so it only breaks in production. Worth adding to your deploy checklist if you're shipping an SPA.
Xcode 16 and Capacitor's Generated AppDelegate
Updating to Xcode 16 broke the iOS build with a cryptic overload resolution error in AppDelegate.swift — a file Capacitor generates, not one I wrote. Xcode 16 tightened protocol conformance checking; the generated code called a method on a UIApplicationDelegate reference in a way the new compiler couldn't resolve without an explicit protocol cast.
The fix was straightforward once I found the right GitHub issue thread, but it took an hour of staring at a build error in generated code before I thought to search Capacitor's issues. Lesson taken: before updating Xcode or Capacitor, check the release notes and the open issues. The community is small enough that obscure build failures usually have a thread.
The Auth State Race Condition
After the Supabase OAuth callback, the app occasionally showed a blank screen — no error, just white. The root cause was a timing issue: the Supabase auth state listener fired before the React component tree finished mounting, applied a state update, then the initial render wiped it. The listener fired again, but by then the component was in an inconsistent state and stayed blank.
I was modeling auth as a boolean: isLoggedIn: true | false. That's missing a third state — loading, meaning "we haven't heard back from Supabase yet." Introducing a three-state enum (loading | authenticated | unauthenticated) and gating the component tree behind loading resolving eliminated the race entirely. Nothing renders until auth state is known.
This pattern shows up everywhere async state gets involved. A boolean is almost always the wrong model for async status.
Current Status and What's Next
World 1 is complete — 12 lessons, the full Practice Hub, and the Boss Battle. Worlds 2–3 are partially written. The main blocker for App Store launch is IAP approval for Onigiri Pro; the web version is live and playable at great-teacher-onigiri.vercel.app.
On the near-term roadmap: daily challenge mode, a hand replay feature for reviewing past Boss Battle hands, and a leaderboard. The bigger open question is the bundle size — static content works great now, but Worlds 4–5 will push it. Code-splitting by world is the obvious fix; I haven't needed it yet.
Source on GitHub.