Skip to content

9.7.7 — Chess

"Design chess: a board, pieces, and only legal moves allowed."

White has a king on e1 and a bishop on e2. Black has a rook on e8. The bishop's own movement rule says it may go to d3, and every diagram of how a bishop moves agrees. It cannot go to d3. The moment it leaves e2, the black rook is looking straight down the e-file at the white king, and a move that leaves your own king attacked is not a move.

the e-file♜ e8♗ e2♔ e1bishop's own rule says d3 is fineand d3 is illegalthe rook's line to the king opens the moment it leavesNothing about the bishopchanged. What changed iswhere the other pieces are,which is not a fact thebishop can hold.
Figure 1 — Legality is not a property of a piece. The bishop's movement rule is unchanged and the move is illegal, because of two other pieces on a line the bishop knows nothing about. Any design that asks a piece whether it can move has asked the wrong object.

That position is the whole interview. The obvious model gives each piece a method that says whether it can move somewhere, and that model cannot express the sentence above. Watching somebody discover why, and then reorganise, is what the forty-five minutes are for.

Scope. Two-player legal chess: the board, all six piece types, full legality including check, checkmate, stalemate, castling, en passant and promotion, plus move history and undo. Deferred with reasons: clocks are an independent timer, notation is a way of writing a move down, and the computer opponent is a search problem rather than a modelling one. The engine's job for that opponent is to expose "what are the legal moves" and "apply this move" quickly enough for a search to use, which is a real requirement and a different one.

1. The model everybody writes first, and the four things that kill it

typescript
abstract class Piece {                                          
  abstract canMove(from: Square, to: Square): boolean;
}

class Knight extends Piece {
  canMove(from: Square, to: Square): boolean {
    const df = Math.abs(from.file - to.file);
    const dr = Math.abs(from.rank - to.rank);
    return (df === 2 && dr === 1) || (df === 1 && dr === 2);
  }
}

For a knight this is complete and correct. Knights jump, so nothing between the squares matters. Then it fails, four times, each failure needing something bigger than the last.

One: sliders need the board. A rook on a1 can reach a8 only if a2 through a7 are empty. canMove has no way to know, so the signature grows a board parameter. Annoying, and survivable.

Two: pawns need the history. A pawn may advance two squares only from its starting rank, which is really the rule "only if it has not moved yet". And en passant is worse: a pawn that has just advanced two squares past an enemy pawn may be captured as if it had only advanced one, and only on the immediately following move. That is not a fact about the board at all. It is a fact about the previous move, so the signature grows again.

Three: castling needs the opponent's attacks. Castling requires that the king and the chosen rook have never moved, that the squares between them are empty, and that the king is not in check, does not pass through an attacked square, and does not land on one. So now the king's canMove needs to know every square the enemy currently attacks, which means running every enemy piece's movement rules. A piece's own method now depends on all sixteen enemy pieces.

Four: the universal rule kills it outright. No move may leave your own king attacked. That is the position at the top of this page. To know whether the bishop may go to d3, you have to make the move and look at the resulting position. Not the current position — the one that would exist afterwards.

So the true signature is (piece, board, history, everything the opponent could reply), evaluated on a position that does not exist yet. That is not a method on a piece. The object was never able to answer the question, and every attempt to feed it more parameters spreads king-safety logic across six classes, where it will be subtly different in each one.

Say this collapse out loud in an interview, deliberately. "This works for knights. Here is where it dies: rooks need the board, pawns need the last move, castling needs the enemy's attacks, and pins need me to try the move first. Legality is a property of the position, not of the piece, so I am going to restructure." Narrating the recovery is worth more than never having written the wrong thing, because recognising a mis-scoped model is the skill actually being tested.

2. Generate, then filter

Reorganise around the question the game actually asks, which is never "can this piece move there" but always "what are all the legal moves right now".

typescript
interface MoveGenerator {                                       // (1)
  generate(from: Square, board: Board, history: History): Move[];
}

const GENERATORS: Record<PieceKind, MoveGenerator> = {          // (2)
  knight: new LeaperGenerator(KNIGHT_OFFSETS),                  // (3)
  king:   new KingGenerator(),                                  // (4)
  bishop: new SliderGenerator(DIAGONALS),                       // (5)
  rook:   new SliderGenerator(ORTHOGONALS),
  queen:  new SliderGenerator([...DIAGONALS, ...ORTHOGONALS]),
  pawn:   new PawnGenerator(),
};

function legalMoves(color: Color, board: Board, history: History): Move[] {
  const pseudo = board.piecesOf(color)                          // (6)
    .flatMap(p => GENERATORS[p.kind].generate(p.square, board, history));

  return pseudo.filter(m => {                                   // (7)
    const after = board.apply(m);
    return !after.isAttacked(after.kingSquare(color), opposite(color));
  });
}

