Dots and Boxes is the game everybody scribbled in the back of a school notebook, and almost nobody plays properly. Two players take turns drawing one wall between two dots; close the fourth wall of a box and it is yours. That is the whole rulebook, plus one clause that quietly turns a doodle into a game with real theory: complete a box and you move again.
I built a playable version in vanilla JavaScript on a single canvas, with an opponent that classifies every wall, finds chains and loops, plays the double-cross, and solves the endgame exactly. Here is how the pieces fit.
Stop storing squares
The first instinct is a grid of boxes. It is the wrong one, because what players actually touch is the gaps between dots. So store those instead.
A 4×4 board of boxes has 5×5 dots and exactly 40 walls — 20 horizontal, 20 vertical. The entire game state is one Uint8Array(40). Boxes become derived data: box b is four indices into that array.
const BR = 4, BC = 4; // boxes down, boxes across
const HCOLS = BC, HROWS = BR + 1; // 4 x 5 = 20 horizontal walls
const VCOLS = BC + 1, VROWS = BR; // 5 x 4 = 20 vertical walls
const H_COUNT = HROWS * HCOLS; // ids 0..19 = horizontal
const EDGES = H_COUNT + VROWS * VCOLS; // ids 20..39 = vertical
const BOX_EDGES = []; // box -> [top, bottom, left, right]
for (let r = 0; r < BR; r++) for (let c = 0; c < BC; c++)
BOX_EDGES[r*BC+c] = [ r*HCOLS+c, (r+1)*HCOLS+c,
H_COUNT + r*VCOLS + c, H_COUNT + r*VCOLS + c + 1 ];
const EDGE_BOXES = []; // wall -> the 1 or 2 boxes it touches
for (let id = 0; id < EDGES; id++) {
const out = [];
if (id < H_COUNT) { const r = (id/HCOLS)|0, c = id%HCOLS;
if (r > 0) out.push((r-1)*BC + c); // box above
if (r < BR) out.push(r*BC + c); // box below
} else { const k = id - H_COUNT, r = (k/VCOLS)|0, c = k%VCOLS;
if (c > 0) out.push(r*BC + c - 1); // box to the left
if (c < BC) out.push(r*BC + c); // box to the right
}
EDGE_BOXES[id] = out;
}
Border walls touch one box, interior walls touch two. That asymmetry matters later: it is why a single wall can occasionally close two boxes at once.
Claiming is just counting to four
There is no separate "close a box" step. Draw a wall, look at the one or two boxes it touches, and claim any whose four walls are now all drawn.
const sides = (st, b) => {
let n = 0; for (const id of BOX_EDGES[b]) if (st.e[id]) n++; return n;
};
function play(st, id, p) {
st.e[id] = 1;
let got = 0;
for (const b of EDGE_BOXES[id])
if (st.owner[b] < 0 && sides(st, b) === 4) { st.owner[b] = p; got++; }
st.score[p] += got;
return got; // 0, 1 or 2
}
That return value is the most useful number in the whole codebase. It drives the turn rule, defines what a "free" move is, and is what the endgame search adds up.
The entire game loop is one if
function move(st, id) {
const got = play(st, id, turn);
if (got === 0) turn = 1 - turn; // captured nothing -> hand it over
return got; // captured -> SAME player moves again
}
Strip that if out and Dots and Boxes is close to a coin flip. Put it back and everything interesting appears, because a run of boxes that are each already on three walls gets eaten in one unbroken turn, not one per round. The real question is never "which wall do I want" — it is "how big is the run I am about to hand over".
Free money vs. gift-wrapping
Two small look-ahead helpers hold all the strategy. Both tentatively set the bit, count, and clear it again, so neither disturbs the board.
function completes(st, id) { // boxes this wall would close
st.e[id] = 1; let n = 0;
for (const b of EDGE_BOXES[id]) if (st.owner[b] < 0 && sides(st, b) === 4) n++;
st.e[id] = 0; return n;
}
function isSafe(st, id) { // leaves NO box on exactly 3 walls
st.e[id] = 1; let ok = true;
for (const b of EDGE_BOXES[id]) if (st.owner[b] < 0 && sides(st, b) === 3) { ok = false; break; }
st.e[id] = 0; return ok;
}
completes > 0 is free money — always take it, and you keep the turn. !isSafe means you have gift-wrapped a box: the opponent closes it next move and carries on going.
I checked the promise over thousands of quiet random positions, and it holds exactly: from a position where nothing is already on offer, a wall marked safe never lets the opponent capture anything, and a wall marked unsafe always offers at least one box.
Why greedy play loses
A greedy player takes every box it can and otherwise moves at random. It loses badly — not because it misses captures, but because it never asks what happens after them. It will play an unsafe wall while a safe one still exists, and when it finally must give something away it gives away whatever it stumbles onto, which late in the game is usually the longest chain on the board.
The better ladder is: capture everything on offer, else play a safe wall, else open the smallest thing available.
function smallestSacrifice(st) {
let best = -1, least = Infinity;
for (const id of legal(st)) {
const c = clone(st); play(c, id, 0);
const loss = cascade(c).boxes.length; // how much they'd eat in one turn
if (loss < least) { least = loss; best = id; }
}
return best;
}
What the board collapses into
Play safe walls long enough and they run out. What remains is never random: the unclaimed boxes fall into connected groups joined by the walls still missing between them. Those groups are chains (a line with two ends leading out) and loops (a ring that closes on itself).
An ordinary flood fill finds them. Two unclaimed boxes are in the same region if the wall between them is still open. A region is a loop precisely when every box in it has exactly two walls missing and none of those gaps is on the outer border.
const loop = comp.every(x => 4 - sides(st, x) === 2) // every box has 2 gaps
&& comp.every(x => BOX_EDGES[x].every(id =>
st.e[id] || EDGE_BOXES[id].length === 2)); // no gap on the border
A chain of length L has L+1 missing walls; a loop of length L has exactly L. Open either and the opponent eats the whole thing in a single turn.
The double-cross
Here is the move that separates people who have played Dots and Boxes from people who have thought about it.
Your opponent opens a chain. You eat along it. If you swallow the whole thing, you must then move again with nothing free left — which means you crack open the next chain and they eat that one. So instead you stop two boxes short and draw the far wall of the last pair. It closes nothing, so your turn ends. The opponent can take those two boxes, but one wall closes both at once, so they get a single move's worth of gain and are then left, exactly as you were, with nothing free and forced to open the next chain.
You gave up two boxes to buy every remaining chain on the board.
function doubleCross(st) {
const cas = cascade(st); // what is on offer right now
if (cas.boxes.length !== 2) return -1; // only the LAST TWO of a chain
const set = new Set(cas.boxes), free = legal(st);
if (!free.some(id => !EDGE_BOXES[id].some(b => set.has(b)))) return -1; // nothing else left
for (const id of free) // the wall that closes NOTHING
if (EDGE_BOXES[id].some(b => set.has(b)) && completes(st, id) === 0) return id;
return -1;
}
The guard matters: if nothing else is left on the board, refusing is just throwing away two boxes. Take them.
Parity: the long chain rule
Once you see that whoever opens the first long chain loses it, the real question becomes who runs out of harmless moves first. That is a counting problem, and it has a famous rule of thumb: aim to make dots + long chains come out even if you moved first, odd if you moved second. You steer it during the safe-move phase, by choosing walls that merge two would-be chains into one or split one into two — long before any capture happens.
The opening in Dots and Boxes is not about boxes at all. It is a fight about a parity.
Solving the ending exactly
Heuristics are for the crowded middlegame; the ending deserves a proof. Renumber the n walls still open as 0..n-1 and the position becomes a single integer bitmask. Then search every line.
It is negamax with one twist — the extra turn. If a move captured, the same player continues, so you add the child's score; if it captured nothing, sides swap, so you negate it.
function rec(mask) {
if (mask === 0) return 0;
if (seen[mask]) return memo[mask];
let best = -128;
for (let i = 0; i < n; i++) {
const bit = 1 << i; if (!(mask & bit)) continue;
const got = doMove(i);
const sub = rec(mask & ~bit);
const val = got > 0 ? got + sub : -sub; // the extra-turn twist
undoMove(i);
if (val > best) best = val;
}
seen[mask] = 1; memo[mask] = best;
return best;
}
Memoising on the mask alone is valid, because which walls are drawn fully determines which boxes are closed. Store it in a flat Int8Array(1 << n) rather than a hash map. Sixteen open walls is 65,536 states and a perfect answer in tens of milliseconds — I checked it against an independent brute-force search, and they agree on every position tested. On a 2×2 board the solver says the first player wins 3–1, and perfect self-play delivers exactly that.
Clicking a wall, not a cell
Every other grid game maps a click to a cell with two divisions. Here the player is aiming at a line segment, so take the nearest still-open wall by point-to-segment distance, inside a tolerance of about a third of a cell. The same test on pointermove gives a live ghost of the wall that would be drawn, which is what makes it feel precise on a touchscreen instead of fiddly.
function distToSeg(px, py, x1, y1, x2, y2) {
const dx = x2 - x1, dy = y2 - y1, L = dx*dx + dy*dy;
let t = L ? ((px - x1)*dx + (py - y1)*dy) / L : 0;
t = Math.max(0, Math.min(1, t));
return Math.hypot(px - (x1 + t*dx), py - (y1 + t*dy));
}
Putting it together
function aiMove(st, level) {
const free = legal(st); if (!free.length) return -1;
const LIMIT = level === "easy" ? 0 : level === "medium" ? 10 : 16;
if (free.length <= LIMIT) return exactBest(st).id; // solved, not guessed
const caps = capturingMoves(st);
if (caps.length) {
if (level === "hard") { const dc = doubleCross(st); if (dc >= 0) return dc; }
return caps[0];
}
const safe = safeMoves(st);
if (safe.length) return safe[Math.random() * safe.length | 0];
return smallestSacrifice(st); // cornered: give the least
}
A flat array of 40 walls, two counts, and one clause about going again. Everything else — cascades, safe moves, chains, loops, the double-cross, an exact solver — falls out of those.
Play it, flip on the safe-move overlay to watch every wall get classified live, and copy the eight build blocks here: https://dev48v.infy.uk/game/day59-dots-and-boxes.html
Top comments (0)