Method

How the solver actually works

Counterfactual Regret Minimization, from the problem it exists to solve through to the two implementation details that decide whether it converges fast or slowly. This is the algorithm behind Cepheus, Libratus and Pluribus; Kuhn poker is just the smallest game that exercises all of it.

1. Why game-tree search does not work

Minimax and alpha-beta assume you can look at a position and evaluate it. Poker denies you that. The value of your situation depends on a card you cannot see, so there is no position to evaluate — only a probability distribution over positions.

Two consequences follow, and both break classical search:

Kuhn poker is small enough to solve as a linear program. Real poker is not: the LP scales with the number of game states, and heads-up limit hold'em has about 1014. CFR is the method that scales, and it needs nothing but the ability to walk the tree.

2. Information sets and reach probabilities

An information set I is a set of game states the acting player cannot tell apart. In Kuhn poker it is exactly “my card + the betting so far”. There are 12.

The key bookkeeping is the reach probability — how likely a state was to occur — split into three independent factors:

πσ(h) = πiσ(h) · π−iσ(h) · πc(h)

// player i's own choices, the opponent's choices, and chance

Splitting them is the whole trick. Everything below hinges on being able to divide out a player's own contribution to reaching a decision.

3. Counterfactual value and regret

The counterfactual value of an information set weights each state by how likely the opponent and chance were to produce it, deliberately excluding the player's own probability of getting there:

viσ(I) = Σh∈I Σz∈Z π−iσ(h) · πσ(h,z) · ui(z)

Why exclude your own reach? Because otherwise a rarely-visited decision would look unimportant simply because your current strategy avoids it — and you would never discover that it is avoided precisely because you play it badly. Dividing out your own probability makes each information set improvable on its own terms.

Counterfactual regret is then the obvious question: how much better would always taking action a have been?

rt(I,a) = viσt|I→a(I) − viσt(I)

RT(I,a) = Σt=1..T rt(I,a) // accumulated over every iteration

4. Regret matching

Play each action in proportion to how much positive regret it has accumulated. Actions you wish you had taken more get taken more.

σT+1(I,a) = RT,+(I,a) / Σb RT,+(I,b) // R+ = max(R, 0)
// if every regret is ≤ 0, play uniformly

That is the entire learning rule. No gradients, no neural network, no opponent model. Just: count what you wish you had done, and do more of it.

5. Why this produces a Nash equilibrium

Two results combine, and neither is obvious on its own:

So minimising a purely local, greedy, per-decision quantity yields a global equilibrium of the entire game. The formal guarantee:

RiT / T ≤ Δ · |Ii| · √|A| / √T

// Δ = payoff range (4 here), |I| = 6 info sets, |A| = 2 actions
// ⇒ exploitability decays as O(1/√T)

The single most important caveat. It is the average strategy over all iterations that converges — never the current one. Vanilla CFR's current strategy orbits the equilibrium forever and never settles. The solver keeps two separate accumulators for exactly this reason, and the ablation on the Solver page measures both so the difference is visible rather than asserted.

σ̄T(I,a) = Σt πiσt(I) σt(I,a) / Σt πiσt(I)

// weighted by the player's OWN reach — a strategy is only
// evidence about a spot in proportion to how often you land there

6. The implementation

One recursive tree walk carries both reach probabilities down and returns values back up. Chance is enumerated exactly — all six deals every iteration — rather than sampled, which removes Monte-Carlo variance entirely.

def _walk(self, deal, history, reach0, reach1, chance,
          updating_player, regret_weight, average_weight):
    # returns the expected value to Player 0
    if is_terminal(history):
        return terminal_utility(history, deal)

    player   = current_player(history)
    node     = self.nodes[info_set_key(deal[player], history)]
    strategy = node.strategy          # FROZEN for this iteration

    # average strategy accumulates weighted by OWN reach
    own = reach0 if player == 0 else reach1
    node.strategy_sum += own * chance * average_weight * strategy

    # recurse, threading each player's reach separately
    action_values = [self._walk(deal, history + a, ...) for a in ACTIONS]
    node_value = dot(strategy, action_values)

    # regret uses the OPPONENT's reach - the counterfactual weight
    sign = 1.0 if player == 0 else -1.0
    counterfactual = (reach1 if player == 0 else reach0) * chance
    for i in range(NUM_ACTIONS):
        regret = sign * (action_values[i] - node_value)
        node.regret_delta[i] += counterfactual * regret * regret_weight

    return node_value

Two details in that code decide whether CFR+ is fast or useless.

  • strategy is read from a snapshot taken once per iteration. CFR is defined over a fixed profile σt; letting it drift between chance outcomes means each deal is evaluated against a slightly different opponent.
  • Regret goes into regret_delta, not straight into the running total. Regret matching+ clamps cumulative regret at zero, and that clamp must be applied once per iteration to the full sum over all six deals. Clamping after each individual deal lets one chance outcome zero the accumulator before another's contribution arrives.

Both of these were real bugs in this solver's first version. Individually each looks harmless and still converges to the correct strategy — they simply drag CFR+ back to vanilla's 1/√T rate. Fixing them moved exploitability at 50,000 iterations from 1.0×10−3 to 5.8×10−6. Both are now pinned by regression tests.

7. The variants

VariantRegret ruleAveragingUpdatesObserved rate
vanillaregret matchinguniformsimultaneousT−0.52
cfr+regret matching+ (floor at 0)linear in talternatingT−0.88
linearregret matching, weighted by tlinear in talternatingT−0.9

Ablation result: alternating updates — each player responding to the opponent's freshly improved strategy rather than a stale snapshot — are worth roughly two orders of magnitude on this game, more than regret matching+ contributes on its own. Switching Linear CFR from simultaneous to alternating updates alone moved it from 1.2×10−3 to 1.7×10−6 at 50,000 iterations.

8. Measuring it: exploitability

“The strategy stopped changing” proves nothing — a solver can converge confidently to a non-equilibrium. The real question is what a perfect adversary could win:

ε = ( BR01) + BR10) ) / 2

// at Nash, BR0 = v and BR1 = −v, so ε = 0
// elsewhere ε > 0 strictly: σ is an ε-Nash equilibrium

The subtle part of computing a best response is that the maximisation must happen at the information set, not at individual game states. A best responder still cannot see the private card, so it must commit to one action across every deal consistent with what it observes. Taking a max at each state separately silently grants clairvoyance and reports exploitability that is too high.

How this is verified: Kuhn poker's equilibria are known in closed form, so the test suite feeds five exact equilibria to the best-response code and asserts they measure as unexploitable. They come back at ~10−17 — machine zero. A best responder that could peek at the opponent's card would fail that test immediately.

9. What equilibrium does and does not buy you

The most misunderstood property of a Nash strategy, and the most interesting measured result in this project: an equilibrium does not try to win.

It guarantees you cannot lose more than the game value against any opponent. But against a specific flawed opponent it frequently earns exactly zero, because equilibrium play makes the opponent indifferent — every option they have is worth the same, so their bad habits cost them nothing against it.

Measured here: the solved equilibrium scores exactly +0.0000 chips/hand against three of the seven rule-based baselines, verified across the entire α-family — including one that is 0.25 chips/hand exploitable. It is the same reason always playing rock scores dead even against a random opponent in rock-paper-scissors while being the most exploitable strategy available. Punishing a known opponent requires a best response, which wins far more and is itself wide open to anyone who adapts.

That trade — safety versus maximum value — is the real subject of the tournament table on the Solver page, and it is why both agents are reported side by side rather than picking one number to headline.