NOTES — 02 · NINE POSITIONS

Solving a game that was already solved

The first thing I measured told me the card game had a trivial equilibrium worth exactly nothing. That result is the reason the bot in this game is any good — it moved all the work to the layer that actually mattered.

ENGLISH ONLY · ~14 MIN · MCCFR · 148,060,000 ITERATIONS · 350 KB SHIPPED

01The game, and why it resists the obvious tools

Both players hold the same nine cards, 1. Cash through 9. Derivatives, a ladder of rising risk. Each card is spent once, so a game is exactly nine sessions. Higher wins, equal draws, and 1 beats 9 — in a crash the leveraged position is liquidated and cash is king. That single loop in the ordering is what stops the game from being "play your highest card."

On top of the cards sit three declarations, each usable once, announced openly with the card:

  • 2× and 3× leverage — the session is worth the largest multiplier either side declared.
  • Inverse — the session's ranking flips entirely, so the lowest card wins and 9 beats 1. If both players declare inverse in the same session the two cancel by XOR and the session resolves normally, which turns it into a bluff about a bluff.
  • The draw pot — a drawn session's point rolls forward onto the next decided one.

The critical rule is what stays hidden: cards are never revealed. After each session both players learn only win, loss, or draw. That makes this an imperfect-information game, which is why the tooling that beats chess and Go is the wrong shelf to reach for — a search tree needs to know where it is. The right family is counterfactual regret minimisation, the line that produced Libratus and Pluribus for poker.

02The first measurement: the card game is already solved, and it is worth nothing

Before writing a trainer I stripped the game down to cards alone — no tokens, no pot — and solved it exactly. The result was blunt.

RESULT

The Nash equilibrium of the pure card game is uniform random, and the value of the game is exactly zero. An opponent who somehow knew you were playing uniformly at random, and could see your remaining hand, still gains 0.0000 points per game.

I re-derived this independently while writing this page, with a dynamic program over all 48,619 reachable pairs of remaining hands. The best response to a uniform opponent is worth 0.0000000000, and every opening card — 1 through 9 — scores identically zero. There is nothing to choose between them.

That is a strange thing to discover about your own game, and it is the most useful thing in this write-up, because of what it implies: a card-only CFR trainer would have burned a fortnight to reproduce random(). Every point of strategy in Nine Positions lives in the declaration layer, or in exploiting an opponent who is not uniform.

And being non-uniform is expensive. Against an opponent who knows your bias:

Card habitPoints lost per game
Uniform random0.0000
Holding 9 back0.9874
High cards first1.8934
Low cards first2.1880
Strictly highest first, every time9.0000
Exact dynamic programming against a fully informed best responder. The last row is the degenerate case and I computed it separately: a deterministic opponent loses all nine sessions, every game.

Nine points out of nine. A habit is not a small leak in this game; it is the whole game.

03The tell

The complaint that started the work was that the bot "plays consistently and feels too easy to beat." Consistent is the polite word for readable, so I measured what the bot opened with across thousands of games, in the position where it has read nothing yet and should therefore be indistinguishable from a coin.

OLD BOT — FIRST CARD, HIGHEST DIFFICULTY
123456789
χ² = 1.6588 against uniform. It opened with 8 or 9 in 73.0% of games.

Three quarters of its openings came from two of nine cards. Any player who noticed — and players notice — could hold 1 back and take the first session almost for free, then keep taking it. The bot was not losing because it evaluated badly. It was losing because it had a habit, and section 02 already priced habits.

04Fix one: derive the correction instead of tuning it

The old bot picked cards by expected value with a hand-tuned penalty bolted on to stop it dumping its best cards early. Hand-tuned constants are exactly how you end up with a 73% habit.

The equilibrium result from section 02 hands you the correct penalty for free. If the card-only game has value zero under uniform play, then the myopic gain from playing card c right now is exactly cancelled by the future cost of no longer holding it, where that future cost is the same expected value evaluated under the baseline belief that the opponent plays uniformly from their remaining hand:

score(c) = w · ev(c) − corr · ev₀(c) with w = corr = 1 and nothing to read, score(c) is identically zero for every c → exactly uniform

No constant to pick. The distribution is flat because the mathematics says it must be flat, not because a number was nudged until it looked flat.

BEFORE — χ² 1.6588
123456789
AFTER — χ² 0.0001
123456789

Every card lands between 11.0% and 11.2% against a uniform ideal of 11.1%. Two notes on what was deliberately not flattened:

  • The middle difficulty keeps corr between 0.30 and 0.80 on purpose, leaving a χ² of 0.2046 — a readable bot is the correct behaviour for a tier called Analyst. Only the top tiers are jitter-free.
  • Flat is only correct when there is nothing to read. Given information, the same code sharpens hard: following an opponent who declared 3×, the distribution collapses onto 8 (42.8%) and 9 (38.5%).