(1) A generator produces the moves a piece could make ignoring king safety entirely. These are usually called pseudo-legal moves, and the name is worth keeping because it says exactly what is missing.

(2) A table from piece kind to generator, typed so the compiler requires an entry for every kind. Adding a piece to the union without adding a generator is a build failure.

(3) A knight is a leaper: a fixed set of offsets, each valid if it is on the board and not occupied by one of your own pieces. The king is nearly the same, with eight one-square offsets.

(4) The king still gets its own generator rather than a leaper, because castling lives here and nowhere else.

(5) Bishops, rooks and queens are one generator with different directions. From the square, walk in a direction until you leave the board or hit a piece. If that piece is an enemy, the capture is included and the walk stops. If it is your own, the walk stops before it. Three piece types, one implementation, and the only difference is a list of directions. A queen is literally a bishop's directions plus a rook's.

(6) Every piece of the moving colour, all its pseudo-legal moves, flattened into one list.

(7) And then the filter, which is the entire design in three lines. Apply the move to get the position that would result, find your king in that position, and reject the move if the enemy attacks it.

What that one filter handles, all of it, with no special cases anywhere.

Pins. The bishop on e2 generates its move to d3 quite happily. The filter applies it, sees the rook's line to the king, and rejects it. Nothing anywhere in the code knows the word "pin".

Moving into check. The king generates a step to an attacked square. The filter applies it and sees the king attacked. Rejected.

Being in check. When you are in check, every move that does not resolve it is rejected, so the legal move list is automatically the list of ways out. Block, capture the attacker, or move the king, and all three appear because they are the moves that survive, not because anybody enumerated the three options.

Discovered checks against yourself. Moving a piece that was shielding your king exposes it, and the filter catches it for the same reason it catches a pin.

One rule, one place. The alternative is king-safety logic living inside every piece's movement code, where the rook's version and the knight's version will drift apart and one of them will be wrong in a way nobody finds for months.

3. The detail that makes the filter correct, and a bug it prevents

The filter tests the position after the move, and there is a specific reason that is not the same as testing the position before.

A black rook on e8 gives check to a white king on e5. The king wants to run to e4. On the current board, is e4 attacked? Walk the rook's line down the e-file: e7, e6, and then e5, where it hits the white king and stops. So on the current board, e4 is not attacked, and a filter that checked the current board would allow the king to step to e4 and remain in check.

On the board after the move, the king is on e4 and e5 is empty, so the rook's line runs all the way through and e4 is attacked. Rejected, correctly.

The king moving directly away from a slider along its own line is the classic bug in a first chess engine, and it exists entirely because the check was done against the wrong position. Applying the move first is not a convenience; it is what makes the answer right.

A second detail, in the opposite direction: attack detection must not be filtered for legality.

typescript
isAttacked(square: Square, by: Color): boolean {                // (1)
  return this.piecesOf(by).some(p =>
    GENERATORS[p.kind].generate(p.square, this, EMPTY_HISTORY)  // (2)
      .some(m => m.to.equals(square)));
}

(1) One question, asked by the filter, by castling, and by checkmate detection.

(2) It uses pseudo-legal generation, deliberately, and this is the part people get wrong. A pinned enemy bishop is not allowed to move, and it still attacks the squares along its diagonal, which means your king may not step onto them. Attack is about where a piece could capture, not about whether moving there would be legal for its owner. Filtering here would produce a king that walks into check from a pinned piece, which is both a rules violation and very hard to debug.

Passing an empty history is a small honest detail: attack detection does not care about en passant or castling, because neither of those can capture the king.

One geometry engine. isAttacked reuses the same generators as movement, so there is exactly one description of how a bishop moves in the entire system. If a future variant changes it, everything that depends on it changes together and nothing can drift.

4. The three irregular rules, in full

These are what an interviewer probes to find out whether you have actually thought about chess or only about design.

Castling lives entirely in the king's generator.

typescript
#castlingMoves(from: Square, board: Board, history: History): Move[] {
  const color = board.at(from)!.color;
  if (board.isAttacked(from, opposite(color))) return [];        // (1)

  const out: Move[] = [];
  for (const side of ["king", "queen"] as const) {
    const rookSquare = CASTLE_ROOK[color][side];
    if (history.hasMoved(from) || history.hasMoved(rookSquare)) continue;  // (2)
    if (!board.at(rookSquare)?.is(color, "rook")) continue;
    if (!EMPTY_BETWEEN[color][side].every(sq => board.isEmpty(sq))) continue;  // (3)
    if (KING_PATH[color][side].some(sq =>                        // (4)
          board.isAttacked(sq, opposite(color)))) continue;
    out.push({ from, to: KING_DEST[color][side], special: `castle-${side}` });
  }
  return out;
}

(1) You cannot castle out of check. Checked first because it rules out both sides at once.

