← poker-tutor
# Poker Tutor
A poker coach that watches you play, finds the holes in your game, and keeps raising the
difficulty as you close them. Mac first, iPhone and iPad from the same codebase later.
Full plan: `~/.claude-personal/plans/linked-swimming-bunny.md`
## Where things stand
| Phase | What it is | Status |
|-------|-----------|--------|
| 0 | Cards, shuffling, hand ranking, equity | done |
| 1 | Betting engine, side pots, tournament structure, ICM | done |
| 2 | Computer opponents with personalities and a skill dial | done |
| 3 | The coach: mistake detection, leak taxonomy, skill rating | done |
| 4 | The Mac app you can actually see and play | done |
| 5 | Assessment, drills, ladder, hand history | done |
| 6 | Cash game mode | engine and table done (no-limit, pot-limit, fixed-limit; carry-over stacks, rebuys); the cash coaching is next — `docs/SPLIT-PLAN.md` |
| 7 | iPhone and iPad | builds and runs in the simulator; never been on a phone |
| 8 | The App Store, iPhone then Mac | blocked on an Apple Developer account — `App/AppStore/SUBMISSION.md` |
| 9 | Android, from the same Swift | builds, installs and plays on a Pixel 7 emulator; never been on a real phone |
Phase 5 is finished in the sense that all four things exist, are tested and can be used end to end:
sit an assessment, read the scorecard it produces, turn any row of it into a drill, get a check back
days later, and climb a ladder of tables off the hands that came out of all of it. What is not
finished is anybody having played a few hundred hands through it, which is the only thing that will
say whether the bars are set in the right places.
Phase 7 is honest rather than modest. The phone app compiles and runs, every screen lays itself out
for a thumb, and the suites cover the parts it draws — but nothing has been on a real device, so
nothing is known about how it feels to play a hand on one, and "runs in the simulator" has never
been the same claim as "works".
## Running it
```
./Scripts/build-app.sh # builds build/PokerTutor.app — double-click it, or `open` it
./Scripts/build-app.sh release cash # builds build/PokerTutorCash.app, the cash product
open App/PokerTutorPhone.xcodeproj # the iPhone app: two schemes, PokerTutorPhone and PokerTutorCashPhone
```
Two products come out of this one codebase — **Poker Tutor** (tournaments) and **Poker Tutor
Cash** — told apart by `AppVariant`, which the app shell chooses once. Each has its own bundle
id, history folder, settings keys and subscription. The cash app lets the player pick the blinds
and how betting is limited (fixed-limit, pot-limit or no-limit), sits everybody down with a
hundred big blinds, carries stacks from hand to hand, buys the busted back in, and counts the
session in big blinds when they cash out. The engine takes the structure in `HandConfig`;
`pokerlab --format cash --structure fixedLimit` sits the command line at that table.
Both are free for the first hundred hands, the forty-hand assessment included, and then a yearly
subscription. What the free hands buy is deliberately one thing: the player's **most common**
habit — not the most expensive, which is how the scorecard itself ranks — named in full with its
drill open, while every other habit on the scorecard, the report and the drills list is present
but blurred, so somebody can see how much else was found without being told what. `FreeView` in
`PokerSession` is the one place that decides; every screen asks it and none works it out alone.
The naming rule does not bend for it: a habit is shown only once the hands support naming it, and
with nothing proved yet the most common watched habit is shown and labelled as not proved.
The phone app runs in the simulator as it stands. Putting it on a real iPhone needs an Apple ID
set as the signing team in Xcode — nothing else about it changes.
### Android
The Android app is the same Swift, compiled for Android by [Skip](https://skip.dev) and drawn
through Skip's SwiftUI. Nothing about it is written twice: `Sources/PokerTutorAndroid` is a
twenty-line shell around `RootView`, `Skip.env` names the app, and `Android/` is the Gradle
project Android needs to start anything at all.
```
export JAVA_HOME=/opt/homebrew/opt/openjdk/libexec/openjdk.jdk/Contents/Home
export ANDROID_HOME=~/Library/Android/sdk
export PATH=~/.swiftly/bin:$JAVA_HOME/bin:$ANDROID_HOME/platform-tools:$PATH
~/Library/Android/sdk/emulator/emulator -avd pokerphone & # a Pixel 7, once it exists
gradle -p Android installDebug # build the app and put it on the phone
adb shell am start -n com.thedigitaladlab.pokertutor/poker.tutor.android.MainActivity
```
Everything Android-only in `Package.swift` is declared only when `ANDROID_HOME` is set, so the Mac
and iPhone builds neither fetch nor link any of it. Three things Skip taught us the hard way, all
recorded in the code where they bit: every module with views in it must carry a `Skip/skip.yml`
and the `skipstone` plugin or its views draw as nothing; view state and view types cannot be
`private`; and a public signature cannot say `UserDefaults` bare, because the plugin shadows the
name. If a Gradle build "succeeds" but the app looks stale, grep its log for `error:` — a failed
Swift step does not always fail the build. `Scripts/android_port.py` applies the whole recipe to a
sibling repo, which is how PLO Tutor got its Android app.
Not there yet: table sounds (silent on Android), Google Play billing (the paywall says
subscriptions are not available on this device), the tab icons Skip cannot map, and a real phone.
Tables are nine handed: you and eight opponents. Tournaments start with nine and pay three.
```
swift build -c release
./.build/release/pokerlab test # all test suites
./.build/release/pokerlab test --filter "side pots" # one suite
./.build/release/pokerlab hand --players 6 --seed 12 # deal and play one hand
./.build/release/pokerlab tournament --players 9 # play a tournament to a winner
./.build/release/pokerlab shootout # measure the opponents against each other
./.build/release/pokerlab ablate --style solidRegular # what each parameter is actually worth
./.build/release/pokerlab diagnose # where the chips go in one matchup
./.build/release/pokerlab coach --player bb --hands 1000 # read the coach's verdicts
./.build/release/pokerlab assess --player bb # sit a scripted player through an assessment
./.build/release/pokerlab sweep --player control --hands 10000 --seeds 16 # where every bar came from
```
`--player` picks a scripted player with one deliberate flaw: `bb` folds every big blind,
`limps` never raises first in, `river` never value bets the river, `turn` never value bets the
turn, `flop` folds to every flop bet, `hero` calls every river bet, `minbet` and `overbet` size
every flop bet wrongly in opposite directions, `loose` plays far too many hands, `control` has no
flaw at all. Every one of them exists so a bar can be measured against both the player who has the
habit and the player who does not; a bar set from one population only is how three of these were
wrong.
Give each one enough hands to clear the floor its habit sits behind, or the coach will correctly
say it cannot tell yet and you will read that as the coach missing it: `bb` 1,000, `limps` and
`loose` 420, `flop` 1,500, `turn` 4,000, `river` 8,000, `hero` 12,000, and `control` 14,000 before
silence means anything.
## Layout
```
Sources/
PokerCore/ the rules of poker — no UI, no platform code. The iPhone app reuses this untouched.
PokerAI/ the opponents: styles, the skill dial, what they infer from watching you
PokerCoach/ mistake detection, the leak taxonomy, the skill rating
PokerSession/ one sitting at a table: deals hands, runs the opponents, feeds the coach,
writes every hand down, and reads a stack of them back as analysis
PokerUI/ every screen, for both platforms, plus the table sounds
PokerTutorApp/the Mac app — a window around PokerUI, and nothing else
TestKit/ a small test harness (Apple's frameworks need a full Xcode install; this doesn't)
PokerTests/ the suites
pokerlab/ command-line driver
App/ the iPhone and iPad app — one file, and an Xcode project around it
```
The rule that keeps the iPhone port cheap: **no poker logic in the app layer.** `PokerCore` knows
nothing about screens, and `TableSession` — which is what actually plays a session — knows nothing
about SwiftUI. `AppState` only decides which screen is showing. That is also what makes a session
testable: the suites drive `TableSession` by calling `act` exactly as a person would, so the parts
the app merely draws are covered by tests rather than by clicking.
`AppState` itself is driven the same way now. It has a second initialiser that takes a session and
makes no noise, and one method — `stepOpponent` — that is the dealer's own work with the waiting
taken out, so the suite plays a hand exactly as the window does and can then ask what the window
would be showing. That was worth adding the day a table that kept dealing to an empty chair survived
four rounds of review: everything about it was true only while somebody was clicking.
Two things that look like details and are not:
- Anything remembered about a player is keyed by **who they are, not where they sit**. Seats are
rebuilt every hand as players bust, so a seat-keyed lookup starts putting the wrong style badge
under the wrong name a few hands in.
- An illegal action is refused and changes nothing — including the coach's record. A mis-click
must never be filed against the player as a mistake.
- The opponents are dealt out one decision at a time on a timer, so a hand can be watched instead
of just arrived at. That is pacing only: a test plays the same seed both ways and asserts the
poker is identical, because the day it is not, every measurement in the project is wrong.
- The opponents' playing styles are never shown at the table. Working out who is loose and who is
a nit is the read the player is there to learn to make; printing it under their name hands them
the answer. `TableSession` still knows, for the coach and the tests.
- The coach interrupts only when it has something to say. A hand played well gets a thumbs up on
the felt and deals on — stopping someone to tell them they did nothing wrong costs attention and
teaches nothing.
- Leaving the table by the sidebar or the tab bar **pauses** it. The opponents stop acting, and a
hand that finishes while the player is on another screen opens nothing: the review waits under the
felt for them to come back. Nothing is discarded — a tournament somebody is forty hands into is
not something to throw away because they glanced at their stats — and nothing is dealt to an empty
chair. The Leave button is still the way to put a sitting down.
The blind ladder is written out rather than generated — 25/50, 50/100, 100/200, 200/400, 500/1000,
1000/2000, 2000/4000, 4000/8000 and up. Every level, and every ante, is payable in the chips that
are actually on the table; a 150 blind is not a level anyone would run when the smallest chip is 25.
Everyone bets in the same denominations. The opponents round every bet to whole chips through the
one place their actions are already made legal, so a bot never pushes out 437 next to a chip tray
that cannot make 437. The chip in play scales with the blinds, the way a real tournament colours the
small ones up and takes them off the table.
Bets are counted out in chips — 25 up to 10,000 — rather than dragged on a slider. Click the
hundred twice and the fifty once and you have bet 250. It costs a click or two more than a slider
and is far more deliberate, which is the point: a bet you had to count out is a bet you thought
about. Undo puts the last chip back, All in counts the stack out for you, and the Bet button stays
unavailable until what is on the table is actually a legal raise.
There is one screen layout, not two. `Metrics` decides the sizes from the space available, and the
controls get *larger* on a phone, not smaller — a thumb needs more room than a pointer.
The table sounds are synthesised by `Scripts/make-sounds.py` rather than recorded, so nothing has
to be licensed and the repo stays self-contained.
## The history
Every hand is appended to `hands.jsonl` in Application Support as it finishes, one JSON object per
line. A line per hand rather than a database, for a specific reason: a write cut off half way loses
one hand instead of the history, and a test asserts exactly that. Appending finishes the broken
line before it starts a new one, which is not fussiness: a torn write leaves the file ending without
a newline, and writing straight onto the end of that fused the next hand into the fragment and lost
it too — so a crash cost two hands, the second of them played after the crash, in the one file
format chosen specifically to survive being cut off half way. It is also plain text — anyone can
open it and check the app is telling them the truth, which matters for a tool whose whole job is to
be honest about someone's game.
Saved hands are made of strings and numbers, never the game's own enums. A hand recorded today has
to still be readable after those types have been renamed, and they will be.
Resetting the history moves the old file to `hands.previous.jsonl` rather than deleting it. Reset
is one tap away from a year of hands, and a mis-tap should not be the end of them.
Both screens default to **everything ever played**, not the current sitting. Defaulting to the
sitting meant that on a fresh launch — a brand new, empty sitting — a full history looked like no
history at all, which is exactly how it was first reported: "hands does not seem to be storing
history". It was storing it. It was filtering it out.
The Hands and My game screens both read the history rather than memory, scoped to this sitting, to
one earlier sitting, or to everything. The numbers are the ones a poker player already knows —
VPIP, PFR, showdown rates, big blinds per hundred, by seat — so they are comparable with what they
read anywhere else. Under 200 hands the screen says so rather than letting a win rate off 30 hands
look like a fact.
My game leads with the scorecard: the player's weaknesses, worst first, ranked by what they have
actually cost in big blinds. It is built from the written-down hands rather than from the coach's
memory, which is what lets it follow the same scope bar as everything else on the screen and be
there again tomorrow — memory starts empty every launch, so a scorecard read off it tells someone
with a year of hands behind them that they have no leaks. Four refusals run through it and they
are all the same refusal: say only what the hands support. A mistake seen twice is never ranked
above a habit seen forty times — it is put under a heading that says so, with the number of chances
it would take to call it. A cost that cannot be worked out from what was recorded is reported as
unknown, never as nought, because nought reads as "this one is free". How often a habit happens is
counted over every hand in view and what it costs is not: an assessment sits somebody in the big
blind and on a short stack far more often than a real table does, so "about 8.0 for every 100 hands
you play" is worked out over the hands that were dealt rather than arranged, and where every hand in
view was arranged the money is left blank. The rate is the fair half of an assessment and the money
never was — the app already kept those hands out of the win rate, the VPIP and the by-seat table,
and then let them into the one figure it prints in big blinds. And a player with nothing
much wrong is told exactly that instead of being handed a manufactured list; the empty scorecard is
the good outcome, and a coach that invents work to look busy is one nobody believes twice. Each row
also says which seat the mistake mostly comes from, but only where it really is mostly one seat —
three in five, off at least three instances — rather than finding a pattern in noise.
The per-hand screens agree with that card, which took a third state to arrange. The coach speaks
only about a mistake worth stopping the game over; the scorecard counts anything it put a price on
at all, because opening too many hands costs a third of a big blind at a time and is ruinous forty
times an hour. Those two bars are different on purpose and the higher one is not moving. What was
wrong was the other end: a hand holding three counted instances of the habit at the top of somebody's
list was shown to them as "clean", with "no comment" against each decision and the replay saying
calling it a mistake would be guessing. Every screen they opened to check the coach's work denied
the finding. So a hand now reads clean, counted or flagged, a decision reads too close to call,
counted or flagged, and the coach still says nothing at the table about the middle one.
Two numbers are called "rating" and they are both on screen at once on a Mac, so they are named
apart rather than reconciled. The one in the corner is every hand ever played; the ladder's is at
most the last four hundred at the table you are on now, and it is the one a promotion turns on. A
forty-hand assessment moves the first and cannot touch the second, so they disagree, and "you rate
47, you need 52 to move up" beside a permanent "RATING 62" reads as an app that has forgotten to
promote somebody. Making them one number would mean either promoting off hands played at a different
table or throwing away the all-time figure, and both are worse than a label.
Before there is a hand behind it, neither of them is a number at all. An empty history rates
everybody at 50 on purpose — the ladder needs somewhere to place a new player from — but the middle
of a scale is not a finding, and Home used to draw "50" in inch-high type beside "Solid amateur"
over "0 hands watched", about somebody the app had never seen play. It is a dash now, with no label
and no bar, which is the same refusal the statistics panel three inches away already made.
Seats are numbered rather than only named. The small blind is 1 and the dealer is always the last
number, so "seat 3 of 9" says the thing "UTG" was standing in for: where you are sitting and how
many people still get to speak after you. The traditional names stay alongside the numbers, because
everyone else at a card table uses them, and picking a seat on the My game screen draws the table
and says in plain words who acts after you from there. Heads-up is numbered big blind 1, dealer 2 —
the button posts the small blind two-handed, and numbering it 1 would make the number go down as the
seat got better. A saved hand records how many were dealt in as well as which seat, because the
cutoff is seat 8 of nine and seat 5 of six and a tournament table passes through both; that field is
optional, so every hand written down before it existed still reads.
Any hand can be played back a step at a time, forwards or backwards: the shared cards come out as
they came out, the pot grows the way it grew, every action is attributed to a numbered seat, and the
coach's read sits under the decision it was actually about. It is deliberately not the table — it
steps in both directions, nothing is on a timer, and it never makes a sound, because a screen
somebody opened to think quietly on is not a screen that should start dealing chips at them. The
transcript this needs is written down with the hand — who did what, in which round, and what the pot
stood at afterwards — as optional fields, so hands recorded before it existed still load and simply
offer the summary instead. Adding a *required* field would have been the expensive mistake here:
`HistoryStore.load` skips any line it cannot decode, so one non-optional addition would have deleted
every saved hand with no error message anywhere. A test loads a line written in the old format and
fails if it ever stops reading.
The reconstruction is in `PokerSession`, not in the screen, for the same reason as everything else:
a test plays a hand through a real session, saves it, reloads it the way the next launch would, and
asserts the replay reproduces the same rounds of betting, the same actions in the same order, and
the same pot after every one of them.
## The assessment
Forty hands with a beginning and an end, which produce a starting rating and the first list of
what a player's game is costing them. It is a measured sitting rather than a game, and the whole
of what that means is said before it starts rather than in a footnote at the end.
Each hand is set up to look at whatever is least understood so far. Three things are chosen: which
seat the player sits in, how many big blinds everybody has in front of them, and which of the eight
opponents sits where — the last one because sitting the people who raise constantly on somebody's
right is how you get them a hand that starts "somebody has raised, now what". **The cards are never
chosen.** That is the line the whole measurement rests on: within any one situation the hands dealt
are as random as they are anywhere else, so how often the player got a decision wrong *in* that
situation is a fair number however often they were put there. What it is not fair for is money —
they spend far more time in the big blind and on a short stack than anyone does at a real table —
so assessment hands are counted towards the leaks and kept out of the win rate, VPIP and by-seat
tables, with the screen saying so rather than letting the numbers quietly stop adding up.
Which hand to deal next is a greedy choice: for each way of setting the table up, add up how likely
it is to produce each kind of decision, weighted by how little is known about that kind of
decision, and take the best total. Two things in that sentence were learned the expensive way.
The first is that "how likely it is to produce" cannot be a fixed belief. A setup that promises
river decisions and then never gets anybody as far as the river will keep promising them for forty
hands, and the first version of this dealt the same setup forty times waiting for a river that was
never coming. What actually happened now outvotes what was expected after about three hands of it.
The second is that "how little is known" cannot only be a standard error. A situation seen four
times has a respectable-looking standard error and is worth nothing, because nothing can be said
off four chances — so until there have been six, another hand there is worth exactly as much as a
hand in a situation nobody has ever been in. Above that floor, a situation whose error rate is
sitting above where a steady player sits pulls harder than one that came back clean, because forty
hands cannot measure eleven situations properly and the ones that are already settled should be
left alone.
The report is in two tiers and the split is the honest part. Something is **named** only when even
the cautious end of what those chances can show is still worse than a steady player; everything
else that turned up too often is listed as where to look first, saying in as many words that it is
not proved. Forty hands clears the first bar for a loud habit — playing far too many hands shows up
in every unopened pot — and often cannot clear it for a quiet one, because folding the big blind
too much only shows up when somebody raises at it *and* the hand was worth continuing with, which
is six to eleven chances in a sitting this long. The rating comes with the range the hands actually
support rather than as a number on its own, and it is held near the middle on purpose: at forty
hands only a fifth of what they say is let through, because forty hands cannot tell an unlucky
evening from a real weakness. The situations it never got near are listed by name, because silence
about a spot nobody was ever in reads as "you are fine there".
The report and the scorecard used to be two bars for one question, and on a fresh install they
contradicted each other on the same screen. The report names a habit when even the cautious end of
what its chances can show is still worse than the bar, which an assessment can clear off eight; the
scorecard names one when there have been as many chances as the catalogue says it takes, which for
that habit was twenty-five. So the first thing anybody ever did produced a card reading "One habit
was already clear enough to be worth naming" beside another reading "Nothing confirmed yet", over a
row whose own note said nothing is called a habit here until there have been 25. Neither bar moved.
The report carries the row instead — printing its own numbers and saying where they came from,
exactly as a failed check does — and only where the hands in view have too few chances to speak for
themselves. Past that they are the better evidence and they decide, in either direction.
Stopping half way costs nothing. The hands played stay in the history as the ordinary evidence they
are, and the run is rebuilt by replaying them — there is no second copy of the progress to fall out
of step, because there is no second copy. A test stops a sitting at fifteen hands, throws the
session away, builds a new one off the same file the way a fresh launch would, and asserts the
picture of what has been seen came back identical.
## The drills
Any habit on the scorecard turns into a set of hands that do nothing but hand you that one
situation. The arrangement is the assessment's, aimed at one spot instead of eleven: the table is
set up whichever way is likeliest to produce the decision, and if that arrangement stops producing
it — which happens, because beliefs about poker are wrong all the time — it is dropped after about
three hands rather than repeated for forty. The cards are never chosen here either.
The arrangement is chosen per **habit**, not per situation, and the difference is not a nicety.
Three habits live in "an unopened pot" and two of them want opposite ends of the table: playing too
many hands is a first-seat problem and not attacking the blinds is a last-seat one. Ranked on the
situation alone, all three went to the first seat to speak — where every hand is an opening chance
and almost none of them is worth raising, so twenty-odd correct folds finished the drill and
finished it for a player with the habit too, while the sheet a few lines above the felt said "seats
9 and 8, the button and the cutoff". Where a habit names the arrangement it needs, that one is the
only one on offer and the yield above cannot wander off it, because for those habits the seat is
what the habit *is*. Six seeds of the full drill, sittings cleared, sound player against the
matching flawed one: playing too many hands 6 and 0, limping 5 and 1, not attacking the blinds 5
and 3. The last of those is honest rather than good, and why is worth knowing — the habit can only
show itself in the third or so of unopened pots that were worth raising, while the number of
chances it takes to name it is counted over all of them. Making that tighter means re-deriving two
numbers for all eighteen habits from a sweep rather than editing one.
**When it ends is the whole design.** Not after a round number of hands: twenty hands of practice
prove nothing if the situation only came up twice, and one good decision proves less than that. It
ends when two things are true at once — the rate is at or under what a steady player manages, with
one imaginary slip added to the count so the arithmetic errs against clearing, and there have been
at least as many chances as the catalogue already says it takes to *name* that habit. Both are
needed. Without the rate it would clear on time served; without the sample it would clear on four
hands of luck. That second number is per habit, so folding the big blind too often takes 50 chances
to clear and folding to every re-raise takes 15, and the drill tells you how many more it wants as
it goes. Get one wrong and it wants a few more, which is the point of it. A drill that runs its full
length without clearing says so in as many words, because a drill that reported a fix there would be
lying about the only thing it is for.
The banner that says a sitting cannot finish today now says *why*, because there are two reasons and
it used to blame the player for both. Too many slips is one. The situation simply not coming up
often enough per hand is the other, and it is the common one — on hand one of a turn drill, with
nothing played and nothing wrong, the banner read "Too many have gone wrong for this sitting to
clear it". The two are told apart by running the same arithmetic again with the slips taken out: if
a spotless record would not have reached the finish either, the deal is what is in the way, and the
sentence says that instead.
**Clearing it is not the end.** An evening of getting something right while you are watching for it
is not the same as having stopped doing it, so clearing puts a check in the diary five days out
rather than closing the matter. The check is twelve chances at the same situation and then an
answer either way — a check allowed to run until it liked the result would not be a check — and one
slip does not fail it, because twelve chances cannot tell one bad decision from a habit coming
back. The check is judged on the number it prints, which sounds like a truism and was not one: it
used to be judged on the raw rate while the screen showed the rate with the imaginary slip in it,
so a check could read "It held up — 2 of 12 went wrong" beside "which makes it 21%, against the 18%
a steady player sits at", with the bar past the mark. The forgiven slip is now the only place the
answer and the printed number part company, and the report says which of the two carried it rather
than leaving somebody to work out why 14% passed against an 8% bar. Pass and the gap doubles: the
first check asks whether the fix survived the week, the third asks whether it survived not thinking
about it at all. Fail and the habit goes back on the scorecard with the row saying exactly what
happened, rather than being quietly forgotten. Reopening something needs less evidence than naming
it did, and deliberately: it was already measured well enough to be worth drilling. The numbers on
that row are the check's own, and they have to be: drills are read over everything ever played while
the scorecard follows the scope bar, so pointing the screen at one sitting used to produce a row
under the heading "what is costing you" whose next sentence read "This has not come up yet".
Where the diary lives is the same answer as the ladder and the assessment: nowhere. Every drill hand
records which habit it was drilling and whether it was a drill or a check, and the date the next
check is owed is counted back off those hands. There is no second file holding a date that could
disagree with what was played, which is exactly why a check scheduled for next Tuesday survives the
app being closed — the evidence for it survives, so it does. A test writes a cleared drill to a file,
opens it again the way the next launch would, and fails if the check is not sitting there waiting.
The clock is passed in rather than read, so a fortnight of scheduling is tested in a millisecond.
**Drill hands are kept out of the leak scorecard**, and that is a judgement rather than an oversight.
Practice at one situation, with you watching for it, is not how you play when it turns up
unannounced — and letting those decisions into the count would let anybody drill a habit off their
own record without playing a single hand of poker differently. They stay out of the win rate too,
for the same reason assessment hands do, and the screen says so. What counts is the check days
later.
The bar is demanding rather than impossible, and it was measured rather than guessed: across four
seeds a sound player clears the big blind drill three times in four, and a player who folds every
big blind clears it none. Both halves of that matter — a drill nobody can finish teaches as little
as one everybody finishes.
## The ladder
Five tables, and what makes one harder than the last is who is sitting at it. That is not a
stylistic choice — the shootouts measured style as worth roughly ten times what the skill dial is
worth, so a rung is written down as a cast of eight people rather than as a number. The bottom rung
seats seven players who call too much and raise too little; the top seats none of them. The skill
each of them plays at climbs alongside, second, and is pulled into whatever band the style can exist
in, because a calling station is a losing style by definition and cannot be an expert one. The cast
is fixed and the seating is not: the same eight kinds of player are shuffled into different chairs
every sitting, so two evenings at the same table are not the same evening twice.
Promotion and demotion are both real, and both need two hundred hands at the current table before
anything happens. Somebody can win a whole tournament and still be exactly where they were, which is
the point — over forty hands a good player and a lucky one are the same player. The rating that
moves you up off a rung is always twenty points clear of the one that would send you back down to
it. Without that gap, a player sitting on a bar gets carried up on one sample and back down on the
next for as long as they keep playing, and a ladder that moves somebody every fortnight in
alternating directions has told them nothing. A test feeds it evenings that alternate between well
past the promotion bar and well under the demotion one and fails if the player is ever moved.
Where the standing lives is the same answer as everywhere else here: nowhere. Every hand records the
rung it was played at, and the standing is counted back off the file, so there is no second copy to
disagree with the hands. Everyone starts at the bottom unless there are already two hundred hands
saying otherwise — the rating off a forty-hand assessment is held near the middle on purpose, and
placing somebody off it would be reading a number the hands do not support. Assessment hands are not
results at all: the seat and the stacks were chosen, so they carry no rung and can never move
anybody.
Finding this needed one real bug fixed on the way. A hand can be over the moment it is dealt — once
a stack is short enough that the blind puts it all in and everybody else covers it, nobody has a
decision to make, so nothing ever steps the hand and nothing closed it. No review, no row in the
history, and the tournament was never told anybody busted, so the table dealt hand after hand that
finished before they started, for ever. It was only reachable at a table loose enough to leave
somebody sitting on a stack that small, which is exactly what the bottom rung is.
## Reading it
The colours can be switched to a scheme with no red-green pairing anywhere, because the default
says good in green and bad in red — the one pairing eight percent of men cannot separate. Chips
carry their value in writing as well as in colour, so the colour is always a shortcut rather than
the only signal.
The type comes in four sizes, small through extra large, chosen in Settings by reading a sample of
each. Everything moves, but not all of it moves the same distance: the coach's write-ups — the
reason the app exists — go all the way, while words with a fixed-size box around them stop at
Large. A seat name, a chip and a button under the felt cannot grow past the thing they are printed
on without pushing the table off the screen, so they don't.
Any poker word the app could not avoid can be tapped, and the plain English opens where you are
reading rather than on some other screen — the statistics on My game, the old seat names, the words
in the coach's write-ups. Only the first appearance in a paragraph is underlined: a paragraph with
every occurrence marked is one nobody reads. The whole list is in Settings to be browsed instead of
stumbled on. None of this is a licence to write jargon — it is there for the words that have no
plain equivalent, which is essentially the statistics, and for the traditional seat names now
printed under the numbers.
The rule is enforced rather than remembered: a test walks every word the screens show and every line
the coach writes and fails if either uses a term with nothing behind it, which is how "range",
"shove" and "heads-up" came out of the drills. That test used to stop at the edge of the screens,
and the hole showed. It walked the leak catalogue, the assessment, the ladder and the drills —
everything with a testable string on it — while the writing a person actually reads sat in `Text(…)`
calls no test could open, and the commit whose whole purpose was taking one poker word out of the
app left that same word on the My game screen. The screens' prose now lives in `Copy`, in `PokerUI`,
and the suite reads it. What moved there is the writing: sentences, paragraphs, the subtitle under a
heading. What stayed where it was is the labels — buttons, column headings, a number with a word
beside it — because there is no sentence in them to get wrong. That is the limit, and it is worth
knowing: a poker word used as a button label would still have to be caught by reading.
The same suite runs a second list over all of it, and over every comment in `Sources`: the handful of
phrases that are banned outright rather than merely unexplained. Those are habits of writing rather
than vocabulary, they spread by being copied, and each of the ones on the list was found by hand in
one file after surviving in four others.
## Notes for anyone reading the code
- Every hand is driven by a seed, so any hand can be replayed card-for-card. The hand history
browser and the tests both depend on this.
- `HandEngine` is the only thing that decides what is legal. Bots, the coach and the UI all read
`legalActions()` and call `apply(_:)` — none of them re-implement the rules.
- Antes are dead money, tracked separately from wagers. Treating them as bets creates a phantom
side pot the big blind wins back every hand, and can stall a tournament permanently.
- Uncalled bets are refunded against the current betting round, not the whole hand — dead money
otherwise disguises part of the overbet.
- **Nothing that decides anything may read a dictionary or a set in its own order.** Swift shuffles
that order every time the program starts, so anything downstream of it answers differently on
each launch from the same seed. A range of hands is stored as a dictionary, and two things read
its keys in order: drawing a sample for an equity estimate, and breaking the tie between two
hands of exactly equal strength when a bet narrows the range. Both were therefore giving the
coach a slightly different answer every run — same seed, same cards, different verdict. It
surfaced as a check count that would not sit still between two runs of the same binary, which is
a small symptom of a large problem: a hand that cannot be replayed to the same conclusion makes
every measurement in the project a measurement of the launch it was taken on. `Combo.order` is
the fixed order, and the keys go through it before anything is done with them.
- A test that loops over whatever a session produced has to check the loop ran. An assertion inside
a loop over an empty list is an assertion that never happened, and the suite reports it as a
pass. Several of the loops here now count first and fail on nought.
## The cash app's coach
Poker Tutor Cash reads a second edition of everything above that depends on the game: the habit
catalogue (the two short-stack habits go; putting a whole stack in with one pair, calling a re-raise
with a small pair the money behind does not pay for, and folding a hand it does pay for come in),
the rating line (a cash table charges a sound player about fifteen big blinds a hundred to a
tournament's ten, because every pot is deep, so the two ratings are not comparable across the
apps; one decision is charged at most thirty, and a
preflop fold at most six, because a single misjudged four-hundred-blind pot should not rate
anyone zero), the ladder (four stakes, 25/50 to 500/1000, promotion on the same four hundred hands), the
assessment's arrangements (two hundred big blinds in the cutoff instead of eleven in the hijack),
the lessons and the tutorial. The bars were re-swept at the app's own nine-handed cash tables and
the bands are in `Leaks.swift` beside each number; `docs/SPLIT-PLAN.md` has the ledger. A player
who puts a whole stack in with one pair on every flop costs sixty big blinds a hundred by this
pricing and rates ten, the bottom of the scale, which is where that player belongs — though the
habit itself is priced and not named, because no sound player ever reached the twelve stack-off
spots its bar would need, so the bar was never measured against one.
Pick fixed-limit on the setup screen and the app reads a third edition, because fixed-limit is a
different game and none of the arithmetic about bet sizes applies to it. A bet is a fraction of the
pot by rule, so the price of a call is nearly always right and the expensive habits are folds: a
draw let go for one small bet, a river folded to one bet in a pot of twelve. A bet folds almost
nobody, so raising is how the best hand is paid, and the player who acts last can raise a draw on
the flop and, about half the time, see the turn card for free. So the limit coach judges with its own ranges
(`ReferenceRanges.Limit`: wider everywhere, raise-or-fold first in, cold-call only with hands that
play, defend the big blind very wide, cap with the top of the deck), its own postflop judgement
(`LimitEvaluator.swift`: one bet or raise on offer, a fold rate a few percent, the free card priced
at a few tenths of a small bet — the raise is about even with a call on price alone, and earns
its keep from the pots it takes down and the extra bet in when the draw arrives), its own habits (folding a draw to one bet, folding the turn with the
price, calling two bets cold, calling the last bet with nothing, folding the river to one bet,
capping light — and two it prices but does not name, calling with the best hand instead of raising
and missing the free-card raise, because the sweep could not separate the player who has each from
the player who does not), its own lessons, and bots that play the game (wider, calling at the
price, raising top pair, the free-card raise, calling down with a pair). The tournament and the
no-limit cash coach are byte-for-byte unchanged by it: the tournament control still reads 150
decisions and 13.2 bb/100 on seed 4242, and the no-limit cash control's sweep is identical between
the code before and after. On the limit line a sound player costs about nine big blinds a hundred
and rates 66; the player who folds every draw costs forty-three and rates 10.
## What the measurements showed
Every claim about the opponents is measured, not asserted. Two things are worth knowing before
changing any of it.
**Measuring poker is harder than playing it.** A straight match cannot tell a small skill edge
from a run of good cards inside any sane number of hands. Matches are therefore *duplicated* —
every hand is dealt to both sides with the seats swapped, and each seat's random choices are
reseeded identically, so two identical bots measure at exactly zero rather than approximately
zero. A test asserts that. Full tables rotate the measured player through every seat on the same
deal. Without this, tuning is just reacting to noise, which is what the first three attempts here
turned out to be.
**Two of these measurements were passing on luck**, and only admitted it when chip rounding
reshuffled the seeds. A single 700-hand match between an accurate reader and a careless one swung
between 1 and 26 bb/100 depending on nothing but the seed. The rating comparison was worse: at 420
hands it called the flawed player correctly five times out of ten — a coin flip — and needs four
thousand hands before it separates cleanly. Both now measure across several seeds at a sample that
means something. The suite takes about twenty seconds as a result, which is the price of the numbers
being true.
**And 2,500 hands was still not enough for the first of them.** The fix for that 700-hand match was
2,500 hands across three seeds, which read as stable because the three seeds it happened to pick
were. Swept properly — 24 seeds, same bots, same 2,500 hands — it runs from **-2.6 to +38.4 bb/100**,
and on one seed in twenty-four the accurate reader loses outright. The same 24 seeds at 6,000 hands
run from **12.8 to 37.7, mean 23.8**, which is the number this measurement has always been claimed
to be. So the test is six seeds at 6,000 hands now, and it prints the mean and all six seeds as it
passes, because a number nobody can see without breaking something is a number that drifts.
That is also how the bars under it were chosen, rather than by taste. The per-seed bar is 5.0 and the
mean bar 15.0: the first is under half the worst of the 24 seeds measured, the second is three and a
half standard errors below the measured mean. Both are far enough below the truth to survive an
unlucky evening and far enough above nought to catch a real loss of the skill dial — the bar they
replaced was "every seed above nought", which would have failed on a seed with nothing wrong with the
build, and passed a version of the app with two thirds of its skill dial missing.
Chip rounding itself cost nothing measurable: the readError edge went from 23.2 to 22.2 bb/100 over
6,000 duplicated hands, which is inside the variation between seeds.
**And the coach was naming a habit in a player who did not have one.** `pokerlab coach --player
control` names the leaks it finds in a scripted player with no planted flaw at all, and it kept
naming "not betting the river with the best hand". The first thing to establish was whether that was
one unlucky seed, because this project has twice been burned by concluding from a single run. It was
not: swept over twenty seeds at 1,000 hands the control player was named for that habit in fifteen
of them. Nor was it a false positive in the narrow sense — at 4,000 hands over ten seeds the control
player really does miss river value 15% to 23% of the time (mean 19.3), against a bar that said a
steady player sits at 15%. The bar was inside the band of the player it existed to clear, so every
extra hand made the wrong answer surer rather than rarer, and no minimum sample could have fixed it.
Both numbers were re-derived from the same sweep: the same measurement puts a player who never bets
a river at all at 38–46% (mean 40.2), so the bar goes between the two at 30%, and the chances it
takes to say anything go from 12 to 25 — because at 12 chances the same flawless player reads
anywhere from 0% to 42%. What that costs is real and is the right price: at 320 hands the coach now
says nothing about the river either way, because 320 hands cannot see it. The test moved with the
numbers — it used to catch the planted river player at 420 hands, where the flawed player measured
31% and the sound one 29%, which is to say it was passing on a margin that would have convicted
somebody with nothing wrong.
**The habit next door had exactly the same shape, and it took a lot more hands to fix.** Over 4,000
hands the control player misses turn value 13–21% (mean 17.7, twenty seeds) against a bar that said
18%, so the same fault was sitting on "not betting the turn with a good hand". A planted player who
checks every turn he is allowed to check settles at 25–33% (mean 29.0) on the same measurement, and
at 8,000 hands the two are 15–21% and 28–31% — so the real rates are about 17.5% and 29.4% and
everything below 4,000 hands is noise around them.
The sample was the expensive half. Measured at four sizes, twenty seeds each, the two populations
overlap almost completely until about 250 turn decisions:
| chances | control | never bets the turn |
|--------:|--------:|--------------------:|
| 25 | 5–35% | 17–38% |
| 50 | 7–36% | 18–36% |
| 110 | 10–28% | 21–34% |
| 270 | 13–21% | 25–33% |
There is no honest bar until the bottom row, so the bar goes at 24% — three points above the worst
of twenty control seeds and one below the best of twenty flawed ones, catching 20 of 20 flawed
sittings and convicting 0 of 20 clean ones — and the chances it takes to say anything go from 14 to
250, which is roughly 3,800 hands of poker. Until then the habit sits under "too soon to call" with
its count on show and no accusation attached. That is a real cost and it is the right one: the app
can wait, and a player told their turn play is leaking when it is not stops believing the rest of
the list.
Two things fell out of that measurement. The control player at 420 hands is now handed one habit
rather than two, because the second one was this bar. And a full ninety-hand drill at the turn habit
produces only five to thirteen turn decisions, which is nowhere near enough to say anything either
way — so the drill's ending has a third answer in it now ("not enough to say") instead of telling
somebody their habit is still there off six chances. Making that drill actually deal turns is the
next measurement, and it is a real one: the arrangement promises 0.40 turn decisions a hand and
delivers about 0.09.
**Then the same fault turned up in five more habits at once, and the reason nobody had seen it is
worth more than the fix.** Every sweep up to this point read the coach's profile at the *end* of a
long run. A player does not read it at the end. They read it after three hundred hands, and after
eight hundred, and a bar that convicts somebody at three hundred and clears them again by four
thousand has still told them something untrue — the finishing rate never shows it. So the sweep now
replays the decisions in the order they happened and applies the coach's own three tests after every
one, and what it writes down is the *worst the running rate ever reached* once a habit had a given
number of chances behind it. That number is nothing like the finishing rate. Folding the big blind
finishes at 11-14%; on the way there it passes through 28% with twenty-five chances behind it, and
the bar was 18% over 25.
Sixteen seeds of ten thousand hands of the control player, who has no planted flaw at all:
| habit | old bar | finishes at | worst running rate, from 25 / 50 / 150 chances | named |
|---|---|---|---|---|
| Folding the big blind too often | 18% over 25 | 11-14% | 28% / 24% / 22% | 10 of 16 |
| Folding to the first bet too often | 20% over 14 | 10-17% | 25% / 21% / 18% | 3 of 16 |
| Not betting the river with the best hand | 30% over 25 | 16-23% | 41% / 34% / 27% | 2 of 16 |
| Calling flop bets with too little | 18% over 16 | 4-9% | 20% / 13% / — | 1 of 16 |
| Bluffing into people who cannot fold | 15% over 14 | 3-8% | 13% / 10% / — | 1 of 16 |
Each was fixed from that table and from a matching sweep of the player who has the habit. Folding
the big blind goes to 30% over 50 chances, against a player who folds every big blind measuring 53%.
Folding to the first bet goes to 30% over 25, against 48-58% for a player who folds to every flop
bet. The river-value bar was right and its floor was not, so that goes from 25 chances to 200.
Calling flop bets and bluffing keep their bars and take their floors to 50, which is a change that
can only ever make the app quieter and so needs the clean measurement and nothing else.
**"Paying off on the river" was the one a reviewer found, and it needed a new player built first.**
A bar can only be set by measuring the player who has the habit against the player who does not, and
there was no player who calls every river bet. With one, at 8,000 hands over fourteen seeds: control
15-27% (mean 21.4), a player who calls every river bet 34-56% (mean 48.7). At 16,000 hands the two
settle at 17-23% and 43-56%. The whole difficulty is the sample —
| chances | control | calls every river bet |
|--------:|--------:|----------------------:|
| 65 | 11-33% | 30-60% |
| 90 | 13-27% | 32-56% |
| 120 | 15-27% | 34-56% |
— because at 65 chances the two bands overlap and no bar exists there. The bar goes at 30% and the
floor at 150, which is where the running rate of a flawless player finally drops clear of it.
**And one habit cannot be measured at all, so it is never named.** "Betting the wrong amount" had a
bar of 25% over 25 chances and the control player measured 20-29% (mean 24.9), which is the same
fault again — except that re-deriving the bar from two populations does not fix it, because the two
populations are the same population. Twelve seeds of 4,000 hands: no planted flaw 20-29%, a player
who min-bets every flop 0-5%, a player who shoves his whole stack on every flop 22-34%. A shove is
unambiguously the wrong size and moves the number three points; a min-bet reads *better* than
playing well, because the coach's own preferred size is often the minimum and a size counts as wrong
whenever it differs from that by a single chip. Separating a three-point difference from a
four-point seed spread would take on the order of a hundred thousand hands. So the habit carries a
flag saying it may not be named, and the app is silent about it — no row, no watch list, no drill —
while what a badly priced bet costs still counts towards the rating. A habit that can never be named
honestly is better than one named wrongly, and the honest version of this one is silence.
The sweeps are a command rather than a one-off script, because the next person to move a threshold
should have to run them:
```
./.build/release/pokerlab sweep --player control --hands 10000 --seeds 16
./.build/release/pokerlab sweep --player hero --against control --hands 8000 --seeds 14
```
The suite holds both halves of this now, at samples where each claim is honest. Each planted player
is caught at the sample its own habit's floor needs — 420 hands for the loud ones, twelve thousand
for paying off the river — and the "and nobody else" half is one sweep of the whole catalogue
against a player with no flaw, long enough that every floor in it is actually cleared. That last
part is the bit that used to be missing: the run the reviewers used was 320 hands against a floor of
14, and the river-call habit produced 10 to 13 chances, so it passed by never being asked. The test
now fails if any habit was left unjudged, as well as if any was named. The two short-stack habits
are the exception and are called out as one: this table is a hundred big blinds deep from the first
hand to the last, so they never come up and their bars are unswept.
**Difficulty comes mostly from who is at the table, not from a number.** Style beats skill by an
order of magnitude: a competent regular beats a table of calling stations by well over a hundred
big blinds per hundred hands, and beats a better version of its own style by single digits. That
is a real property of poker rather than a shortcoming, and it is why the ladder replaces the
opponents as you climb rather than just turning a dial.
Two things were tuned into the bots and then measured out again, and both are recorded in the code
so they do not come back:
- Making the "expert" version of a style play wider ranges and bluff more made it *lose* to its
own novice. Wider ranges only pay if you can play the extra hands well after the flop. Skill
now only removes things that are unambiguously mistakes.
- Erratic bet sizing turned out not to be a leak against these opponents at all, so it is no
longer treated as one.
The backbone of the skill dial is `readError`: how far off a player's judgement of their own hand
is. It is the one parameter that costs money against everyone, and the ablation confirms it
carries the difference.