Cairn
algorithms · game trees · O((b·s)^d) worst case — b choices × s chance outcomes per ply, d plies deep

back to Game Trees

Expectimax

Minimax handles a future where half the branches are chosen by an adversary trying to make your outcome as bad as possible. Expectimax handles a different kind of uncertain future: one where a branch is picked by chance, not by anyone at all — a die, a shuffled deck, a random tile spawn. It reuses minimax's exact shape (a max node picks the best of its children's values) but replaces the minimizer with a third kind of node entirely: a chance node, whose value is the probability-weighted average of its children, not the best or the worst of them. No adversary is required at all — this is the same node structure real engines use for a random tile spawn in 2048, a dice roll in backgammon, or a card draw in blackjack, and this page's own demo is the last of those three.

Try it

The game is Push to 21: starting from a running total of 0, roll a ten-sided die (faces 110) as many times as you like, adding each roll to your total. At any point you can stand and bank the current total, or hit and roll again — but go over 21 and you bust, banking nothing. The question expectimax answers at every total is: is one more roll worth it? The table below fills in by backward induction, from total 21 down to total 0, because the value of hitting from any total depends on the (already-known) value of every total it could roll into — exactly the dependency order 0/1 Knapsack and Longest Common Subsequence fill their own tables in, just running from the end of the game backward instead of from an empty prefix forward. Press Step or Run. Rows where the exact answer disagrees with a common shortcut — "keep rolling until your total is at least 17," the same fixed-threshold instinct real blackjack's dealer rule uses — are flagged the moment they're computed.

total stand hit (avg) optimal hit if <17
step 0
Press Step or Run.

Why it works

Every total from 21 down to 0 gets exactly one number, EV[t], the best possible expected banked score reachable from total t with perfect decisions from here on. EV[21] = 21 — standing is the only sane move with no room left to roll. For every total below that, standing is worth exactly t (whatever's already banked), and hitting is worth the average of the ten possible next totals, treating any that bust as 0: hit(t) = (Σ EV[t+d] for d in 1..10, using 0 for any t+d > 21) / 10. EV[t] = max(stand(t), hit(t)) — the same "try both, keep the better" shape Knapsack's skip-or-take recurrence uses, except one of the two options here isn't a single known value, it's an average over ten already-solved subproblems. That average is the chance node: nobody is choosing which face comes up, so the honest way to value "roll and see" is to weight every outcome by how likely it actually is, not to assume the best one or the worst one will happen.

Reference implementation

function backwardInduction(target = 21, dieMin = 1, dieMax = 10) {
  const faces = dieMax - dieMin + 1;
  const ev = new Array(target + 1);
  const action = new Array(target + 1);

  for (let t = target; t >= 0; t--) {
    let hitSum = 0;
    for (let d = dieMin; d <= dieMax; d++) {
      const next = t + d;
      hitSum += next > target ? 0 : ev[next]; // bust banks nothing
    }
    const hitValue = hitSum / faces;    // chance node: average, not min or max
    const standValue = t;
    ev[t] = Math.max(standValue, hitValue);
    action[t] = hitValue > standValue ? 'hit' : 'stand';
  }
  return { ev, action };
}

Pitfalls

"Hit until at least 17" is a real, common rule of thumb — and it's measurably wrong here. Running the recurrence above shows the optimal action flips from hit to stand at total 14, three totals earlier than the naive threshold. At total 16 the gap is stark: standing banks 16 outright, while hitting once more (as the naive rule insists, since 16 < 17) averages only 9.5 — five of the ten faces (6 through 10) push the total past 21 and bust. That 9.5 isn't asserted, it's the exact figure this page's own recurrence computes at total 16, cross-checked against a 2,000,000-hand Monte Carlo simulation using this site's same seeded mulberry32 generator (the one skip list's preload and Monte Carlo Tree Search's playouts both use): the simulation landed at 9.5037, well within noise. Played out over a full hand starting from total 0, following the naive rule instead of the exact answer costs an average of 2.14 banked points per hand (15.86 optimal vs. 13.72 naive, both figures confirmed the same way) — three wrong stopping decisions, repeated across every hand that happens to pass through them, add up to a real and checked amount of lost value.

A chance node isn't a min node — mistake one for the other and the whole tree collapses toward a single pessimistic fixed point. Swap the average in hitSum / faces for a Math.min over the same ten branches — treating every roll as if the worst face were guaranteed, the way minimax's minimizer treats an adversary's move — and the recurrence no longer answers the same question. Run it and EV[0] drops from the correct 15.86 to exactly 12.00, and the stand threshold moves to total 12 instead of 14 — not a small correction in the safe direction, a collapse to a fixed point where hitting is assumed to always roll the worst face forever. The bug never throws: it just quietly assumes an opponent is rolling the dice against you, the same silent failure mode Minimax's own "flip which side is maximizing" pitfall describes, extended to a third node type this page adds.

Complexity

Time: O(target · faces) for this page's linear-chain version — one constant-time average over faces branches per total, target + 1 totals, so 22 × 10 = 220 additions total for this demo's numbers. In general, a max node with branching factor b and a chance node with s equally-costly outcomes multiply together per ply, giving O((b · s)^d) for a d-ply lookahead — the same shape as minimax's O(b^d), with the chance node's outcome count folded into the per-ply branching factor. Like 0/1 Knapsack's table, this cost is pseudo-polynomial: it scales with the numeric size of the target, not the size of its representation — doubling the bust threshold doubles the table, even though "42" takes one more character to write than "21." Space: O(target) for the full table, since every cell's hit value only ever reads cells above it.

This is the site's third Game Trees entry, alongside Minimax with Alpha-Beta Pruning and Monte Carlo Tree Search — three different answers to the same problem, "the future isn't fully mine to choose." Minimax assumes the worst, because an adversary is really there. Expectimax assumes nothing and averages, because the true odds are known and nobody is choosing against you. MCTS reaches for sampling when neither exhaustive search nor a clean probability distribution is available — a Go board's branching factor is too large to visit every child, chance node or not, so it estimates instead of computing exactly, the same trade this page's own O((b·s)^d) bound explains the need for. A fourth entry, Transposition Tables, doesn't compete with any of these three answers — it just makes sure the same question, chance node or adversary or otherwise, never gets answered twice. A fifth, Principal Variation Search, only refines the first of the three — minimax's own adversarial search gets a cheap yes/no question before every expensive one, with no equivalent trick available once a chance node's averaged outcome replaces a real opponent's chosen one. A sixth, Iterative Deepening, is orthogonal to the chance-node question entirely — it's about how much of any of these three searches actually gets run before time runs out, not about what happens at each node once the search reaches it. A seventh, Zobrist Hashing, doesn't touch this page's own chance-node question either — it's the incremental technique behind Transposition Tables' own cache key, and this page's own averaging never needs a key at all, since nothing here is ever cached or re-looked-up. See Choosing a Game Tree Search Algorithm for how all ten compare side by side, starting with exactly this chance-vs-adversary fork.