(2) Neither the king nor that particular rook may ever have moved. It is not "has not moved recently" and it is not undone by moving back: a rook that went to h3 and returned to h1 has permanently lost the right. This is why the generator needs the history, and it is why history is a parameter rather than something derived from the board.

(3) Every square between the king and the rook must be empty. For queenside castling that is three squares, including the b-file square, which is easy to forget because it is not on the king's path.

(4) The king may not pass through an attacked square, nor land on one. KING_PATH is the squares the king crosses, which is two squares on each side.

And here is the subtlety worth volunteering, because it separates people who play from people who have read a summary. For queenside castling, the b-file square must be empty but it may be attacked, and castling is still legal. The king travels from e1 to c1, crossing d1, so it never touches b1. The rook crosses b1, and there is no rule about a rook passing through attacked squares. Implementations that apply the "no attacked squares" test to everything between the king and the rook get this wrong, and it is a real position that occurs in real games.

En passant lives entirely in the pawn's generator, and its condition is exact.

typescript
#enPassant(from: Square, board: Board, history: History): Move[] {
  const last = history.lastMove();                               // (1)
  if (!last || last.piece !== "pawn") return [];
  if (Math.abs(last.from.rank - last.to.rank) !== 2) return [];   // (2)
  if (last.to.rank !== from.rank) return [];                      // (3)
  if (Math.abs(last.to.file - from.file) !== 1) return [];        // (4)

  return [{ from, to: squareBehind(last.to), special: "en-passant" }];
}

(1) Only the immediately previous move. The right expires after one move and never comes back. That single-move window is the whole reason history is needed at all, and a design that stores "this pawn is capturable en passant" as a flag on the pawn has to remember to clear it, which is exactly the kind of thing that gets forgotten.

(2) It was a two-square advance.

(3) and (4) Your pawn is on the same rank it landed on and in an adjacent file.

The capture is unusual in a way that matters to apply: the captured pawn is not on the destination square. Your pawn moves diagonally to an empty square, and the enemy pawn one rank behind it is removed. Every other capture in chess removes the piece standing where you land. This is why the special marker is carried on the move rather than being inferred later, and why apply needs a branch for it.

Promotion is the third, and it has a modelling consequence people miss.

A pawn reaching the last rank must become a queen, rook, bishop or knight. That means one pawn advance is four different moves, not one move with a choice attached, because they lead to four different positions.

typescript
if (isPromotionRank(to)) {
  return PROMOTION_PIECES.map(kind => ({ from, to, special: { promoteTo: kind } }));
}

Generating all four matters for more than tidiness. Underpromotion to a knight is occasionally the only winning move, because a knight reaches squares a queen cannot, and a legal-move list that only offers a queen will report checkmate in positions where a knight promotion escapes it. It is rare and it is not theoretical.

5. Boards that do not change, and why that is worth it

typescript
class Board {
  apply(m: Move): Board {                                        // (1)
    const next = this.#copy();
    const piece = next.#take(m.from);

    if (m.special === "en-passant") next.#take(squareBehind(m.to));      // (2)
    if (m.special === "castle-king" || m.special === "castle-queen")
      next.#put(rookDestination(m), next.#take(rookOrigin(m)));         // (3)

    next.#put(m.to, typeof m.special === "object"                       // (4)
      ? { ...piece, kind: m.special.promoteTo }
      : piece);

    return next;                                                        // (5)
  }
}

(1) Applying a move returns a new board and leaves this one untouched.

(2) En passant removes a piece that is not on the destination square.

(3) Castling moves two pieces in one move.

(4) Promotion replaces the pawn with the chosen piece.

(5) All three irregularities are handled here and in the generators. The filter never learns about any of them, which is what keeps the hardest rule in the design to three lines.

Why immutable, argued rather than asserted. The filter applies every candidate move and inspects the result. With a mutable board that means make the move, test, and undo it exactly, and "exactly" is doing a lot of work: undoing a castle restores two pieces, undoing en passant restores a pawn that was never on the destination square, undoing a promotion turns a queen back into a pawn, and the castling rights and en-passant availability that the move changed have to be restored too. Forgetting any one of them corrupts the position in a way that shows up several moves later, in a position nobody can reproduce. It is the most famous category of bug in amateur engines.

Copying a board makes the whole class disappear. There is nothing to restore because nothing changed.

Three things fall out for free. The move history can be a list of positions rather than a list of moves, so repetition detection is a comparison rather than a replay. A computer opponent can explore branches without any risk of corrupting the real game. And an undo is picking up an earlier board.

The cost, stated honestly. Validating one move means applying perhaps thirty-five candidates, so thirty-five board copies. At human speed that is invisible. A search exploring millions of positions a second cannot pay it, and there the answer is the mutable make-and-unmake spelling behind the same interface, which is what every real engine does. Section 8 is about that boundary. Correctness-first for the rules, performance-first for the search, one interface over both.

