fffd60ad9d
The reported symptom was a "Rock" at 41% VPIP against a 12% setting. Chasing it uncovered four separate places where a *skill* parameter was smuggling in a *style* change — the two axes were supposed to be independent. 1. Error direction. blunder() pushed every mistake the same way, so a 30% error rate put any beginner near a 30% VPIP floor regardless of style. Making it style-directed fixed the Rock but collapsed the gradient, because a tight player's errors then became folds, which cost almost nothing. Errors are now split: pre-flop follows the player's character (a nit's mistake is folding a hand they should have played), while post-flop stays costly for everyone — paying off when beaten and checking back hands worth betting. That is also where weak players genuinely lose money. 2. potOddsRespect shifted weak players systematically toward calling. That is not a weakness — calling wider than break-even against bad opponents is a winning adjustment, so it handed low-skill bots a real edge. Discipline now means ACCURACY: a weak player misjudges the threshold in either direction. 3. OpponentModel treated 0.5 as a neutral bet/raise share. Folds, checks and calls are counted too, so a normal player sits near 0.32 — every opponent read as passive, and the only two levels that consult the model tightened against the whole table and lost money for it. The exploitation feature was a handicap. Baseline calibrated and named. 4. positionAwareness widened 45% in position but narrowed 25% out of it. A seat is last to act about a quarter of the time, so the tighter branch dominated and higher awareness silently meant fewer hands. Position now shifts WHERE hands are played, not how many. Also: the pre-flop slop multiplier now saturates (multiplying pushed a 0.75 maniac to 0.93 while still drowning out the tight end), raw pot odds carry an implied-odds discount, and skill levels are re-spaced. Results: Rock 41.3% -> 14.5% VPIP, every style ordered correctly by looseness, and win rates down from ~113 to ~20 bb/100 for a strong seat. HONEST LIMITATION: Advanced and Expert are not separable. Over 100k hands their order flips with the seed. The simulator now asserts each level beats the one two tiers below it — true on every seed tried — rather than strict adjacent ordering, which would be reading noise as signal. Separating the top two needs either a wider parameter gap or a different distinguishing mechanism. New ProfileBehaviourTest is the regression that was missing: it asserts styles actually produce their own behaviour. A gradient can look healthy while every profile is misnamed, which is exactly what happened. Tests: 154 -> 166 (75 engine JVM, 75 Android host, 16 app). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
5.1 KiB
5.1 KiB
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
- 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. - 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. - Pre-flop is range-based, not equity-based. All-in equity overvalues trash
(7-2o has ~35% vs one random hand but is unplayable).
PreflopChartranks the 169 starting hands soloosenessmeans "plays the top N%". - 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.
- A skill parameter must not smuggle in a style change. Several bugs came
from exactly this:
positionAwarenesssilently reduced hands played,potOddsRespectsystematically 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.
Testing notes
Tabletakes aCardSource, soStackedDeck.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: profiles play like their labels (Rock 14.5% VPIP against a 12% setting;
ProfileBehaviourTestasserts this). Win rates are in a plausible range — roughly +20 bb/100 for a strong seat rather than the earlier +113. - Adjacent top tiers are not separable. Advanced and Expert sit inside seed-to-seed noise of each other over 100k hands. The simulator therefore asserts each level beats the one two tiers below it, which holds on every seed tried; claiming strict adjacent ordering from one seed would be reading noise as signal.
Rules invariants that are easy to get wrong
- Reopening betting cannot be a boolean.
Seat.lastActedAtBetrecords the bet level a player last acted at; betting reopens when `currentBet - lastActedAtBet= minRaiseSize`. Several short all-ins can reach that together.
PreflopChartpercentiles 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.historyacross hands must key offhandNumber. 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
archivesdeprecation from the Kotlin Multiplatform plugin's ownjvm()target registration — upstream in Kotlin 2.2.10, not our build. - Not built yet: LLM persona layer, opt-in coach, Compose UI.