NEW BOT, FOLLOWING A 3× DECLARATION — INFORMATION PRESENT
123456789
Uniformity is not the goal. Being unreadable when there is nothing to read is the goal.

Three knobs that measurement deleted

While I was there I tested the other hand-tuned ideas in the bot. All three had a plausible story. None survived contact with a number.

KnobWhat it was forWhat the measurement said
aggroCommit early to big sessionsBarely closed the hole, badly damaged uniformity
escPCorrect adverse selection when leadingHole 2.31 → 2.18, but first-move χ² 0.0001 → 0.31. Once opponent-read was in, the gain was ~0
fwUnspent leverage makes future sessions worth moreClosed nothing (2.31 → 2.28 → 2.49 → 2.89) and skewed the bot toward low cards
All three were removed. A fourth hypothesis of mine — that within-game opponent reading would close the worst hole — was also wrong: 2.31 → 2.30. It helped substantially everywhere else, so it stayed.

Deleting code because you measured it is cheaper than defending it forever. The reading layer that did survive is worth a line: it enumerates every assignment of remaining cards consistent with the win/loss/draw record, carries a likelihood vector for five playing styles through the search, and shrinks the posterior toward the uniform baseline by λ = m/(m+3) where m is the number of observed sessions. At m = 0 that is exactly zero, which is what keeps the first move exactly uniform. Precomputing the static probability tables took a decision from 2.6 ms to 0.14 ms, byte-identical output.

05The fix made the worst case worse

To score a bot you cannot use its average result, because the average hides the one opponent that dismantles it. I keep six hand-written strategies and score the bot by its worst result against any of them — a minimax number, positive meaning the human wins.

Human strategyvs old botvs patched bot
Uniform, never declares−3.03−3.37
Uniform + smart declarations−1.33−0.97
High cards first−0.14−2.24
Snipe leverage with a 1−0.88−0.83
Dump low early, harvest late+0.21+2.32
Mirror the bot's declarations−0.03−0.95
Worst case (minimax)+0.21+2.32
Human point of view; negative means the bot wins. The patched bot is better in five of six — the two strategies that fed on the old bot's high-card habit collapsed by 2.1 points each — and worse on the one that counts.

Removing the tell made the bot eleven times more exploitable by its worst enemy. That is not a paradox, it is a diagnosis. The surviving strategy dumps its lowest cards through sessions 1–4 and then plays its best cards with 3× and 2× in the endgame. It beats the bot through adverse selection while leading: the bot commits its declaration first, and a scripted opponent only escalates when already strong. Nothing about that exploit lives in the card layer. Flattening the card distribution could not touch it, and I confirmed that by trying — the three deleted knobs in section 04 were all attempts to patch this hole from the card side.

WHAT THIS COST ME

Three implementations, three measurements, three deletions, and the hole did not move. The information was in the negative result: a declaration-layer problem cannot be fixed from the card layer. That is what made it worth building a real trainer instead of a fourth knob.

06Fix two: MCCFR over cards and declarations

The trainer solves the whole action space — which card, and whether to declare 2×, 3×, inverse, or nothing. Leaving declarations out of the abstraction would have reintroduced exactly the hole from section 05.

  • External sampling MCCFR with regret matching, written in C, two cores, 30 rounds of 600 seconds.
  • 148,060,000 iterations. Final snapshot 93.9 MB.
  • Information sets reached: 441,406 of 917,504 card sets (48.1%) and 163,479 of 580,608 declaration sets (28.2%). The unreached remainder are states with near-zero probability on the equilibrium path.

The two information sets are encoded as flat integers, which the JavaScript runtime must reproduce exactly:

declFeat(stage, llev, linv): leading → 0 following → 1 + (llev==0?0:(llev==2?1:2))*2 + linv // 0..6 key1 = ((((hand*8 + mytok)*8 + otok)*4 + min(pot,3))*7 + declf key2 = (((((((c−1)*9 + nn)*2 + h1)*2 + h9)*8 + mytok)*8 + otok)*4 + min(pot,3))*7 + declf
THE BUG THAT WOULD NOT HAVE ANNOUNCED ITSELF

Card strategy sums are indexed by position in the sorted hand; declaration strategy sums are indexed by the declaration id itself. Mixing the two conventions does not crash, does not warn, and does not look wrong in a log. It quietly trains a policy for a different game. This is written down because I nearly did it.

0793.9 MB into 350 KB

An exact policy table of 93.9 MB cannot ship to a browser. The compression is a hybrid rather than a pure model, and the split is by how often a state actually occurs:

  • The 17,082 highest-reach card information sets are shipped verbatim as an exact table, binary-searched at runtime. Most decisions in a real game land here.
  • Everything else is answered by two small MLPs — one for cards, one for declarations, hidden layers 160×160, trained for 40 epochs in plain numpy with no deep-learning framework, then stored as fp16.