6. The game: one gate, and endings that are definitions

typescript
class Game {
  #board = Board.initial();
  #history: Move[] = [];
  #status: Status = { kind: "active", toMove: "white" };

  play(m: Move): Status {
    if (this.#status.kind !== "active") throw new GameOverError();
    const legal = legalMoves(this.#status.toMove, this.#board, this.#history);
    if (!legal.some(l => sameMove(l, m))) throw new IllegalMoveError(m);   // (1)

    this.#board = this.#board.apply(m);
    this.#history.push(m);
    this.#status = this.#deriveStatus();                                   // (2)
    return this.#status;
  }

  #deriveStatus(): Status {
    const toMove = opposite(lastMover(this.#history));
    const moves = legalMoves(toMove, this.#board, this.#history);

    if (moves.length === 0) {                                              // (3)
      return this.#board.isAttacked(this.#board.kingSquare(toMove), opposite(toMove))
        ? { kind: "checkmate", winner: opposite(toMove) }
        : { kind: "stalemate" };
    }
    if (this.#drawByRepetition() || this.#drawByFiftyMoves()) 
      return { kind: "draw", reason: "..." };                              // (4)
    return { kind: "active", toMove };
  }
}

(1) One gate. A move is legal if it is in the legal move list, and there is no second place in the system where legality is decided. That means there is no way for a move to be accepted by one path and rejected by another, which is the failure mode of designs that validate in several places.

(2) The status is derived after every move, never assigned by whoever thought they knew.

(3) And here is the payoff. Checkmate and stalemate are not detected. They are read off the definitions.

No legal moves and the king is attacked is checkmate. That is literally what checkmate means. No legal moves and the king is not attacked is stalemate. That is literally what stalemate means.

Nothing looks for attacking pieces or enumerates escape squares. A design that hand-writes mate detection duplicates the work the filter already did and will disagree with it on the awkward positions: a smothered mate where the king is surrounded by its own pieces, or a position where the only piece that could block is pinned. Those are correct here without anybody thinking about them, because "no escape exists" is exactly what an empty filtered list means.

(4) The draws by rule ride on the history, and one of them has a detail worth chasing.

Threefold repetition is not "the same pieces on the same squares three times". Two positions are the same only if the same side is to move, the same castling rights exist, and the same en-passant capture is available. Those last two are the interesting ones. A position where you can still castle is genuinely a different position from the identical arrangement after your rook has moved and come back, because the moves available differ. Comparing only piece placement declares draws that are not draws.

So the comparison key is the placement plus the side to move plus the four castling rights plus which en-passant capture, if any, is currently possible. That is exactly what a position hash in a real engine contains, and the reason it contains them is this rule.

The fifty-move rule counts moves since the last capture or pawn move, which is two counters updated in apply. It is easy, and it is worth pairing with repetition because both are folds over the history rather than state anybody maintains separately.

Insufficient material is the third draw and it is a small table: king against king, king and bishop against king, king and knight against king, and king and bishop against king and bishop with both bishops on the same colour squares. It is a lookup rather than a rule, and mentioning it shows you enumerated the ways a game can end rather than only the famous two.

7. Undo, and the history as the game

typescript
undo(): void {
  this.#history.pop();
  this.#board = this.#history.reduce((b, m) => b.apply(m), Board.initial());  // (1)
}

(1) Replay from the start. A chess game is at most a few hundred moves, so this is thousands of operations, which is instant. Keeping a stack of previous boards makes it a pop instead, at the cost of holding a few hundred boards, and either is fine.

The reason to mention both is the more general point. The move list is the game. Everything else is a view of it: the current position is a fold, undo is a shorter fold, repetition is a comparison over the fold, and writing the game out in notation is a formatting of the list. Sending the game to another player over a network is sending the list. This is the same ledger shape as the expense sharing in 9.7.29, and it appears here for the same reason, which is that an append-only record of what happened answers more questions than a maintained summary of where things stand.

8. Where change lands

"Add a new piece." Fairy variants have an archbishop, which moves as a knight or a bishop.

typescript
GENERATORS.archbishop = new CompositeGenerator([
  new LeaperGenerator(KNIGHT_OFFSETS),
  new SliderGenerator(DIAGONALS),
]);

One entry, composed from generators that already exist. The filter, attack detection, mate detection, the history and Game are all untouched, because none of them enumerate piece kinds — they consult the table. And the Record<PieceKind, MoveGenerator> type means that adding archbishop to the union without adding the entry fails to compile, so the omission is a build error rather than a piece that silently has no moves.

"Pawns promote on rank 6 in this variant." Promotion lives in one generator and one branch of apply, and the rank is a number. Lift it into a rules object holding promotionRank, castlingEnabled, board dimensions and the starting position, pass it to the generators, and variants become configuration. The second variant request, which there will be one of, then costs nothing.

"Make it a network game." The move list is already the protocol. The server holds the authoritative Game and the only thing that decides legality is play, because a client can be modified and its opinion of the rules cannot be trusted. Clients render and suggest; the server decides. Spectators receive applied moves as they happen, and someone reconnecting replays the list from where they left off — which means reconnection and spectating are the same operation at different offsets, and noticing that is worth saying.

"Add a computer opponent." It consumes legalMoves and apply and runs a search, which is an algorithms problem rather than a design one. What matters here is the interface it needs and the performance boundary it crosses.

A search exploring millions of positions cannot afford a board copy per candidate. So the search path uses a mutable board with make and unmake, behind the same interface the rules use, and it takes on exactly the restore-everything burden that section 5 described — because there the speed is worth the care, and the code doing it is small and heavily exercised. Attack detection also gets replaced, since running every enemy generator per candidate is far too slow, and engines use precomputed attack tables and bitboards instead.

The sentence that matters: the design's interfaces survived and two implementations behind them were replaced for a consumer with a different budget. Same rules, different bookkeeping, which is the same trade the LRU cache made in 9.7.30 when production chose sampled approximation over exact recency.

9. What the interviewer will push on

"Start modelling and show me the pieces." They are watching for whether you notice the collapse and how you handle it. Write canMove, get to rooks needing the board, pawns needing the last move, castling needing the enemy's attacks, and pins needing you to try the move first, then say plainly that legality is a property of the position and restructure. Narrating the recovery beats never having written the wrong thing, because recognising a mis-scoped model is the skill being tested. The wrong answer keeps patching the signature until king-safety logic is spread across six classes.

"What does your one filter actually cover?" Pins, moving into check, escaping check, and discovered checks against yourself. All four are the same sentence — the position after this move must not have my king attacked — and none of them appear as a named rule anywhere in the code. The tell is being able to say why the check-escape case needs no code at all: the legal move list, when you are in check, is the list of escapes.

"Why do you test the position after the move rather than before?" Because a king running directly away from a checking rook, along the rook's own file, is not attacked on the current board — the king itself blocks the line. Test the resulting position and the line is open and the move is correctly rejected. This is the classic first-engine bug and knowing it is a strong signal.

"Does your attack detection filter for legality?" It must not. A pinned enemy bishop cannot legally move and still attacks its diagonal, so your king may not step there. Attack is about where a capture could land, not about whether the owner is allowed to move. Candidates who reuse legalMoves for attack detection produce a king that walks into check from a pinned piece, and it is very hard to find.

"Walk me through castling." They want all of it: neither the king nor that rook ever moved (and moving back does not restore the right), the squares between are empty, not in check, not through an attacked square, not into one. The thing to volunteer is that for queenside castling the b-file square must be empty but may be attacked, because the king never crosses it — only the rook does, and there is no rule about rooks passing through attacked squares.

"How do checkmate and stalemate work?" No legal moves plus king attacked is checkmate; no legal moves and king not attacked is stalemate. They are definitions read off the pipeline, not detectors. The follow-up worth pre-empting is that hand-written mate detection duplicates the filter's work and diverges on smothered mate and on positions where the only blocker is pinned.

"Why immutable boards?" Because the filter tries every candidate move, and with a mutable board that means undoing exactly — including two pieces for a castle, a pawn that was never on the destination square for en passant, a promotion, and the castling rights and en-passant availability the move changed. Forgetting one corrupts the position several moves later in a position nobody can reproduce. Then concede the cost honestly: about thirty-five copies per validated move, invisible at human speed and impossible for a search, which is why the search path uses make and unmake behind the same interface.

The thing to volunteer that nobody asks for: threefold repetition does not compare piece placement. Two positions are the same only if the same side is to move, the same castling rights exist, and the same en-passant capture is available, because those change which moves exist. Comparing only placement declares draws that are not draws. Almost nobody raises it, and it is the reason a real engine's position hash contains exactly those extra fields.

Recall

  • Legality is a property of the position, not of the piece. A bishop shielding its king cannot move, and nothing about the bishop changed.
  • The four escalating failures of canMove: sliders need the board, pawns need the last move, castling needs the enemy's attacks, pins need you to try the move first.
  • Generate, then filter. Per-kind generators produce pseudo-legal moves; one filter applies each and rejects it if your own king ends up attacked.
  • That one filter covers pins, moving into check, escaping check, and discovered checks — and when you are in check, the legal list is the list of escapes.
  • Test the position after the move. A king running away along a checking rook's file looks safe on the current board because the king blocks the line.
  • Attack detection must not filter for legality. A pinned enemy bishop still attacks its diagonal, so your king may not step there.
  • isAttacked reuses the generators, so there is exactly one description of how each piece moves.
  • Bishop, rook and queen share one slider generator, differing only in a list of directions.
  • Castling: never-moved king and that rook (moving back does not restore it), squares between empty, not in check, not through an attacked square, not into one. Queenside's b-file square must be empty but may be attacked.
  • En passant depends only on the immediately previous move and expires after one move, and it removes a pawn that is not on the destination square.
  • Promotion generates four moves, not one. Underpromotion to a knight is sometimes the only escape from mate.
  • apply returns a new board. Mutable make-and-unmake must restore two pieces for a castle, an off-square pawn for en passant, the promoted piece, and the castling and en-passant rights.
  • Checkmate and stalemate are definitions, not detectors: no legal moves, split by whether the king is attacked.
  • Threefold repetition compares placement plus side to move plus castling rights plus en-passant availability, because those change which moves exist.
  • One gate: a move is legal if it is in the legal list, and legality is decided nowhere else.
  • The move list is the game. Position, undo, repetition, notation and network sync are all views of it.
  • New piece = one table entry composing existing generators, and a missing entry is a compile error.
  • The search path swaps in make-and-unmake and attack tables behind the same interface. Same rules, different bookkeeping.

Self-test: Name the four failures of piece-local legality in order. What single sentence does the filter enforce, and which four rules does it subsume? Why after and not before? Why must attack detection be pseudo-legal? State castling's five conditions and the b-file exception. What exactly makes two positions "the same" for repetition?

Quiz Bank

FoundationalWhy does giving each piece a canMove method fail? Go rule by rule.

It works completely for one piece, and that is what makes it seductive.

typescript
class Knight extends Piece {
  canMove(from: Square, to: Square): boolean {
    const df = Math.abs(from.file - to.file), dr = Math.abs(from.rank - to.rank);
    return (df === 2 && dr === 1) || (df === 1 && dr === 2);
  }
}

Knights jump, so nothing between the squares matters, and this is correct and complete. Then four rules break it, each needing something larger than the last.

One — sliders need the board. A rook on a1 reaches a8 only if a2 through a7 are empty. Pure geometry cannot answer that, so the signature grows a board parameter. Ugly and survivable.

Two — pawns need the history. The two-square advance is allowed only from the starting rank, which is really "only if this pawn has not moved". And en passant depends on nothing about the board at all: a pawn that has just made a two-square advance past your pawn may be captured as though it had advanced one, and only on the immediately following move. That is a fact about the previous move, so the signature grows again.

Three — castling needs the opponent's entire attack map. The king may not castle out of check, through an attacked square, or into one. To answer whether the king may castle, you must run every enemy piece's movement rules over the whole board. A single piece's method now depends on all sixteen enemy pieces.

Four — the universal rule breaks it beyond repair. No move may leave your own king attacked. A white bishop on e2, in front of its king on e1, with a black rook on e8, may not move to d3 even though every bishop rule says it may. To know that, you have to make the move and examine the resulting position, not the current one.

So the honest signature is (piece, board, history, all enemy replies) evaluated against a position that does not exist yet. That is not a method on a piece, and it never was. Every attempt to save it by passing more parameters spreads king-safety logic across six classes, where the rook's version and the pawn's version will diverge and one will be wrong in a position nobody tests.

The fix is to change the question. The game never asks "can this piece move here". It asks "what are all the legal moves right now", and that question has a natural two-stage answer: generate what each piece could do from geometry, then filter the whole list by one positional rule.

Say the collapse deliberately in an interview. "This is fine for knights. It dies at rook paths, at en passant, at castling through check, and at pins. Legality belongs to the position, so I am restructuring to generate-then-filter." The narrated recovery is worth more than a clean first guess, because recognising a mis-scoped model is precisely the judgement being assessed.

AppliedExplain generate-then-filter, everything the filter subsumes, and the two ordering details that make it correct.

Stage one — pseudo-legal generation. Each piece kind has a generator producing the moves its geometry allows, ignoring king safety entirely.

Leapers (knight, king) use a fixed offset table, keeping any destination on the board that is not occupied by a friendly piece. Sliders (bishop, rook, queen) are one generator that walks each of a list of directions until it leaves the board or hits a piece, including the capture if that piece is an enemy. Bishop, rook and queen differ only in their direction list, and a queen's list is the other two combined. The pawn generator owns everything irregular about pawns: the one-square push, the two-square push from the starting rank, diagonal captures only, en passant, and promotion. The king generator owns castling.

Stage two — one filter.

typescript
return pseudo.filter(m => {
  const after = board.apply(m);
  return !after.isAttacked(after.kingSquare(color), opposite(color));
});

Apply the move, find your king in the resulting position, reject if it is attacked.

What that one sentence subsumes.

Pins. The bishop generates its move to d3; the filter applies it, sees the rook's line to the king, rejects it. The word "pin" appears nowhere in the code.

Moving into check. The king generates a step to an attacked square; the filter rejects it, using the same line.

Escaping check. When you are in check, only moves that resolve it survive the filter. The legal move list is the escape list, and nobody had to enumerate block, capture and run as three cases.

Discovered check against yourself. Moving a piece that was shielding your king exposes it, and the filter catches it for the same reason as a pin.

Ordering detail one: the filter tests the position after the move, and that is not interchangeable with before.

A black rook on e8 checks a white king on e5, and the king wants to go to e4. On the current board, is e4 attacked? The rook's line runs e7, e6, and stops at e5 where the white king stands. So e4 looks safe, and a filter testing the current position lets the king step into check. On the board after the move, the king is on e4 and e5 is empty, so the line runs through and e4 is attacked, correctly rejected.

A king running directly away from a checking slider along its own line is the classic first-engine bug, and it exists entirely because somebody tested the wrong position.

Ordering detail two: attack detection must use pseudo-legal moves, never legal ones.

typescript
isAttacked(square: Square, by: Color): boolean {
  return this.piecesOf(by).some(p =>
    GENERATORS[p.kind].generate(p.square, this, EMPTY_HISTORY)
      .some(m => m.to.equals(square)));
}

A pinned enemy bishop may not legally move, and it still attacks the squares on its diagonal, so your king may not step onto them. Attack is about where a capture could land, not about whether its owner is permitted to make the move. Reusing legalMoves here produces a king that walks into check from a pinned piece, which is a rules violation that is extremely hard to track down. The empty history passed in is deliberate too: attack detection does not care about en passant or castling, because neither can capture a king.

And isAttacked reusing the generators means there is exactly one description of each piece's movement in the whole system. Movement, threat detection and mate detection all consult it, so a change to how a piece moves changes everything together and nothing can drift.

One rule, one place. The alternative is king-safety logic inside every piece's movement code, where six copies will not stay identical.

InterviewWalk through castling, en passant, promotion and the draw rules. Where does each live, and what do people get wrong?

Castling — entirely inside the king's generator. Five conditions:

The king is not currently in check. Neither the king nor that particular rook has ever moved. All squares between them are empty. The king does not pass through an attacked square. The king does not land on one.

What people get wrong, in order of frequency.

Treating "has not moved" as recoverable. A rook that went to h3 and came back to h1 has permanently lost the right, so this is a fact about the history and cannot be read from the board. It is one of the two reasons history is a parameter to generation.

Forgetting the b-file square on the queenside. Three squares must be empty there, not two, and the b-file one is easy to miss because the king never touches it.

Applying the "not attacked" rule to all of those squares. This is the one worth volunteering. For queenside castling, b1 must be empty but may be attacked, and castling is still legal. The king travels e1 to c1, crossing d1 only. The rook crosses b1, and there is no rule about rooks passing through attacked squares. Implementations that test every square between king and rook reject a legal move that occurs in real games.

En passant — entirely inside the pawn's generator. The condition is exact and narrow: the immediately previous move was an enemy pawn advancing two squares, landing on the same rank as your pawn and in an adjacent file.

What people get wrong: storing it as a flag on the pawn. The right lasts for exactly one move and then vanishes forever, so a flag has to be cleared, and something eventually forgets. Reading it from the last move means there is nothing to clear.

The other trap is in apply: the captured pawn is not on the destination square. Your pawn moves diagonally onto an empty square and the enemy pawn one rank behind is removed. Every other capture in chess removes whatever stands where you land, so generic capture code silently does nothing here and leaves an extra pawn on the board.

Promotion — one branch in the pawn generator, one in apply. A pawn reaching the last rank becomes a queen, rook, bishop or knight, and all four are separate moves because they lead to four different positions.

What people get wrong: generating only the queen. Underpromotion to a knight is occasionally the only move that wins or the only move that escapes mate, because a knight reaches squares no queen can. A move list offering only queens will report checkmate in positions that are not mate. Rare, and not hypothetical.

The draws — all folds over the history.

Threefold repetition, and this is the detail worth chasing. Two positions are the same only if the same side is to move, the same castling rights exist, and the same en-passant capture is available. The last two are what people miss. A position where you can still castle is a genuinely different position from the identical arrangement after your rook has moved and returned, because the available moves differ. Comparing piece placement alone declares draws that are not draws.

So the comparison key is placement plus side to move plus four castling rights plus the en-passant file if any, which is exactly what a real engine's position hash contains — and this rule is why it contains them.

Fifty-move rule: a counter of moves since the last capture or pawn move, reset in apply. Trivial once the counter exists.

Insufficient material: a small table rather than a rule. King against king; king and bishop against king; king and knight against king; king and bishop against king and bishop with both bishops on the same colour. Naming it shows you enumerated the ways a game ends rather than the two famous ones.

The design point across all of these: every irregularity is confined to generation and application. The filter, isAttacked, Game and the status derivation never learn that castling or en passant exist, which is why the hardest rule in chess stays three lines long.

StaffTake this to a server running 50,000 concurrent games with spectators, reconnection, and a computer opponent. What survives, what is added, and which decisions get revisited?

What survives is the engine, and it survives as the single authority on legality. Clients render the board and suggest moves; the server's play decides. That is not defensive programming for its own sake — a client can be modified, so its opinion of the rules is worth nothing, and the one-gate design means there is exactly one place that opinion is checked.

Fifty thousand games is not a scale problem, which is worth saying early. A game is a move list of a few hundred entries plus a position, so it is kilobytes. The whole fleet's live state is well under a gigabyte. What matters is not size but isolation: no game needs anything from any other game, so games shard by identifier across processes with zero coordination.

Concurrency has one interesting property and it is a pleasant one. Two players submit moves for the same game, and one game is a single sequence of moves. Handling each game's submissions one at a time removes every race in the system: no locks, no versions, no comparison. That is the actor arrangement from 9.5.4, and chess fits it perfectly because the domain is inherently sequential. The only real check is that the submitted move comes from the player whose turn it is, which play already enforces because the status carries whose turn it is.

Everything added rides on the move list.

Persistence is the list plus periodic position snapshots, so a restart replays a few moves rather than the whole game.

Spectators receive applied moves as they happen.

Reconnection replays the list from the offset the client last had.

And those last two are the same operation at different offsets, which is worth pointing out because it turns two features into one implementation. A spectator joining at move forty and a player reconnecting after missing three moves are both "send me everything after N".

Notation and game export are formatting of the list.

Cheat detection is an offline consumer of the same lists, comparing played moves against engine choices. It needs no engine change at all, which is the receipt for having kept the history as the source of truth.

The computer opponent is where two decisions get revisited, and both are measured rather than assumed.

First: immutable boards. Validating one human move copies the board about thirty-five times, which at human move rates across fifty thousand games is a few hundred validations a second fleet-wide and completely invisible. A search exploring millions of positions a second cannot pay it.

So the search path uses a mutable board with make and unmake, behind the same interface. That path takes on exactly the burden section 5 avoided — restoring two pieces for a castle, a pawn that was never on the destination square for en passant, the promoted piece, and the castling and en-passant rights the move changed — and it is worth it there for two reasons. The speed genuinely matters, and the code is small and exercised millions of times per second, so a mistake surfaces immediately rather than lurking.

Second: attack detection. Running every enemy generator per candidate is fine for validating one move and hopeless inside a search. Engines precompute attack tables and represent the board as bit patterns so that "which squares does this rook attack" is a few machine operations rather than a loop. Same interface, different implementation.

And the search itself never runs on the game loop. It is CPU-bound work measured in seconds, so it goes to a worker pool, and a game waiting for a computer move is waiting on a message rather than blocking anything.

What I would push back on. A request to let clients validate moves locally to reduce server load. The load is not the problem — validation is microseconds and the server is doing it anyway to decide whether to broadcast. What such a change actually buys is a second implementation of the rules, in a language the server does not control, that will disagree with the first. Two rule engines is how you get a game where one player's client shows a legal move that the server rejects, and there is no good way to explain that to a user. Clients may preview legality for responsiveness, and the server's answer is the only one that changes the game.

What I would measure. Rejected-move rate per client version, because a spike means a client's preview logic has drifted from the server's rules. Games with no move for a long time, which is either a disconnection or a stuck process. Search time per computer move against its depth, because that is where a performance regression shows first. And replay time on reconnection, since a growing number there means the snapshot interval needs tightening.

Flashcards

FlashWhy piece-local legality dies

Sliders need the board, pawns need the last move, castling needs the enemy's attack map, and pins need you to try the move first. Legality is a property of the position, evaluated on a board that does not exist yet.

FlashGenerate then filter

Per-kind generators emit pseudo-legal moves from geometry. One filter applies each and rejects it if your own king ends up attacked, which covers pins, moving into check, escaping check and discovered checks with no named rule anywhere.

FlashWhy the position after

A king running away along a checking rook's file is not attacked on the current board, because the king itself blocks the line. On the resulting board the line is open. Testing before is the classic first-engine bug.

FlashAttack detection is pseudo-legal

A pinned enemy bishop cannot move and still attacks its diagonal, so your king may not step there. Attack is where a capture could land, not whether the owner may move. Reusing legal moves here lets a king walk into check.

FlashCastling's b-file exception

Queenside needs three empty squares including b1, and b1 may be attacked — the king travels e1 to c1 crossing only d1, and only the rook crosses b1. Testing every square between king and rook rejects a legal move.

FlashWhat makes two positions the same

Piece placement, plus side to move, plus castling rights, plus en-passant availability. Those change which moves exist, so comparing placement alone declares draws that are not draws. It is why engine position hashes carry exactly those fields.

Next: 9.7.8 — the ATM, where the design has to survive the machine losing power halfway through handing over cash.