Files
holdem_poker/CLAUDE.md
T

11 KiB
Raw Blame History

Poker — Texas Hold'em with teachable AI opponents

Kotlin Multiplatform. Ships iOS + Android; Android first (only Android hardware for physical testing).

Build

No java/gradle on PATH — use Android Studio's bundled JDK:

export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home"
./gradlew :engine:jvmTest              # engine tests (JVM)
./gradlew :engine:testAndroidHostTest  # same suite, Android variant
./gradlew :sim:run --args="50000"      # simulate 50k hands, print bot stats
./gradlew :sim:test                    # fast calibration-policy tests
./gradlew :sim:run --args="styles"     # enforced controlled style experiment
./gradlew :sim:run --args="calibrate"  # enforced 4×100k fixed-pool skill + style calibration
./gradlew :sim:run --args="short-stacks" # persistent stacks; asserts side-pot/call-clamp coverage
./gradlew :app:assembleDebug           # build the APK

Layout

Path What
engine/src/commonMain/.../core/ Cards, evaluator, equity, pre-flop chart
engine/src/commonMain/.../bot/ Skill/style profiles, MathBot
engine/src/commonMain/.../game/ Table — betting rounds, side pots, showdown
sim/ JVM-only headless simulator used to tune bot profiles
app/ Android app: Compose table, PokerViewModel
designs/ Visual direction and interaction references; current table follows 1A
assets/cards/ 52 CC0 card faces + generated backs (source of truth)
tools/generate_card_assets.sh Rasterises those SVGs into app/.../drawable-*

engine is pure Kotlin with no platform APIs, so androidTarget() / iosArm64() slot in without touching commonMain.

Design rules

  1. Poker maths never goes near the LLM. Difficulty and style are engine-side EV/frequency calculations — instant, deterministic, testable, offline. The LLM only narrates numbers the engine already computed (DecisionTrace), and adds persona/table talk.
  2. Skill and style are orthogonal. SkillLevel = how correct decisions are; PlayStyle = bluffing, sandbagging, aggression, tightness. Build the strongest bot, then inject controlled error for lower tiers.
  3. Pre-flop is range-based, not equity-based. All-in equity overvalues trash (7-2o has ~35% vs one random hand but is unplayable). PreflopChart ranks the 169 starting hands so looseness means "plays the top N%".
  4. The simulator is how bots get tuned. Run it after any bot change, across several seeds — a single seed will happily agree with a wrong conclusion. The quick 50k run is diagnostic; only calibrate is an enforced result. Its paired skill comparisons reset equal stacks to isolate skill, then a mandatory persistent-stack pass verifies that short-stack pricing is live.
  5. A skill parameter must not smuggle in a style change. Several bugs came from exactly this: positionAwareness silently reduced hands played, potOddsRespect systematically loosened weak players (which is a winning adjustment, so it inverted the gradient), and error direction overwrote style entirely. Skill should change how well a decision is made, not how loose or tight the player is.
  6. Pot odds are the post-flop baseline. Never apply a blanket implied-odds discount: it is categorically wrong on the river, and future value on earlier streets must account for future costs and reverse implied odds before it is called an advantage. Price a call against eligiblePot, not the displayed total: a short stack cannot win side-pot chips above its contribution level.
  7. DecisionTrace is the coach contract. It records raw pot odds, the actual adjusted threshold, every adjustment, intended and chosen actions, and whether a skill error changed the decision. Pre-flop chart percentile/range fields are structurally separate from post-flop equity fields; inapplicable values are null. The production coach explains these values; it does not reconstruct hidden bot logic.
  8. Persist aggregates, not surveillance. Player history stores the schema version, setup preference, session/hand counts, baseline agreement, and stable review-category counters. It never stores hole cards, boards, opponents, or raw action histories. Storage mutations are serialised because hand completion and coach analysis run on different coroutines. History is non-critical: an unreadable/newer schema or failed commit falls back to in-memory counters and disables persistence for that process rather than crashing or overwriting data.
  9. Personas can speak, never act. PersonaCue contains only an opponent's already-accepted public action and public table totals. It has no cards, equity, decision context, legal actions, or mutation callback. PersonaNarrator returns only nullable text; PersonaArbiter rate-limits, times out, bounds, and sanitises that text with a deterministic offline voice.

Testing notes

  • Table takes a CardSource, so StackedDeck.of(holes, board) gives fully deterministic hands. Use it for any rule test.
  • The simulator gives the deck its own RNG, separate from each bot's. Never share one: bots consume RNG proportional to their equityIterations, so a shared stream means changing a profile silently changes the cards dealt.
  • ./gradlew :sim:run --args="chart" dumps the starting-hand ranking.
  • Small samples lie. 1,000 hands is not enough to rank profiles. Use the paired 4×100k fixed-opponent calibration before accepting a skill change; it computes confidence bounds and exits nonzero when the contract fails.
  • Equal starting stacks are deliberate in the paired skill experiment, but they cannot exercise side-pot pricing. calibrate therefore finishes with 3,000 persistent-stack hands and fails unless both eligiblePot != pot and toCall > stack occur. :sim:test pins the same paths at a fixed seed.