Total asset: 350,800 bytes, 241,035 gzipped. Distillation quality on the declaration network came out at a weighted total-variation distance of 0.0628 and a weighted KL of 0.0389 — but the number that matters is not the distance between the policies, it is whether the compressed policy still wins.

StageWorst case (minimax)Samples
Old bot+0.21
Patched bot (section 04)+2.32
A failed first attempt — outcome sampling, 50M+2.31
MCCFR round 1 (5.31M)−0.309
MCCFR round 11−0.879
MCCFR round 21−1.055
Round 30, exact 93.9 MB table−1.00730,000
Distilled hybrid, in C−0.9646,000
Shipped JavaScript runtime−0.9474,000
Human point of view; negative means the bot wins. The sign flip between row 3 and row 4 is the moment the approach started working.

The last three rows are the point of the section. Going from an exact 93.9 MB table to a 350 KB browser asset moved the score by 0.06 points per game, which is inside sampling error. The compression is free.

Against the previous top difficulty, head to head over 3,000 games: the old bot loses by 1.305 ± 0.147 points per game, winning 32.3% and losing 57.9%.

08What the equilibrium says out loud

A solved policy is also a document. Reading session one of the equilibrium is more instructive than any advice I could write from intuition.

  • Leading, session one: it spreads across 1 (20.4%), 5 (17.4%) and 9 (14.7%), and declares nothing 80.4% of the time. The two ends of the ladder are both favoured, because 1 and 9 are the two cards with a special relationship.
  • Following a 3×: the distribution is bimodal on 2 (37.5%) and 7 (37.3%) — either concede the session for almost nothing, or contest it properly, never in between — and it answers with a counter-inverse 37.0% of the time.
  • Following an inverse: it piles onto 3 (59.4%) and cancels the inverse only 1.0% of the time. It would rather win inside the flipped market than undo the flip.

That last line is the one I did not expect. My hand-written bot cancelled inverses whenever the arithmetic was close, because cancelling feels safe. The equilibrium almost never does it.

09Giving the player the same instrument, then taking it away

The bot's belief layer enumerates every assignment of your remaining cards consistent with the public record. After playing against the solved bot I wanted that view myself, so the game grew a Position Ledger.

Because every session publishes your card, the result, and whether the market was inverted, the set of cards the opponent could have played is determined. Enumerate all systems of distinct representatives across completed sessions and three facts come out exactly, not probabilistically: which cards are certainly spent, which are certainly still held, and the narrowed candidate set per session. A bitmask depth-first search finishes instantly — the worst case is 8! = 40,320 leaves.

It was verified on 4,000 random games: the opponent's real card was always inside the candidate set, the spent and held verdicts always matched reality, and on the 3,000 games short enough to check by independent brute force the two agreed completely. Zero violations. All 2,997 sessions that narrowed to a single candidate were correct.

A DESIGN DECISION, NOT A FEATURE FLAG

The ledger runs on the two lower difficulties only. On the top two it stays on screen but locked, with one line explaining why. Beginners get to learn what counting cards feels like; from Managing Director upward, counting is the exam. Hiding the panel entirely would have lost that signal.

The footnote under the ledger says the part that matters: the AI is running this identical deduction on you.

10What it still cannot do

It does not read you. MR. MARKET replays a fixed equilibrium and would play identically against a grandmaster and a cat walking on a keyboard. Equilibrium play is unexploitable, which also means it declines to punish you for being predictable. Anyone who plays a strong habit gives away up to nine points a game to a bot that would take them — and this one will not.

Six hand-written strategies are not a population. The minimax number is the worst result against six opponents I invented, so it bounds nothing. A genuine exploitability figure needs a best responder trained against the frozen policy, which I have not run. Until then −0.947 is a comparison, not a guarantee.

Half the tree is unvisited. 51.9% of card information sets and 71.8% of declaration sets were never reached in 148 million iterations. The argument that they are near-zero-probability states is sound and is also exactly the argument that would hide a mistake in the encoding.

The strategy that beat the patched bot is a script. "Dump low through session four, harvest with 3× after" is a deterministic plan a real person is unlikely to hold for nine sessions without deviating. It was still worth treating as real, because a bot that loses to a simple script loses to the first person who finds the script.

Where this goes next

The layer that reads you is deliberately separate and deliberately not built yet. The design is a mixture, policy = (1−λ)·GTO + λ·BestResponse(your model), with λ a function of how many games you have played and a hard ceiling. The ceiling is not a safety margin, it is a design requirement: a bot that adapts without limit turns the mind game into a treadmill, and the ceiling is what keeps it possible for you to plant a false pattern and take the bot's money with it.

Play it. Four difficulties; MR. MARKET is the equilibrium policy described above. The Position Ledger is on for the first two.

Open Nine Positions →