Files
holdem_poker/CLAUDE.md
T
thejayman77 950c7ceb57 Playable Android table
The game runs on device: verified on a Pixel 10 Pro emulator (Android 17) by
installing, tapping through a hand, and confirming it advanced pre-flop to flop
with correct pot, folds, and re-offered action.

App:
- :app module on AGP 9.2.1. Note AGP 9 has built-in Kotlin support, so applying
  org.jetbrains.kotlin.android conflicts with it ("extension with name 'kotlin'
  already registered"); only android.application + kotlin.compose are applied,
  matching recipeze.
- PokerViewModel runs a continuous cash game and publishes to Compose.
- Compose table: opponents, board, pot, hero, action bar with a raise slider.

Frames are queued, not conflated. An all-in runout emits flop, turn and river
microseconds apart; pushing those into a StateFlow would collapse them and the
board would jump from empty to complete. The engine's suspending observer sends
into a Channel, a consumer paces each frame, and only then is StateFlow updated
— so backpressure paces the engine rather than the UI dropping frames. Three
tests cover this, including a characterisation test showing a conflating
StateFlow does lose the intermediate frames.

Assets:
- tools/generate_card_assets.sh rasterises the SVGs into four density buckets
  using sips, which renders SVG directly — no librsvg or ImageMagick.
- Resource names are prefixed card_ because Android resource names may not start
  with a digit (10_of_clubs would be rejected).
- CardArt.kt maps deck index to drawable via static R references, so R8 resource
  shrinking cannot strip the artwork the way getIdentifier lookups would risk.

Layout fixes found by actually looking at the running app: five opponents did
not fit a fixed-width scrolling row (Enzo was off-screen), the header collided
with the status bar clock, and the board floated against a large dead space.

Tests: 52 -> 55, green on jvmTest and testAndroidHostTest.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 17:23:34 -04:00

4.2 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 :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
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; it prints a controlled skill-ladder test that must stay monotonic.

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 50,000+ before believing a gradient.

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: skill gradient passes monotonically (85.9 / 79.8 / 17.4 / 183.1 bb/100 at 50k hands).

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.
  • Known-imperfect: win-rate magnitudes are still ~10x realistic, and several profiles are looser than their labels (the Rock plays ~38% VPIP, should be ~12%). Tuning is the open work.
  • 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: LLM persona layer, opt-in coach, Compose UI.