Status

  • Evaluator: verified exhaustively against published frequencies for all 2,598,960 five-card hands. ~24M evals/sec.
  • Engine: chip-conserving. Covered by tests: side pots, uncalled-bet refunds, action order, malformed agent output, TDA Rule 47 (incomplete raises do not reopen betting, but several that cumulatively reach a full raise do), and TDA Rule 20 (odd chip to the first winner left of the button).
  • Bots: controlled style calibration holds style constant against the same five opponents and deal seed. Rock is 10.5% VPIP, Maniac 67.4%; looseness ordering, Calling Station passivity (10.0% PFR, 0.27 AF), Maniac aggression, and PFR relationships all pass.
  • Skill calibration pairs four 100k-hand seeds. Every candidate occupies the same seat against the same fixed opponent pool and deal seed. Advanced and Expert are allowed to overlap, but both must beat Intermediate and Intermediate must beat Beginner with a positive 95% lower confidence bound. Current lower bounds are +24.42, +11.01, and +24.67 bb/100 respectively.
  • Persistent-stack calibration currently reaches 351 decisions with inaccessible side-pot chips and stack-clamped calls in 3,000 hands (seed 20260729).
  • Expert differs by mechanism: it alone maintains opponent reads. The aggression prior is measured by the controlled neutral TAG experiment (0.231 observed, 0.22 configured), not selected because it looks plausible.

Rules invariants that are easy to get wrong

  • Reopening betting cannot be a boolean. Seat.lastActedAtBet records the bet level a player last acted at; betting reopens when `currentBet - lastActedAtBet

    = minRaiseSize`. Several short all-ins can reach that together.

  • PreflopChart percentiles are weighted by combination counts (pair 6, suited 4, offsuit 12, total 1326), so "top 12%" means 12% of dealt hands, not 12% of the 169 classes.
  • Anything consuming DecisionContext.history across hands must key off handNumber. History is cleared each hand, so a size comparison silently drops events.
  • TableSnapshot.lastAction intentionally survives board-transition frames. Live status copy may carry it into the next decision only when the event's street matches the snapshot, or the first flop actor resurrects a pre-flop action.
  • TableSnapshot.actionNumber is the authoritative identity of lastAction. Persona output is accepted only beside the exact hand, street, and action number that requested it; a late response is dropped rather than shown against a newer decision.
  • A completed hand cannot be reduced to one flat winner list. HandResult retains each PotAward in main/side-pot order and only the hands publicly revealed at showdown. The result UI reads those authoritative awards, so two players winning different pots are never presented as though they tied.
  • Android is playable in Compose. Engine snapshots are paced through a rendezvous channel, human decisions are matched by engine-owned tokens, and a completed hand stays on screen until the player explicitly starts the next one. The portrait table follows the 1A design direction: circular felt, perimeter seats, spatial bets, large 2:3 card art, always-visible legal sizing controls, and a winner-focused pot/hand breakdown at completion. Face-up cards have fixed legibility floors rather than shrinking on short displays; the opponent orbit straddles the outside rail rather than occupying the community-card band. The terminal modal repeats the board, every public hand turned up (including losing hands), and exact pot awards so dimming the table never hides the explanation. The default action bar contains only Fold/Check-or-Call/Raise; Raise opens a confirmation screen with coarse slider/presets, one-chip minus and plus controls, and explicit UI/system Back handling. Merely opening or leaving sizing never submits an action. Visible chip stacks grow with bets and the pot while numeric labels remain authoritative.
  • A cash-game session begins at an explicit take-a-seat screen. Opponents may auto-reload below the big blind; the human is never silently topped up and must explicitly choose "Reload to N & deal" from the completed-hand screen.
  • The deterministic coach is opt-in and post-action only. Its fundamentals baseline receives DecisionOffer, so it can see the hero's cards and public table state but no opponent hole cards. Post-flop equity is explicitly labelled as an estimate against unknown random hands; disagreement is "worth reviewing," never declared solver proof of a mistake.
  • Versioned player history survives process restarts in a small aggregate SharedPreferences store. The setup screen restores the last player/coach preference and shows cross-session agreement/review trends only after at least ten coached decisions; smaller samples are labelled as insufficient. Schema reads go through an explicit migration dispatcher; add the migration branch before incrementing PLAYER_HISTORY_SCHEMA_VERSION.
  • Opponents have deterministic offline table voices behind the language-only persona arbiter. A future network narrator implements the same nullable-text interface and inherits its timeout, fallback, output bounds, and stale-action checks without gaining access to poker decisions.
  • Gradle emits an archives deprecation from the Kotlin Multiplatform plugin's own jvm() target registration — upstream in Kotlin 2.2.10, not our build.
  • Not built yet: network-backed LLM narrator. The persona boundary and offline fallback are built.