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>
During an all-in runout, dealRemainingBoard() dealt every remaining card
without touching currentStreet, so a pre-flop all-in produced a terminal
snapshot labelled PREFLOP carrying a five-card board.
Setting currentStreet = RIVER would fix the label but leave a second problem:
the board jumped from empty to complete in a single snapshot, so the UI could
not animate the runout — the moment a poker table most needs to. Instead
dealRemainingBoard() is now suspend and publishes each street as it lands,
which keeps currentStreet honest as a consequence rather than as a special
case. Each runout street also sweeps its betting into the pot via
prepareRound(), matching the normal street transition.
Verified by reverting: the board went 0 -> 5 with the terminal snapshot
labelled PREFLOP, and three of the four new tests failed.
Tests: 48 -> 52, green on jvmTest and testAndroidHostTest.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two defects found reviewing the UI boundary before Compose work:
1. HumanAgent.awaiting was a plain mutable property written by the game
coroutine and read by the UI — a data race, and invisible to Compose. It also
exposed DecisionContext, which holds a live Seat whose fields mutate as the
hand proceeds, so even a safe read could observe torn state. Replaced with an
immutable DecisionOffer published through a StateFlow. A test mutates the live
seat after publication and asserts the offer does not change.
2. STREET_COMPLETE was emitted after dealing the new street but before the round
state was reset, so a flop snapshot carried pre-flop currentBet and
committedThisRound — the UI would have painted last street's chips in front of
every player alongside the new board. The reset is now prepareRound(), called
before publishing. Verified: with the ordering reverted the new test fails
with currentBet 10 on the flop.
Also:
- HumanAgent.cancel() is now covered directly; the previous test only cancelled
the coroutine running act(). cancel() and submit() both report whether anything
was actually pending.
- Android host tests enabled via withHostTestBuilder, so the shared suite runs
against the Android variant instead of the AAR merely compiling.
No librsvg needed for card assets: sips rasterizes the SVGs directly at exact
2:3 dimensions, court cards and patterned backs included.
Tests: 45 -> 48, now green on both jvmTest and testAndroidHostTest.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Build:
- :engine now uses com.android.kotlin.multiplatform.library (AGP 9.2.1), the
modern KMP Android integration, rather than plain androidTarget(). Produces
engine.aar alongside the JVM target; compileAndroidMain verified.
- Version catalog added; SDK levels match the other JSJ apps (compileSdk 37,
minSdk 26).
Engine:
- PlayerAgent.act() and Table.playHand() are now suspend, so a human player can
wait for input without blocking a thread. Bots are unaffected; the simulator
wraps in runBlocking.
- TableSnapshot/SeatSnapshot published after the deal, before and after every
action, and at the finish. Immutable, aliasing no live Seat state, giving
animation, hand history, saving, and replay one boundary to work against.
- Snapshots carry the whole truth; maskedFor(viewer) is an explicit step that
hides hole cards the viewer is not entitled to. Showdown reveals contenders;
folded hands never are.
- Terminal snapshots report the contested pot rather than 0. settle() zeroes
contributions when awarding, so the naive value was empty at exactly the
moment the UI needs to show what was won. Caught by a new test.
- HumanAgent suspends on a CompletableDeferred and clears its pending state in a
finally block, so cancelling an abandoned hand releases the wait instead of
stranding it. Re-usable afterwards; a stale submit returns false.
Tests: 37 -> 45. Chips still conserved, skill gradient still monotonic.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The KDoc claimed the worst hand is percentile 1.0, but percentiles mark the
START of each class's band, so the last class begins at (1326-12)/1326 ~ 0.991
and no real hand reaches 1.0. Documents that this is correct for gating, since a
class is admitted when its band opens inside the range. Also notes that the
unused key slots keep 1.0 as an unreachable sentinel.
Docs only; no behaviour change. 37 tests still pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
TDA Rule 47 — cumulative incomplete raises:
A boolean could not express "facing at least a full raise since acting", so
several short all-ins that together reached a full raise failed to reopen
betting. Seat now records lastActedAtBet (the currentBet when it last acted);
mayRaise() reopens when currentBet - lastActedAtBet >= minRaiseSize. This
subsumes the single-incomplete-raise case, so the hasActed reset in apply() is
gone.
TDA Rule 20 — odd chips:
Split-pot remainders were awarded in seat-list order. They now go to the first
winning seat clockwise from the button. The old test also never produced an odd
pot (20 chips heads-up), so it only ever proved an even split; it now builds a
genuinely odd 25-chip pot via a folded small blind and asserts which seat takes
the extra chip.
OpponentModel skipped events across hands:
It inferred a new hand from a shrinking history, but history is cleared each
hand: having consumed 3 events, first observing the next hand at 4 events left
4 < 3 false and silently dropped the first three. observe() now takes an
explicit handNumber, exposed via DecisionContext and Table.handNumber.
PreflopChart percentile semantics:
The 169 classes were ranked equally, but they are not equally likely — a pair is
6 of 1326 combinations, suited 4, offsuit 12. "Top 12%" therefore meant 12% of
classes, not of dealt hands, so looseness did not mean what it claimed.
Percentiles are now weighted by combination count.
Tests: 30 -> 37. Each new test was verified to fail with its fix reverted.
Skill gradient still monotonic: 85.9 / 79.8 / 17.4 / -183.1 bb/100 over 50k
hands, chips conserved on both tables.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Kotlin Multiplatform engine (JVM target only for now; androidTarget and
iosArm64 slot in without touching commonMain).
Core:
- HandEvaluator: single-pass 5-7 card evaluation, ~24M evals/sec. Verified
exhaustively against published frequencies for all 2,598,960 five-card hands.
- Equity: Monte Carlo with ties split. PreflopChart ranks the 169 starting
hands using all-in equity plus an explicit playability adjustment, so
looseness means "plays the top N%".
- Table: no-limit betting rounds, side pots, odd-chip splits, uncalled-bet
refunds, and incomplete (short all-in) raises that correctly do not reopen
betting.
Bots:
- SkillLevel and PlayStyle are orthogonal axes. Skill drives decision quality
(rollout accuracy, pot-odds discipline, position awareness, error rate);
style drives bluffing, sandbagging, aggression, tightness.
- BotMood gives tilt that persists between hands and decays.
- OpponentModel lets Advanced/Expert exploit habitual bettors.
- MathBot emits a DecisionTrace of the numbers behind each decision, which the
coach will later hand to an LLM to narrate. The LLM never does poker maths.
Simulator:
- 2,200-3,400 hands/sec. Deck RNG is separate from bot RNGs so rollout counts
cannot shift the deal.
- Controlled skill-ladder test asserts the difficulty gradient is monotonic:
73.9 / 53.9 / 27.6 / -155.4 bb/100 over 50k hands.
Assets: 52 CC0 English-pattern card faces plus generated backs.
Tests: 30 passing (evaluator, table rules, pre-flop chart).
Known open: win-rate magnitudes ~10x realistic and several profiles looser
than their labels. Tuning, not correctness.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>