Design Chess
Build a Chess Engine (Design Chess)
Chess is the LLD interview that separates "I can draw a class diagram" from "I can reason about correctness under adversarial pressure." Any candidate can sketch Board, Piece, Move and a per-piece movement rule. What actually gets probed is the sentence right after: "okay, now prove your engine never lets a player leave their own king in check." That single requirement — a move must be filtered by "does this leave MY king attacked?" before it's offered to the player — is where naive designs quietly ship an illegal-move bug, and where this kata lives. You already know the class breakdown from the Design Chess walkthrough; this project makes you build the piece hierarchy and the legal-move filter from an empty file in Java and Go, break the naive version on a concrete pinned-piece position, and walk out able to defend checkmate detection, the pseudo-legal-then-filter architecture, and how a real engine (bitboards) differs.
1. The Trap
You model the obvious classes: Board, an abstract Piece with a subclass per piece type, and each piece knows how it moves — a knight hops in L-shapes, a rook slides until blocked. You wire up generateMoves(square), dispatch through the hierarchy, and it works: pieces move exactly the way chess pieces should move. You demo it. It looks done.
Then the interviewer sets up one position and asks you to play a move. White King on e1. White Knight on e4. Black Rook on e8, with a clear file in between. Your engine offers the knight every one of its normal L-shaped hops — say, Ne4-f6 — because from the knight's own point of view, hopping to f6 is a completely ordinary, unblocked move. It has no idea a king exists three squares below it on the same file. Play that move for real and the board now has White's own king sitting in the open, one rook-move from capture. Your engine just let a player make an illegal move that loses the game to a rule the engine never checked.
That's the trap: every piece's movement rule is a strictly local question ("can I physically reach that square, given what's occupying the board?"). Whether the move is safe — whether it leaves your own king exposed — is a global question about the whole board's attack pattern, and a per-piece movement method structurally cannot answer it. A design that stops at "can this piece reach this square" has silently answered a different, easier question than "is this move legal," and shipped the gap as a bug.
2. Scope it like a senior
Before writing a line, pin down what "build a chess engine" actually means here — a candidate who starts typing class Pawn immediately usually mis-scopes the hard part:
- What's the deliverable — a full rules engine, or the core mechanism? Official chess has six piece types plus castling, en passant, promotion, threefold repetition, the 50-move rule, and insufficient-material draws. Say out loud that you will build a representative subset that exercises the hard mechanism (a sliding piece, a jumping piece, the king, and the check filter) and name the rest as extensions — don't silently drop them, and don't try to boil the ocean in 45 minutes either.
- Board representation: a simple 8×8 array of piece references is the right default for an LLD interview (readable, O(1) square lookup). Bitboards (64-bit ints, one per piece type per color) are what real engines use for raw speed — name it, defer it to Optimise (§6).
- Move generation shape: pseudo-legal first, then filter — or generate only-ever-legal moves directly? State the pseudo-legal-then-filter default (simpler, provably correct) and that you'll name the alternative and its cost later (§6).
- What must be detected? Check, checkmate, and stalemate, at minimum — these are the three states that end or gate a game and the ones interviewers actually test.
- Concurrency: one board per game instance, single-threaded move application — unlike a shared allocator (parking lot, rate limiter), there's no cross-request contention inside one game to design around. Worth saying explicitly; over-engineering locking here is a tell of a candidate pattern-matching from a different kata.
- Special moves — castling / en passant / promotion: explicitly scope these OUT of the core build and INTO the extensions/defend sections. Each is a real rule with real state (has the king moved? what was the last move?) — volunteering a shallow, wrong version is worse than naming it as deferred and being ready to reason about it under drilling (§7).
For this kata: King, Rook, Knight — one piece that steps, one that slides, one that jumps, which is exactly enough structural variety to prove the polymorphic design and the check-filter both work. Bishop, Queen, and Pawn are noted but not re-implemented: Bishop is mechanically identical to Rook (diagonal slide instead of orthogonal), Queen is the union of both, and Pawn just adds direction-dependent capture/push rules plus promotion — the movement-generation pattern doesn't change, only the offset tables do.
3. Reason to the design
Simplest thing that could work: one giant generateMoves(pieceType, row, col) function with a switch on piece type, each case hand-coding that piece's rule. It compiles, it's easy to trace for a single piece, and for a toy demo it looks complete.
Why it fails: two independent problems pile up in one function. First, extensibility — adding a new piece type, or a chess variant with a custom piece, means editing one already-large function instead of adding a unit. Second, and far worse, is §1's trap: the switch only ever answers "how does this piece move," and there is no natural place inside a per-piece movement rule to ask "does making this move leave my OWN king attacked?" — that question needs the whole board's state, not just one square. Bolting it on inside the switch means duplicating "is X attacked" logic per piece type, and it is exactly the kind of duplication where someone forgets a case (a pinned knight, a discovered check from a piece nowhere near the king) and ships a bug that only shows up on a specific board position, not in casual testing.
The key move — split "can this piece reach that square" from "is this move safe": put move generation ON the piece (polymorphism: an abstract Piece with a pseudoLegalMoves(board, row, col) method, one subclass per piece type). This gives you pseudo-legal moves: obeys the piece's own rule and board occupancy, knows nothing about check. Then, as a completely separate, piece-agnostic, board-level operation, filter: for every pseudo-legal move, actually make it, ask "is my own king attacked now?", then undo it. This is the make → is-king-attacked → unmake pattern, and it is the entire fix — it doesn't matter whether the exposure is a simple pin, a discovered check from a completely different piece, or moving the king itself into an attacked square: they all reduce to the same one question asked after simulating the move.
The elegant reuse inside the fix: "is square S attacked by side X?" doesn't need its own hand-written logic — it's just "does any pseudo-legal move of side X's pieces land on S?" You already have that generator. One mechanism (pseudoLegalMoves) answers both "how can my pieces move" and, reused, "what does the enemy attack" — which is exactly how isKingAttacked is built below, and it's why the design doesn't need a second, separately-maintained "attack map."
4. Build it — milestones
Attempt each milestone yourself before reading the reference implementation below. Contract: an abstract Piece with pseudoLegalMoves(Board, row, col) → List<Move>; a Board with legalMoves(side) → List<Move>, isKingAttacked(side) → boolean, isCheckmate(side) → boolean, isStalemate(side) → boolean. Board coordinates: row 0 = rank 1, col 0 = file a (so e1 = (0,4), e8 = (7,4)).
- M1 — Piece hierarchy, pseudo-legal only. Implement
King(one step, 8 directions),Rook(slide until blocked, capture the blocker if enemy),Knight(8 fixed L-offsets). No check-awareness yet — unit test each piece's move list in isolation, including "stops at the first blocker" and "captures an enemy blocker but not a friendly one." - M2 — Attack detection. Build
isSquareAttacked(row, col, bySide)by reusing M1'spseudoLegalMoves(§3's key reuse) — no new per-piece logic. BuildisKingAttacked(side)on top of it. Test it on a position where the king is, and isn't, in the line of an enemy rook. - M3 — The legal-move filter (the reveal). Implement
makeMove/unmakeMoveandlegalMoves(side)= for every pseudo-legal move, make it, checkisKingAttacked(side), unmake it, keep only the ones that pass. This is the reference implementation below — and it's the milestone that fixes §1's trap. - M4 — Checkmate / stalemate detection.
isCheckmate(side) = isKingAttacked(side) && legalMoves(side).isEmpty();isStalemate(side) = !isKingAttacked(side) && legalMoves(side).isEmpty(). Both reuselegalMovesdirectly — no separate "count escape squares" logic. Test both a checkmate and a stalemate position (the reference tests below include one of each) to prove your code tells them apart. - M5 (extension, not built here) — castling, en passant, promotion. Scoped out per §2; walked through under drilling in §7.
Reference implementation — Java
The whole trick is the split between Piece.pseudoLegalMoves (movement physics, polymorphic per piece) and Board.legalMoves (the make→check→unmake safety filter, piece-agnostic). isSquareAttacked reuses pseudoLegalMoves instead of duplicating attack logic — that reuse is the design's whole leverage.
import java.util.*;
public class Chess {
enum Color { WHITE, BLACK }
static final class Move {
final int fromRow, fromCol, toRow, toCol;
Move(int fromRow, int fromCol, int toRow, int toCol) {
this.fromRow = fromRow; this.fromCol = fromCol;
this.toRow = toRow; this.toCol = toCol;
}
boolean sameAs(int fr, int fc, int tr, int tc) {
return fromRow == fr && fromCol == fc && toRow == tr && toCol == tc;
}
public String toString() {
return "" + (char) ('a' + fromCol) + (fromRow + 1) + (char) ('a' + toCol) + (toRow + 1);
}
}
// Every piece generates its OWN pseudo-legal moves -- polymorphism replaces
// a giant switch(pieceType) in the move generator. "Pseudo-legal" means the
// move obeys this piece's movement rule and the board's occupancy, but has
// NO idea whether it leaves the mover's own king in check. That filter is a
// separate, board-level concern -- see Board.legalMoves().
static abstract class Piece {
final Color color;
Piece(Color color) { this.color = color; }
abstract List<Move> pseudoLegalMoves(Board board, int row, int col);
abstract char symbol();
}
static final class King extends Piece {
King(Color c) { super(c); }
List<Move> pseudoLegalMoves(Board board, int row, int col) {
List<Move> moves = new ArrayList<>();
for (int dr = -1; dr <= 1; dr++) {
for (int dc = -1; dc <= 1; dc++) {
if (dr == 0 && dc == 0) continue;
int r = row + dr, c = col + dc;
if (board.inBounds(r, c) && board.canLandOn(r, c, color)) {
moves.add(new Move(row, col, r, c));
}
}
}
return moves; // castling omitted here -- see Optimise / Defend
}
char symbol() { return color == Color.WHITE ? 'K' : 'k'; }
}
static final class Rook extends Piece {
Rook(Color c) { super(c); }
List<Move> pseudoLegalMoves(Board board, int row, int col) {
List<Move> moves = new ArrayList<>();
int[][] dirs = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}};
for (int[] d : dirs) {
int r = row + d[0], c = col + d[1];
while (board.inBounds(r, c)) {
Piece occupant = board.get(r, c);
if (occupant == null) {
moves.add(new Move(row, col, r, c));
} else {
if (occupant.color != color) moves.add(new Move(row, col, r, c));
break; // a piece (either color) stops the slide
}
r += d[0]; c += d[1];
}
}
return moves;
}
char symbol() { return color == Color.WHITE ? 'R' : 'r'; }
}
static final class Knight extends Piece {
Knight(Color c) { super(c); }
List<Move> pseudoLegalMoves(Board board, int row, int col) {
List<Move> moves = new ArrayList<>();
int[][] offsets = {{1, 2}, {2, 1}, {-1, 2}, {-2, 1}, {1, -2}, {2, -1}, {-1, -2}, {-2, -1}};
for (int[] o : offsets) {
int r = row + o[0], c = col + o[1];
if (board.inBounds(r, c) && board.canLandOn(r, c, color)) {
moves.add(new Move(row, col, r, c));
}
}
return moves;
}
char symbol() { return color == Color.WHITE ? 'N' : 'n'; }
}
static final class Board {
final Piece[][] grid = new Piece[8][8]; // grid[row][col]; row 0 = rank 1, col 0 = file a
boolean inBounds(int r, int c) { return r >= 0 && r < 8 && c >= 0 && c < 8; }
Piece get(int r, int c) { return grid[r][c]; }
void set(int r, int c, Piece p) { grid[r][c] = p; }
boolean canLandOn(int r, int c, Color mover) {
Piece occupant = grid[r][c];
return occupant == null || occupant.color != mover;
}
// Pseudo-legal moves for a whole side: dispatch to each piece's own rule.
List<Move> pseudoLegalMoves(Color side) {
List<Move> moves = new ArrayList<>();
for (int r = 0; r < 8; r++) {
for (int c = 0; c < 8; c++) {
Piece p = grid[r][c];
if (p != null && p.color == side) moves.addAll(p.pseudoLegalMoves(this, r, c));
}
}
return moves;
}
// A square is attacked by `bySide` if any of that side's pseudo-legal
// moves targets it. Reusing pseudoLegalMoves here (instead of a second
// hand-rolled "attack map") is what keeps this correct -- one rule per
// piece, used for both move generation and check detection.
boolean isSquareAttacked(int row, int col, Color bySide) {
for (int r = 0; r < 8; r++) {
for (int c = 0; c < 8; c++) {
Piece p = grid[r][c];
if (p == null || p.color != bySide) continue;
for (Move m : p.pseudoLegalMoves(this, r, c)) {
if (m.toRow == row && m.toCol == col) return true;
}
}
}
return false;
}
int[] findKing(Color side) {
for (int r = 0; r < 8; r++) {
for (int c = 0; c < 8; c++) {
Piece p = grid[r][c];
if (p instanceof King && p.color == side) return new int[]{r, c};
}
}
throw new IllegalStateException("no king on board for " + side);
}
boolean isKingAttacked(Color side) {
int[] k = findKing(side);
Color enemy = (side == Color.WHITE) ? Color.BLACK : Color.WHITE;
return isSquareAttacked(k[0], k[1], enemy);
}
// make -> check -> unmake: the whole mechanism that turns a pseudo-legal
// move into a LEGAL one. Play it for real, ask "is my own king now
// attacked?", then undo it -- the board is never left in the tried state.
Piece makeMove(Move m) {
Piece captured = grid[m.toRow][m.toCol];
grid[m.toRow][m.toCol] = grid[m.fromRow][m.fromCol];
grid[m.fromRow][m.fromCol] = null;
return captured;
}
void unmakeMove(Move m, Piece captured) {
grid[m.fromRow][m.fromCol] = grid[m.toRow][m.toCol];
grid[m.toRow][m.toCol] = captured;
}
List<Move> legalMoves(Color side) {
List<Move> legal = new ArrayList<>();
for (Move m : pseudoLegalMoves(side)) {
Piece captured = makeMove(m);
if (!isKingAttacked(side)) legal.add(m);
unmakeMove(m, captured);
}
return legal;
}
boolean isCheckmate(Color side) { return isKingAttacked(side) && legalMoves(side).isEmpty(); }
boolean isStalemate(Color side) { return !isKingAttacked(side) && legalMoves(side).isEmpty(); }
// THE BUG, kept on the page deliberately: a "naive" generator that treats
// pseudo-legal moves as if they were already legal -- no make/check/unmake
// filter at all. Never call this from real game logic; it exists only to
// reproduce the break-it scenario below.
List<Move> naiveLegalMoves(Color side) {
return pseudoLegalMoves(side);
}
}
static void assertTrue(boolean cond, String label) {
if (!cond) throw new AssertionError("FAILED: " + label);
System.out.println("PASS: " + label);
}
static boolean contains(List<Move> moves, int fr, int fc, int tr, int tc) {
for (Move m : moves) if (m.sameAs(fr, fc, tr, tc)) return true;
return false;
}
public static void main(String[] args) {
// ---- Scenario 1: the pin. White King e1, White Knight e4, Black Rook e8. ----
// Board coords: row 0 = rank 1, col 0 = file a. e1=(0,4) e4=(3,4) e8=(7,4).
Board b = new Board();
b.set(0, 4, new King(Color.WHITE));
b.set(3, 4, new Knight(Color.WHITE));
b.set(7, 4, new Rook(Color.BLACK));
b.set(7, 0, new King(Color.BLACK)); // a8 -- present, irrelevant to the pin
assertTrue(!b.isKingAttacked(Color.WHITE),
"White king not currently in check -- the knight still blocks the e-file");
// The naive generator (the bug): does it think Ne4-f6 is a fine move?
List<Move> naive = b.naiveLegalMoves(Color.WHITE);
assertTrue(contains(naive, 3, 4, 5, 5),
"BUG: naive generator OKs Ne4-f6 -- it never looks at the king at all");
// Prove it's actually illegal: play it for real and re-check the king.
Move ne4f6 = new Move(3, 4, 5, 5);
Piece captured = b.makeMove(ne4f6);
assertTrue(b.isKingAttacked(Color.WHITE),
"reproduced: moving the pinned knight exposes White's own king to the rook on e8");
b.unmakeMove(ne4f6, captured);
// The real (filtered) legal-move list must reject exactly this move.
List<Move> legal = b.legalMoves(Color.WHITE);
assertTrue(!contains(legal, 3, 4, 5, 5),
"fix confirmed: legalMoves() filters out Ne4-f6 -- the pin is enforced");
assertTrue(naive.size() == 13 && legal.size() == 5,
"pin removes exactly the knight's 8 pseudo-legal hops (13 pseudo-legal -> 5 legal)");
// ---- Scenario 2: checkmate detection -- the two-rook "ladder" mate. ----
Board mate = new Board();
mate.set(7, 0, new King(Color.BLACK)); // a8
mate.set(6, 7, new Rook(Color.WHITE)); // h7 -- seals off rank 7
mate.set(7, 3, new Rook(Color.WHITE)); // d8 -- checks along rank 8
mate.set(0, 7, new King(Color.WHITE)); // h1, elsewhere on the board
assertTrue(mate.isKingAttacked(Color.BLACK),
"Black king in check from the rook on d8 along rank 8");
assertTrue(mate.legalMoves(Color.BLACK).isEmpty(),
"Black has zero legal moves -- a7/b7/b8 are all covered by the h7 or d8 rook");
assertTrue(mate.isCheckmate(Color.BLACK),
"isCheckmate correctly reports checkmate for the ladder mate");
assertTrue(!mate.isStalemate(Color.BLACK),
"not a stalemate -- the king IS in check (stalemate requires NOT in check)");
// ---- Scenario 3: stalemate is the OTHER empty-legal-moves case. ----
// Black King a8, boxed in but NOT currently in check: Rook d7 covers rank 7
// (so a7 and b7 are both attacked), Rook b6 covers file b (so b8 is attacked
// too), and neither rook's line passes through a8 itself.
Board stale = new Board();
stale.set(7, 0, new King(Color.BLACK)); // a8
stale.set(5, 1, new Rook(Color.WHITE)); // b6 -- covers file b (b7, b8) but NOT rank 8 or a7
stale.set(6, 3, new Rook(Color.WHITE)); // d7 -- covers rank 7 (a7, b7, c7) but not a8/b8
stale.set(0, 7, new King(Color.WHITE)); // h1
assertTrue(!stale.isKingAttacked(Color.BLACK),
"Black king NOT in check in the stalemate position");
assertTrue(stale.legalMoves(Color.BLACK).isEmpty(),
"Black has zero legal moves -- a7 and b7 covered by d7, b8 covered by b6, a8 has no other neighbor");
assertTrue(stale.isStalemate(Color.BLACK), "isStalemate correctly reports stalemate, not checkmate");
assertTrue(!stale.isCheckmate(Color.BLACK), "and confirms it is NOT checkmate -- no legal moves but no check either");
System.out.println("ALL JAVA TESTS PASSED");
}
}
Reference implementation — Go
Go has no class inheritance, so the "piece hierarchy" becomes a PieceKind enum dispatched inside Piece.PseudoLegalMoves — the idiomatic Go shape for "one type, several behaviors" when you don't want a full interface-per-piece-type. The board-level logic (IsSquareAttacked, LegalMoves, IsCheckmate/IsStalemate) is identical in structure to the Java version.
package main
import "fmt"
type Color int
const (
White Color = iota
Black
)
func opponent(c Color) Color {
if c == White {
return Black
}
return White
}
type Move struct{ fromRow, fromCol, toRow, toCol int }
func (m Move) sameAs(fr, fc, tr, tc int) bool {
return m.fromRow == fr && m.fromCol == fc && m.toRow == tr && m.toCol == tc
}
// PieceKind + a per-kind pseudo-legal move method stand in for the polymorphic
// Piece hierarchy (Go has no classes/inheritance -- an interface + concrete
// types is the idiomatic equivalent of "each piece generates its own moves").
type PieceKind int
const (
KingKind PieceKind = iota
RookKind
KnightKind
)
type Piece struct {
kind PieceKind
color Color
}
// Piece.PseudoLegalMoves obeys ONLY this piece's movement rule and the board's
// occupancy. It has no idea whether the move leaves the mover's own king in
// check -- that filter is a separate, board-level concern (Board.LegalMoves).
func (p Piece) PseudoLegalMoves(b *Board, row, col int) []Move {
switch p.kind {
case KingKind:
return kingMoves(b, row, col, p.color)
case RookKind:
return rookMoves(b, row, col, p.color)
case KnightKind:
return knightMoves(b, row, col, p.color)
}
return nil
}
func kingMoves(b *Board, row, col int, color Color) []Move {
var moves []Move
for dr := -1; dr <= 1; dr++ {
for dc := -1; dc <= 1; dc++ {
if dr == 0 && dc == 0 {
continue
}
r, c := row+dr, col+dc
if b.inBounds(r, c) && b.canLandOn(r, c, color) {
moves = append(moves, Move{row, col, r, c})
}
}
}
return moves // castling omitted here -- see Optimise / Defend
}
func rookMoves(b *Board, row, col int, color Color) []Move {
var moves []Move
dirs := [][2]int{{1, 0}, {-1, 0}, {0, 1}, {0, -1}}
for _, d := range dirs {
r, c := row+d[0], col+d[1]
for b.inBounds(r, c) {
occupant, occupied := b.get(r, c)
if !occupied {
moves = append(moves, Move{row, col, r, c})
} else {
if occupant.color != color {
moves = append(moves, Move{row, col, r, c})
}
break // a piece (either color) stops the slide
}
r += d[0]
c += d[1]
}
}
return moves
}
func knightMoves(b *Board, row, col int, color Color) []Move {
var moves []Move
offsets := [][2]int{{1, 2}, {2, 1}, {-1, 2}, {-2, 1}, {1, -2}, {2, -1}, {-1, -2}, {-2, -1}}
for _, o := range offsets {
r, c := row+o[0], col+o[1]
if b.inBounds(r, c) && b.canLandOn(r, c, color) {
moves = append(moves, Move{row, col, r, c})
}
}
return moves
}
// Board.grid[row][col]; row 0 = rank 1, col 0 = file a. A nil *Piece means empty.
type Board struct {
grid [8][8]*Piece
}
func NewBoard() *Board { return &Board{} }
func (b *Board) inBounds(r, c int) bool { return r >= 0 && r < 8 && c >= 0 && c < 8 }
func (b *Board) get(r, c int) (Piece, bool) {
p := b.grid[r][c]
if p == nil {
return Piece{}, false
}
return *p, true
}
func (b *Board) set(r, c int, p Piece) { pp := p; b.grid[r][c] = &pp }
func (b *Board) canLandOn(r, c int, mover Color) bool {
occupant, occupied := b.get(r, c)
return !occupied || occupant.color != mover
}
// Pseudo-legal moves for a whole side: dispatch to each piece's own rule.
func (b *Board) PseudoLegalMoves(side Color) []Move {
var moves []Move
for r := 0; r < 8; r++ {
for c := 0; c < 8; c++ {
p := b.grid[r][c]
if p != nil && p.color == side {
moves = append(moves, p.PseudoLegalMoves(b, r, c)...)
}
}
}
return moves
}
// A square is attacked by bySide if any of that side's pseudo-legal moves
// targets it. Reusing PseudoLegalMoves here (instead of a second hand-rolled
// "attack map") is what keeps this correct -- one rule per piece, used for
// both move generation and check detection.
func (b *Board) IsSquareAttacked(row, col int, bySide Color) bool {
for r := 0; r < 8; r++ {
for c := 0; c < 8; c++ {
p := b.grid[r][c]
if p == nil || p.color != bySide {
continue
}
for _, m := range p.PseudoLegalMoves(b, r, c) {
if m.toRow == row && m.toCol == col {
return true
}
}
}
}
return false
}
func (b *Board) findKing(side Color) (int, int) {
for r := 0; r < 8; r++ {
for c := 0; c < 8; c++ {
p := b.grid[r][c]
if p != nil && p.kind == KingKind && p.color == side {
return r, c
}
}
}
panic(fmt.Sprintf("no king on board for %v", side))
}
func (b *Board) IsKingAttacked(side Color) bool {
r, c := b.findKing(side)
return b.IsSquareAttacked(r, c, opponent(side))
}
// make -> check -> unmake: the whole mechanism that turns a pseudo-legal move
// into a LEGAL one. Play it for real, ask "is my own king now attacked?",
// then undo it -- the board is never left in the tried state.
func (b *Board) makeMove(m Move) *Piece {
captured := b.grid[m.toRow][m.toCol]
b.grid[m.toRow][m.toCol] = b.grid[m.fromRow][m.fromCol]
b.grid[m.fromRow][m.fromCol] = nil
return captured
}
func (b *Board) unmakeMove(m Move, captured *Piece) {
b.grid[m.fromRow][m.fromCol] = b.grid[m.toRow][m.toCol]
b.grid[m.toRow][m.toCol] = captured
}
func (b *Board) LegalMoves(side Color) []Move {
var legal []Move
for _, m := range b.PseudoLegalMoves(side) {
captured := b.makeMove(m)
if !b.IsKingAttacked(side) {
legal = append(legal, m)
}
b.unmakeMove(m, captured)
}
return legal
}
func (b *Board) IsCheckmate(side Color) bool {
return b.IsKingAttacked(side) && len(b.LegalMoves(side)) == 0
}
func (b *Board) IsStalemate(side Color) bool {
return !b.IsKingAttacked(side) && len(b.LegalMoves(side)) == 0
}
// THE BUG, kept on the page deliberately: a "naive" generator that treats
// pseudo-legal moves as if they were already legal -- no make/check/unmake
// filter at all. Never call this from real game logic; it exists only to
// reproduce the break-it scenario below.
func (b *Board) NaiveLegalMoves(side Color) []Move {
return b.PseudoLegalMoves(side)
}
func containsMove(moves []Move, fr, fc, tr, tc int) bool {
for _, m := range moves {
if m.sameAs(fr, fc, tr, tc) {
return true
}
}
return false
}
func check(cond bool, label string) {
if !cond {
panic("FAILED: " + label)
}
fmt.Println("PASS:", label)
}
func main() {
// ---- Scenario 1: the pin. White King e1, White Knight e4, Black Rook e8. ----
// Board coords: row 0 = rank 1, col 0 = file a. e1=(0,4) e4=(3,4) e8=(7,4).
b := NewBoard()
b.set(0, 4, Piece{KingKind, White})
b.set(3, 4, Piece{KnightKind, White})
b.set(7, 4, Piece{RookKind, Black})
b.set(7, 0, Piece{KingKind, Black}) // a8 -- present, irrelevant to the pin
check(!b.IsKingAttacked(White),
"White king not currently in check -- the knight still blocks the e-file")
// The naive generator (the bug): does it think Ne4-f6 is a fine move?
naive := b.NaiveLegalMoves(White)
check(containsMove(naive, 3, 4, 5, 5),
"BUG: naive generator OKs Ne4-f6 -- it never looks at the king at all")
// Prove it's actually illegal: play it for real and re-check the king.
ne4f6 := Move{3, 4, 5, 5}
captured := b.makeMove(ne4f6)
check(b.IsKingAttacked(White),
"reproduced: moving the pinned knight exposes White's own king to the rook on e8")
b.unmakeMove(ne4f6, captured)
// The real (filtered) legal-move list must reject exactly this move.
legal := b.LegalMoves(White)
check(!containsMove(legal, 3, 4, 5, 5),
"fix confirmed: LegalMoves() filters out Ne4-f6 -- the pin is enforced")
check(len(naive) == 13 && len(legal) == 5,
"pin removes exactly the knight's 8 pseudo-legal hops (13 pseudo-legal -> 5 legal)")
// ---- Scenario 2: checkmate detection -- the two-rook "ladder" mate. ----
mate := NewBoard()
mate.set(7, 0, Piece{KingKind, Black}) // a8
mate.set(6, 7, Piece{RookKind, White}) // h7 -- seals off rank 7
mate.set(7, 3, Piece{RookKind, White}) // d8 -- checks along rank 8
mate.set(0, 7, Piece{KingKind, White}) // h1, elsewhere on the board
check(mate.IsKingAttacked(Black),
"Black king in check from the rook on d8 along rank 8")
check(len(mate.LegalMoves(Black)) == 0,
"Black has zero legal moves -- a7/b7/b8 are all covered by the h7 or d8 rook")
check(mate.IsCheckmate(Black),
"IsCheckmate correctly reports checkmate for the ladder mate")
check(!mate.IsStalemate(Black),
"not a stalemate -- the king IS in check (stalemate requires NOT in check)")
// ---- Scenario 3: stalemate is the OTHER empty-legal-moves case. ----
// Black King a8, boxed in but NOT currently in check: Rook d7 covers rank 7
// (so a7 and b7 are both attacked), Rook b6 covers file b (so b8 is attacked
// too), and neither rook's line passes through a8 itself.
stale := NewBoard()
stale.set(7, 0, Piece{KingKind, Black}) // a8
stale.set(5, 1, Piece{RookKind, White}) // b6
stale.set(6, 3, Piece{RookKind, White}) // d7
stale.set(0, 7, Piece{KingKind, White}) // h1
check(!stale.IsKingAttacked(Black),
"Black king NOT in check in the stalemate position")
check(len(stale.LegalMoves(Black)) == 0,
"Black has zero legal moves -- a7 and b7 covered by d7, b8 covered by b6, a8 has no other neighbor")
check(stale.IsStalemate(Black), "IsStalemate correctly reports stalemate, not checkmate")
check(!stale.IsCheckmate(Black), "and confirms it is NOT checkmate -- no legal moves but no check either")
fmt.Println("ALL GO TESTS PASSED")
}
Both files above are copy-paste compilable as-is: javac Chess.java && java Chess compiles clean and runs a self-contained main with 13 assertions; go vet chess.go && go build chess.go && ./chess vets clean, builds clean, and runs the same 13 assertions — both print PASS for every one and end in ALL JAVA/GO TESTS PASSED.
5. Break it — the failure lesson
The bug that actually ships: an engineer builds the piece hierarchy, gets every individual piece's movement rule correct, tests each one in isolation, and ships — without ever adding the make→check→unmake filter, because "the pieces all move correctly" feels like "the engine is correct." naiveLegalMoves / NaiveLegalMoves on this page reproduces exactly that: it returns pseudoLegalMoves verbatim, with zero awareness of check.
Reproduce it (the exact position from §1): White King e1, White Knight e4, White to move, Black Rook e8 with the file otherwise clear. First confirm the setup is sane — isKingAttacked(WHITE) is false: the knight is still blocking, so the king is not currently in check. Now ask the naive generator whether Ne4-f6 is fine: it says yes — the assertion "BUG: naive generator OKs Ne4-f6" passes, because the knight's own rule has no idea a king exists. Play that exact move for real with makeMove, then re-check: isKingAttacked(WHITE) is now true — the file is open and the rook attacks e1. That's the illegal move, reproduced and proven, not hypothetical. Finally, run the real legalMoves(WHITE) and confirm it does NOT contain Ne4-f6: the pin removes exactly the knight's 8 candidate hops, so pseudo-legal count 13 becomes legal count 5 — every other white move (the king's own 5 steps) survives untouched, because only the knight was the sole blocker on that specific line.
The lesson generalizes past this one pin: any piece that is the sole thing standing between its own king and an enemy slider (rook/bishop/queen) is pinned, and no per-piece movement rule can ever detect that on its own — a piece has no visibility into what's behind it on the board. The only fix that covers every such case (pins, discovered checks off a totally unrelated piece, or the king itself stepping into an attacked square) is the same one mechanism: simulate the move, ask the board-level "is my king attacked," undo it. Skipping that step is invisible in casual play (most moves aren't pinned) and is exactly the kind of bug that survives a demo and gets caught by an opponent — or an interviewer — on move six.
6. Optimise — with trade-offs
Every design choice below buys something at a named cost against a named alternative. State the cost, not just the win.
| Piece representation | Move-gen cost | Memory | Buys | Costs | Use when |
|---|---|---|---|---|---|
OO piece hierarchy (this kata: object array + polymorphic pseudoLegalMoves) | Object dispatch + list allocation per piece per call; fine for interactive play | 64 squares of piece references — trivial | Readable, unit-testable per piece, natural fit for an LLD interview and for adding game variants (a new piece = a new class) | Slow relative to bitboards: pointer-chasing, per-call allocations, no SIMD-friendly layout | LLD interviews, teaching, casual/turn-based play, correctness-first prototypes |
| Bitboards (Stockfish, Leela, real engines) | Bit ops (AND/OR/popcount) on 64-bit ints; magic bitboards precompute sliding-piece attacks — orders of magnitude faster | ~12 uint64 (one per piece type per color) + large precomputed attack tables | Millions of positions/sec — the throughput a deep alpha-beta search actually needs | Much harder to write, debug, and explain; magic-bitboard generation is its own multi-day project; a poor fit for "walk me through your design" interviews | Production engines doing deep tree search, where move-gen is the hot loop called millions of times per move decided |
| Legality strategy | When correctness is enforced | Cost | Buys | Costs | Use when |
|---|---|---|---|---|---|
| Pseudo-legal + filter (this kata: make → isKingAttacked → unmake, per candidate move) | After generation, once per candidate move | O(moves) simulations, each re-deriving attacked squares from scratch | Simple, obviously correct — ONE filter catches every interaction (pins, discovered checks, walking into check) with no special-casing | Redundant work: re-computes "is X attacked" fresh for every candidate, even though most of the board didn't change | This kata, most hobby/teaching engines, anywhere correctness-per-line-of-code matters more than raw throughput |
| Fully-legal generation (precompute pins + checkers first, generate only safe moves directly) | Once per position, up front — before any move is generated | A pin/checker-detection pass, then generation with zero redundant simulation | Much faster in deep search — no make/unmake per candidate move at all | Meaningfully more complex: must special-case single-check (block/capture/king-move only) vs double-check (king must move), en-passant's rare discovered-check case, etc. | Search-heavy engines where move generation is called millions of times per search — the complexity buys real throughput |
| Movement-rule mechanism | Buys | Costs | Use when |
|---|---|---|---|
| Polymorphism (Piece subclass per type, this kata) | Rule and identity are one object; simplest mental model; instanceof King works for findKing | A piece can't swap its movement rule at runtime without becoming a different type | Fixed rule set — standard chess, teaching, interview settings |
Strategy pattern (inject a MovementRule object per piece instance) | Swap a piece's movement rule at runtime without losing its identity — e.g. pawn promotion keeps the same piece "instance"/history but swaps in the queen's rule; also natural for chess variants (Chess960/homebrew rules) where a piece's behavior is configured, not hard-typed | Extra indirection: every piece carries a rule reference plus a registry of rule objects to wire up | Promotion-heavy or variant-rich designs, or when an interviewer explicitly asks "what if a piece's move rule could change at runtime?" |
7. Defend under drilling
- "How do you detect checkmate?" Reuse, don't special-case:
isCheckmate(side) = isKingAttacked(side) && legalMoves(side).isEmpty(). The king is under attack right now, AND after trying every single pseudo-legal move for that side and filtering through the same make→check→unmake pass used for every other move, not one of them escapes the check. No separate "checkmate scanner" exists — it falls out of the legal-move filter you already built. Stalemate is the sibling case:!isKingAttacked(side) && legalMoves(side).isEmpty()— no legal moves, but also not in check, which is a draw, not a loss. Getting that distinction right (and this kata's tests prove both a checkmate position AND a stalemate position resolve correctly) is exactly the kind of edge case interviewers probe. - "Why pseudo-legal-then-filter instead of generating only legal moves directly?" Because it decomposes a hard, error-prone problem (does this specific move interact with pins/discovered checks/castling-through-check/etc.) into two simple, separately-correct pieces: "how does this piece move" (easy, unit-testable in isolation) and "simulate-then-ask-is-my-king-safe" (one mechanism, reused for every piece and every kind of exposure). The cost is real — you re-derive attacked squares per candidate move instead of computing pins once up front — and I'd name that cost and the faster alternative (§6) rather than pretend it's free.
- "How would a real engine (bitboards) differ?" Instead of 64
Pieceobjects, each piece type/color is a 64-bit integer where bit i means "occupied." Move generation becomes bitwise AND/OR/shift over those integers, and sliding-piece attacks (rook/bishop) are precomputed via "magic bitboards" — a hash trick that maps (square, blockers) to a precomputed attack set in O(1). This is 100-1000x faster than object dispatch, at the cost of a design that's far harder to read, write, and reason about — exactly why it's the wrong choice to lead with in an LLD interview, but the right one to name as the production answer. - "What about castling, en passant, and promotion?" All three plug into the SAME architecture rather than requiring a redesign — that's the payoff of getting §3's split right early: castling needs extra state (has this king/rook moved yet?) and an extra legality rule — the king may not castle out of, through, or into check, which is just three calls to the
isSquareAttackedyou already have, on the squares the king crosses. En passant needs one bit of history (did the enemy pawn just double-step past this square?) and, unusually, its capture removes a piece that is NOT on the move's destination square — the one real special case inmakeMove/unmakeMove. Promotion replaces the piece at the destination with a new piece object post-move (or swaps itsStrategy, per §6) — it doesn't touch move generation or the legality filter at all. - "What breaks at 100×?" The Board/Piece/legalMoves core doesn't break structurally — it's the same logic whether it runs once or a million times. What breaks is the deployment shape: turn this into a multiplayer chess server hosting thousands of concurrent games, and (a) you never trust a client-submitted move — the server replays it against this exact
legalMoves(side)list before accepting it, making this code the authoritative anti-cheat validator, not just a UI helper; (b) reconnects/spectators rebuild state by replaying the move log (event sourcing) rather than shipping full board snapshots, because the move list IS the source of truth; (c) games shard independently across workers by game ID — there's no cross-game shared state to contend over, unlike a parking-lot allocator's shared free-list, so this scales embarrassingly parallel rather than needing a distributed lock.
8. You can now defend
- You can explain, from first principles, why "each piece can move correctly" is a different and weaker claim than "the engine only offers legal moves" — and you can name the exact mechanism (make → isKingAttacked → unmake) that closes the gap for every kind of exposure: pins, discovered checks, and walking into check.
- You've reproduced a real, concrete illegal-move bug (the pinned knight on e4) end-to-end — proved the naive generator allows it, proved the real position becomes checkmate-adjacent if played, and proved the filtered
legalMovescorrectly rejects it — not a hypothetical, a passing (and failing-before-the-fix) test. - You can derive checkmate and stalemate as the two branches of "zero legal moves," distinguished only by whether the king is currently attacked — and explain why this reuses the legal-move filter instead of needing separate detection code.
- You can defend the OO-hierarchy-vs-bitboards and pseudo-legal-vs-fully-legal trade-offs with real costs on both sides, and you can walk castling/en passant/promotion through the SAME architecture on demand instead of treating them as unscoped surprises.
Re-authored/Deepened for this guide. Model answer: the Design Chess LLD walkthrough.
🤖 Don't fully get this? Learn it with Claude
Stuck on Design Chess? Open Claude, copy a block below, and it'll teach you this exact concept — visually and interactively.
Build the mental picture, not memorization.
I just read a lesson on **Design Chess** (Hands-On Builds) and want to truly understand it. Explain Design Chess from first principles using ONE vivid real-world analogy and a visual mental model — draw it as ASCII art or a clear step-by-step diagram — with a concrete example using real numbers. Then ask me one question to check I got the mental picture, and wait for my reply. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Socratic — adapts to where you're stuck.
Teach me **Design Chess** interactively. Ask me ONE guiding question at a time, wait for my answer, and adapt to my confusion — build the idea with me step by step instead of explaining it all at once. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Active recall exposes what you missed.
Quiz me on **Design Chess** with 5 questions, easy to tricky, ONE at a time. Tell me if each answer is right; at the end, explain clearly what I got wrong and why. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Intuition + hook + flashcards for long-term memory.
Help me remember **Design Chess** for the long term: give the one-sentence intuition, a memorable hook/mnemonic, a tiny worked example, and 3 active-recall flashcards (Q -> A). If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.