Sixty-four games into building one board game a day, every search has had the same shape: I move, you move, each of us picking the best available. Chess, Checkers, Hex, Quoridor — all minimax or rollouts, all over positions somebody chose.
Backgammon breaks it, because between my move and yours there is a die.
The tree now has three kinds of node: max (me), min (you), and chance — and a chance node has no best child. It has an expected value.
function chanceValue(state, me, depth){
let total = 0;
for (const r of ROLLS){
let best = -Infinity;
for (const seq of legalSequences(state, me, r.dice))
best = Math.max(best, -chanceValue(applyAll(state, seq, me), -me, depth - 1));
total += r.p * best; // weighted average, not a maximum
}
return total;
}
Playable, with everything computed live: https://dev48.infy.uk/game/day65-backgammon.html
21 rolls, and they are not equally likely
Two dice have 36 outcomes, but (3,5) and (5,3) let you play the same moves, so the game distinguishes 21. Six are doubles, arising one way each; fifteen are mixed, arising two ways each.
const ROLLS = [];
for (let a = 1; a <= 6; a++)
for (let b = a; b <= 6; b++) // b = a, not b = 1. That is the 21.
ROLLS.push({ dice: a === b ? [a,a,a,a] : [a,b], p: a === b ? 1/36 : 2/36 });
Treat those 21 as uniform — an easy mistake, and the simpler loop — and you have overweighted doubles by 1.7×. Doubles give you four moves, so the error never cancels; it just makes the engine systematically optimistic about big rolls.
The rule almost every implementation drops
You must play both dice if any ordering plays both. If only one can be played, you must play the larger.
Both halves are non-local. You cannot decide legality by looking at a single move: a position where playing the 5 first blocks the 3, but playing the 3 first leaves the 5 available, is illegal to start with the 5 — and nothing about the 5 by itself tells you that.
So move generation is a small search over orderings, filtered by a maximum:
const most = Math.max(...out.map(s => s.length));
let keep = out.filter(s => s.length === most);
if (most === 1 && dice[0] !== dice[1]){
const big = Math.max(...dice);
const withBig = keep.filter(s => s[0].die === big);
if (withBig.length) keep = withBig;
}
Skip those five lines and your engine plays a different, easier game — and it will look completely normal until a cramped position arrives.
The invariant that catches everything
Every legal backgammon move takes a checker toward home or off the board, so your pip count can only fall. One assertion catches direction bugs, the classic sign error, and bear-off mistakes at once:
400 random games, 38,740 plies
0 conservation breaks (15 checkers a side, always)
0 plies where the mover's own pip count increased
The doubling cube, derived rather than quoted
Everyone quotes the 25% take point. It falls out in two lines:
equity(drop) = -1
equity(take) = 2p - 2(1-p) = 4p - 2
take when 4p - 2 > -1 ⟺ p > 0.25
The page bisects on the actual equity functions and lands on 25.00%, then checks it against a real race rollout. That derivation is cubeless, though — it assumes you never get to double back — which is why the practical take point sits below 25%. The algebra gives you the floor, not the answer.
The three test bugs that were mine
Verification caught four failures on the first run and three of them were my tests, not the engine:
- I asserted white's 24-point checker could not run to 18 with a 6. Point 18 is empty on the opening board — that is the classic opening run. The blocked point is 19.
- My blot fixture added a black checker instead of moving one, so the conservation check was failing the fixture.
- I asserted that a forced single die is always the larger. It is only the larger when both are individually playable; if the larger has no legal move at all, playing the smaller is forced, not a violation.
Being wrong about the rules in the test is the failure mode you get when you write the assertion from memory instead of from the rulebook.
Why this game produced the first great neural engine
TD-Gammon reached world class in 1992, years before self-play worked anywhere else. The reason is the chance node again: the dice inject exploration for free. A deterministic self-play loop can converge onto a narrow band of positions it keeps replaying; dice make that impossible, so the network sees a varied distribution with no exploration machinery at all.
Part of a from-scratch series — one game a day, vanilla JS, one file, offline: https://dev48.infy.uk/gamefromzero.php
Top comments (0)