Cairn
algorithms · game trees · O(b^d) worst case, shared with alpha-beta — real savings scale with how often a stored killer is still legal and useful where it's tried

back to Game Trees

Killer Move Heuristic

The Killer Move Heuristic is a move-ordering refinement layered on top of alpha-beta pruning: whenever a move causes a beta cutoff at some search depth, remember it — not by which position it was played in, the way Transposition Tables key their own cache, but by which depth (ply from the root) the cutoff happened at. The next time the search reaches a different branch at that same depth, try the remembered move first, ahead of whatever order move generation would otherwise offer. The bet is narrow but often pays off: a move that refuted one line at depth d frequently refutes a sibling line at the same depth too, because the reason it worked — it blocks the opponent's strongest threat, or completes one of your own — is often a property of the position's shape at that depth, not of the exact sequence of moves that got there. Alpha-beta already visits every legal move regardless of order and never changes its answer because of it — reordering can only change how many branches get pruned before the rest are proven not to matter, never what the search eventually returns.

Try it

Same fixed board as Minimax and Principal Variation Search, O to move. Pick a mode — plain alpha-beta, or alpha-beta with the killer heuristic layered on top — and a root order, which controls what sequence the root tries its four candidate cells in (the killer heuristic itself only ever reorders moves at depth 1 and below; the root's own order is this page's explicit dial instead, same convention as Principal Variation Search's demo). Press Step or Run. A move tried because it's a remembered killer for its depth is logged as such; watch the log for a cutoff line recording a fresh killer, and then for that same cell jumping to the front of a later sibling branch's move order.

Press Step or Run.

Why it works

Alpha-beta prunes hardest when the best move at a node is tried first — the sooner a strong reply is found, the sooner alpha (or beta) tightens, and the sooner later siblings fail the cutoff test without needing to be searched at all. Nothing about a fresh node tells the search which of its legal moves is strongest before trying any of them — except one thing memory can supply cheaply: what worked last time the search stood at the same depth, in a different branch. Two sibling subtrees at the same depth are different positions, but they often share the same tactical shape — the same square blocks the same kind of threat, or completes the same kind of line — so a move that caused a cutoff once is a better-than-random guess at what will cause one again.

Keying that memory by depth rather than by position is the whole trick, and it's also the thing easiest to get wrong (see Pitfalls below). A Transposition Table only pays off when the exact same position recurs — a real transposition, the same board reached by a different move order — which on a board this small and a game this short is rare. A depth-indexed killer list needs no such coincidence: it fires on structural similarity between different positions at the same ply, a far more common event than two branches converging on the literal same board. That's also exactly why it's a weaker signal than a transposition table hit — a killer is a guess, not a certainty, and trying it first only helps if it's still a legal move in the branch that reaches for it (usually true early in a game with many empty cells, never guaranteed).

Reference implementation

function alphaBeta(board, depth, maximizing, alpha, beta) {
  const w = winner(board);
  const empties = emptyCells(board);
  if (w || empties.length === 0) return score(w, depth);

  const mark = maximizing ? 'X' : 'O';
  let best = maximizing ? -Infinity : Infinity;
  for (const cell of empties) {
    board[cell] = mark;
    const value = alphaBeta(board, depth + 1, !maximizing, alpha, beta);
    board[cell] = null; // undo
    best = maximizing ? Math.max(best, value) : Math.min(best, value);
    if (maximizing) alpha = Math.max(alpha, best); else beta = Math.min(beta, best);
    if (beta <= alpha) break; // cutoff
  }
  return best;
}

function alphaBetaKiller(board, depth, maximizing, alpha, beta, killers) {
  const w = winner(board);
  let empties = emptyCells(board);
  if (w || empties.length === 0) return score(w, depth);

  // move remembered killers for this depth to the front, but only the ones
  // that are actually still legal (still empty) in THIS branch
  const stored = killers[depth] || [];
  const front = stored.filter(c => empties.includes(c));
  const rest = empties.filter(c => !front.includes(c));
  empties = front.concat(rest);

  const mark = maximizing ? 'X' : 'O';
  let best = maximizing ? -Infinity : Infinity;
  for (const cell of empties) {
    board[cell] = mark;
    const value = alphaBetaKiller(board, depth + 1, !maximizing, alpha, beta, killers);
    board[cell] = null;
    best = maximizing ? Math.max(best, value) : Math.min(best, value);
    if (maximizing) alpha = Math.max(alpha, best); else beta = Math.min(beta, best);
    if (beta <= alpha) {
      // this move caused the cutoff -- remember it for this depth, most-recent first, cap 2
      let k = (killers[depth] || []).filter(c => c !== cell);
      k.unshift(cell);
      killers[depth] = k.slice(0, 2);
      break;
    }
  }
  return best;
}
// root call: alphaBetaKiller(board, 0, false, -Infinity, Infinity, {})

Pitfalls

Skip the legality filter on a stored killer and the search doesn't just get slower — it returns a wrong score, checked directly. The reference implementation above filters stored down to front before trying anything, because a killer remembered from one branch is frequently not an empty cell in a different branch at the same depth — measured directly on this page's own board, 13 of the killer lookups performed during the small demo search land on an already-occupied cell, and 5,161 do from a completely empty starting board. Delete that filter — try every remembered killer regardless of whether the cell is still empty — and the search silently plays into an occupied square, overwriting whatever mark was already there instead of raising an error. On this page's own demo board the correct value is -7 (matching Minimax's and Principal Variation Search's own figure for the identical position); the unfiltered version returns 0, claiming a drawn position that isn't one, while visiting 66 nodes — more than plain alpha-beta's own 39, not fewer, since a corrupted board can dodge the real terminal conditions and wander deeper than a legal game ever could. From a completely empty board the gap is worse: the correct value is 0 (perfect tic-tac-toe is a proven draw), the unfiltered version returns -5 — claiming O can force a win from the very first move — while visiting 63,705 nodes against the filtered version's 8,038 and plain alpha-beta's own 20,866. The bug doesn't just fail to help; it actively costs more than doing nothing at all, in both correctness and time.

Key the killer list by board position instead of by depth and the heuristic survives — correctness holds — but most of its benefit quietly disappears, checked directly. Swap killers[depth] for killers[boardSignature] everywhere above and every value returned is still exactly right, since a legal, correctly-filtered reorder never changes alpha-beta's answer, only its node count. But on this page's own small demo board a position-indexed table only ever fires on a genuine transposition — the exact same board reached by a different move order — which barely happens on a board this size: node count comes back at 40, identical to plain alpha-beta with no benefit at all, because none of the positions visited during that particular search repeat. From an empty board, real transpositions do start to occur, so position-indexing isn't worthless — 11,775 nodes, a real improvement over alpha-beta's 20,866 — but it still leaves most of the win on the table next to depth-indexing's 8,038. The lesson isn't that position-keying is broken; it's that it answers a narrower, rarer question ("has this exact position been seen before") when the heuristic's whole value comes from answering a much more common one instead ("has this depth seen a move like this work before").

Complexity

Time: O(b^d) worst case, identical to plain alpha-beta's own bound — a bad killer list can only ever cost the price of trying one extra move first before falling back to the normal order, never change what gets visited overall, since every legal move still gets tried exactly once either way. The realized savings depend entirely on how often a remembered killer is both still legal and actually still good where it's tried. Checked directly on this page's own small demo board across three root orderings: with the board's natural order (3, 4, 6, 8), plain alpha-beta visits 40 nodes against the killer heuristic's 39; reorder so the true best move goes first and the two tie exactly at 29, since an already-optimal order leaves the heuristic nothing to improve on; force the worst root order instead (best move tried last) and plain alpha-beta climbs to 49 while the killer heuristic holds at 39 — the heuristic's edge grows precisely when the given move order is worse, never shrinks below matching it. The pattern is far sharper from a completely empty board with no favorable root reordering at all (fixed left-to-right order, cells 0 through 8): plain alpha-beta visits 20,866 nodes, the killer heuristic visits 8,038 — a 61.5% reduction, recovering more of the loss from a naive move order than Principal Variation Search's own empty-board figure (18,111, about 13% fewer) manages on the identical board, at a fraction of that page's bookkeeping — no null-window re-searches, no scores to reconcile, just a handful of remembered cell numbers. Space: O(d) extra — one short list (capped at two entries here, matching common practice) per depth of the tree, nowhere near Transposition Tables' own O(distinct positions) cost, because a killer list only ever needs to be as deep as the search itself, never as wide as every position that search has ever visited.

This is the site's tenth Game Trees entry, and like Principal Variation Search and MTD(f) it isn't a competing way to decide a move — it's a third, independent refinement that only ever applies once Minimax with alpha-beta pruning has already been chosen. Where Principal Variation Search spends a cheap null-window question to confirm the assumed-best move and MTD(f) spends repeated null-window probes against a shared bound table to narrow toward the exact value, the killer heuristic spends almost nothing — a depth-indexed list holding at most two cell numbers — to guess which move is worth trying first, and it's the only one of the three whose worst case never costs more nodes than doing nothing at all. It composes cleanly with either: neither PVS's window-narrowing nor MTD(f)'s probe-and-remember loop cares what order the moves inside a node are tried in, only what values come back, so a real engine can (and typically does) run all three side by side, with the killer heuristic feeding both a better first guess. It doesn't touch Transposition Tables' or Zobrist Hashing's own concerns either — nothing here is cached and no position is ever hashed — and it's orthogonal to Iterative Deepening and Quiescence Search too, both of which decide how deep or how far to search rather than which move to try first at a node already reached. See Choosing a Game Tree Search Algorithm for how all ten compare side by side, including where this page's own depth-indexed memory fits next to Transposition Tables' position-indexed one.