Advertisement

poker-tutor

Public Download (.zip) Make my own copy

by ken · built Librarymo.de · 107 versions, latest Sep 13

A poker coach that deals you hands, watches how you play, and adapts its teaching to your weak spots. Built with Claude Code for Mac, headed for iPhone.

no ratings yet
About this project (the project's own write-up)

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 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.

Versions (107 — latest: Seat mode: My game says where you lose most and why, any seat can be drilled every hand, named only once the hands support it.)
Files (235 · 11.8 MB)

Open a version to see exactly what changed in it, or download the zip for everything.

Get it on your computer

With the Library Mode tool: librarymode get ken/poker-tutor

Or tell Claude Code / OpenCode: "get ken/poker-tutor from Library Mode"

Kept safe with Library Mode. Every version of this project, and this page to show it — built with Claude Code or OpenCode, no developer words. Make your own, free