Chess game LLD deep dive

Design a chess game

Full interview walkthrough for designing a chess game: clarifying dialogue, Game/Board/Piece polymorphism, pseudo-legal vs legal moves, check/mate, verification, and extensibility (castling, undo, AI).

What this prompt is really asking

Chess is a two-player perfect-information game on an 8×8 board. Each side has six piece types with different movement geometry. Players alternate turns; a move that leaves your own king in check is illegal; checkmate ends the game; a full set of draw rules exists beyond the interview’s usual v1 scope.
You’ll hear: “Design the OO model for a chess game.” That sentence is a door. Interviewers are testing whether you split piece geometry from game legality, not whether you can recite FIDE castling footnotes from memory.
01Clarify

Scope · special moves

02Model

Game · Board · Piece

03Code

pseudo-legal → legal

04Extend

castle · undo · bot

Chess move pipeline
Propose move → Piece geometry → Game filters check → update status.

Clarifying questions → locked requirements

Structure clarifying questions around core actions, errors, boundaries, and whether to plan for extensions:
  • You: Two human players, standard rules, backend only? They: Yes — no UI, no network matchmaking.
  • You: Must we support castling, en passant, and promotion in v1? They: Promotion yes (queen is fine); castling / en passant can be extensions.
  • You: Detect check, checkmate, stalemate? They: Yes for the three terminal/status cases.
  • You: Reject illegal moves without corrupting state? They: Yes — wrong turn, empty square, blocked path, leaves king in check.

Entities: Game, Board, Piece, Move, Player

Chess entities
Game orchestrates; Board is the grid; Piece hierarchy owns geometry.
Pull nouns that own rules. Pieces own how they move through empty space. Game owns turn order, applying moves, and the legality filter that asks “does this leave my king attacked?” Board is mostly a spatial store with helpers.
  • Game — Board, two Players, current_turn, status, move history. Entry point: make_move(from, to).
  • Board — 8×8 of nullable Pieces. get/set/remove, path-clear helpers for sliding pieces, find_king, is_square_attacked.
  • Piece (abstract) — color + pseudo_legal_moves(board, pos) or can_move_to(...). Subclasses: King, Queen, Rook, Bishop, Knight, Pawn.
  • Move — from, to, optional captured piece, optional promotion type. Useful for history / undo / notation later.
  • Player — name + color. Pure data (no AI inside Player).

Class design from the requirements

Derive state top-down. Game needs players, turn, board, status, history. Board needs the grid. Piece needs color and type-specific geometry.
from abc import ABC, abstractmethod
from enum import Enum, auto
from typing import Optional

class Color(Enum):
    WHITE = auto()
    BLACK = auto()

class GameStatus(Enum):
    ACTIVE = auto()
    CHECK = auto()
    CHECKMATE = auto()
    STALEMATE = auto()

class Position:
    def __init__(self, row: int, col: int):  # 0..7
        self.row, self.col = row, col

class Move:
    def __init__(self, frm: Position, to: Position,
                 captured: Optional["Piece"] = None,
                 promotion: Optional[type] = None):
        self.frm, self.to = frm, to
        self.captured, self.promotion = captured, promotion

class Piece(ABC):
    def __init__(self, color: Color):
        self.color = color

    @abstractmethod
    def pseudo_legal_moves(self, board: "Board", pos: Position) -> list[Position]:
        ...

class Board:
    SIZE = 8
    def __init__(self):
        self.grid: list[list[Optional[Piece]]] = [
            [None] * self.SIZE for _ in range(self.SIZE)
        ]
        # setup_standard_position()

    def get(self, p: Position) -> Optional[Piece]: ...
    def place(self, p: Position, piece: Optional[Piece]) -> None: ...
    def is_path_clear(self, frm: Position, to: Position) -> bool: ...
    def find_king(self, color: Color) -> Position: ...
    def is_attacked(self, pos: Position, by: Color) -> bool: ...

class Player:
    def __init__(self, name: str, color: Color):
        self.name, self.color = name, color

class Game:
    def __init__(self, white: Player, black: Player):
        self.board = Board()
        self.white, self.black = white, black
        self.current = Color.WHITE
        self.status = GameStatus.ACTIVE
        self.history: list[Move] = []

    def make_move(self, frm: Position, to: Position,
                  promotion: Optional[type] = None) -> bool: ...
    def legal_moves(self, frm: Position) -> list[Position]: ...
import java.util.*;

enum Color { WHITE, BLACK }

enum GameStatus { ACTIVE, CHECK, CHECKMATE, STALEMATE }

class Position {
    int row, col; // 0..7
    Position(int row, int col) { this.row = row; this.col = col; }

    @Override public boolean equals(Object o) {
        if (!(o instanceof Position)) return false;
        Position p = (Position) o;
        return row == p.row && col == p.col;
    }
    @Override public int hashCode() { return Objects.hash(row, col); }
}

class Move {
    Position frm, to;
    Piece captured; // nullable
    Class<? extends Piece> promotion; // nullable

    Move(Position frm, Position to, Piece captured, Class<? extends Piece> promotion) {
        this.frm = frm; this.to = to;
        this.captured = captured; this.promotion = promotion;
    }
}

abstract class Piece {
    Color color;
    Piece(Color color) { this.color = color; }
    abstract List<Position> pseudoLegalMoves(Board board, Position pos);
}

class Board {
    static final int SIZE = 8;
    Piece[][] grid = new Piece[SIZE][SIZE]; // null = empty
    // setupStandardPosition()

    Piece get(Position p) { /* ... */ return null; }
    void place(Position p, Piece piece) { /* ... */ }
    boolean isPathClear(Position frm, Position to) { /* ... */ return false; }
    Position findKing(Color color) { /* ... */ return null; }
    boolean isAttacked(Position pos, Color by) { /* ... */ return false; }
}

class Player {
    String name;
    Color color;
    Player(String name, Color color) { this.name = name; this.color = color; }
}

class Game {
    Board board = new Board();
    Player white, black;
    Color current = Color.WHITE;
    GameStatus status = GameStatus.ACTIVE;
    List<Move> history = new ArrayList<>();

    Game(Player white, Player black) {
        this.white = white; this.black = black;
    }

    boolean makeMove(Position frm, Position to, Class<? extends Piece> promotion) { /* ... */ return false; }
    List<Position> legalMoves(Position frm) { /* ... */ return List.of(); }
}

Implementation highlights

Interviewers usually want three things in real code: a sample piece’s pseudo-legal generation, the legality filter, and make_move.
# --- Knight: geometry only (no path; jumps) ---
class Knight(Piece):
    DELTAS = ((1, 2), (1, -2), (-1, 2), (-1, -2),
              (2, 1), (2, -1), (-2, 1), (-2, -1))

    def pseudo_legal_moves(self, board, pos):
        out = []
        for dr, dc in self.DELTAS:
            r, c = pos.row + dr, pos.col + dc
            if not board.in_bounds(r, c):
                continue
            dest = Position(r, c)
            occ = board.get(dest)
            if occ is None or occ.color != self.color:
                out.append(dest)  # empty or capture
        return out


# --- Game: pseudo-legal → legal (king safety) ---
def legal_moves(self, frm: Position) -> list[Position]:
    piece = self.board.get(frm)
    if piece is None or piece.color != self.current:
        return []
    legal = []
    for to in piece.pseudo_legal_moves(self.board, frm):
        if self._is_safe_after(frm, to):
            legal.append(to)
    return legal

def _is_safe_after(self, frm: Position, to: Position) -> bool:
    piece = self.board.get(frm)
    captured = self.board.get(to)
    self.board.place(to, piece)
    self.board.place(frm, None)
    king_pos = self.board.find_king(self.current)
    safe = not self.board.is_attacked(king_pos, _opponent(self.current))
    self.board.place(frm, piece)      # revert
    self.board.place(to, captured)
    return safe

def make_move(self, frm: Position, to: Position,
              promotion: Optional[type] = None) -> bool:
    if self.status in (GameStatus.CHECKMATE, GameStatus.STALEMATE):
        return False
    if to not in self.legal_moves(frm):
        return False

    piece = self.board.get(frm)
    captured = self.board.get(to)
    self.board.place(to, piece)
    self.board.place(frm, None)
    if isinstance(piece, Pawn) and (to.row == 0 or to.row == 7):
        self.board.place(to, (promotion or Queen)(piece.color))

    self.history.append(Move(frm, to, captured, promotion))
    self.current = _opponent(self.current)
    self.status = self._compute_status()
    return True

def _compute_status(self) -> GameStatus:
    # side to move = self.current
    any_legal = any(
        self.legal_moves(pos)
        for pos in self.board.positions_of(self.current)
    )
    in_check = self.board.is_attacked(
        self.board.find_king(self.current), _opponent(self.current)
    )
    if not any_legal:
        return GameStatus.CHECKMATE if in_check else GameStatus.STALEMATE
    return GameStatus.CHECK if in_check else GameStatus.ACTIVE
// --- Knight: geometry only (no path; jumps) ---
class Knight extends Piece {
    static final int[][] DELTAS = {
        {1, 2}, {1, -2}, {-1, 2}, {-1, -2},
        {2, 1}, {2, -1}, {-2, 1}, {-2, -1}
    };

    Knight(Color color) { super(color); }

    List<Position> pseudoLegalMoves(Board board, Position pos) {
        List<Position> out = new ArrayList<>();
        for (int[] d : DELTAS) {
            int r = pos.row + d[0], c = pos.col + d[1];
            if (!board.inBounds(r, c)) continue;
            Position dest = new Position(r, c);
            Piece occ = board.get(dest);
            if (occ == null || occ.color != this.color) {
                out.add(dest); // empty or capture
            }
        }
        return out;
    }
}

// --- Game: pseudo-legal → legal (king safety) ---
List<Position> legalMoves(Position frm) {
    Piece piece = board.get(frm);
    if (piece == null || piece.color != current) return List.of();
    List<Position> legal = new ArrayList<>();
    for (Position to : piece.pseudoLegalMoves(board, frm)) {
        if (isSafeAfter(frm, to)) legal.add(to);
    }
    return legal;
}

boolean isSafeAfter(Position frm, Position to) {
    Piece piece = board.get(frm);
    Piece captured = board.get(to);
    board.place(to, piece);
    board.place(frm, null);
    Position kingPos = board.findKing(current);
    boolean safe = !board.isAttacked(kingPos, opponent(current));
    board.place(frm, piece);      // revert
    board.place(to, captured);
    return safe;
}

boolean makeMove(Position frm, Position to, Class<? extends Piece> promotion) {
    if (status == GameStatus.CHECKMATE || status == GameStatus.STALEMATE) return false;
    if (!legalMoves(frm).contains(to)) return false;

    Piece piece = board.get(frm);
    Piece captured = board.get(to);
    board.place(to, piece);
    board.place(frm, null);
    if (piece instanceof Pawn && (to.row == 0 || to.row == 7)) {
        try {
            Class<? extends Piece> cls = promotion != null ? promotion : Queen.class;
            board.place(to, cls.getConstructor(Color.class).newInstance(piece.color));
        } catch (Exception e) {
            board.place(to, new Queen(piece.color));
        }
    }

    history.add(new Move(frm, to, captured, promotion));
    current = opponent(current);
    status = computeStatus();
    return true;
}

GameStatus computeStatus() {
    // side to move = current
    boolean anyLegal = false;
    for (Position pos : board.positionsOf(current)) {
        if (!legalMoves(pos).isEmpty()) { anyLegal = true; break; }
    }
    boolean inCheck = board.isAttacked(board.findKing(current), opponent(current));
    if (!anyLegal) return inCheck ? GameStatus.CHECKMATE : GameStatus.STALEMATE;
    return inCheck ? GameStatus.CHECK : GameStatus.ACTIVE;
}

Verification trace

Narrate a short scenario out loud (scholar’s mate sketch is fine):
  1. Initial: White to move, status=ACTIVE.
  2. White e2→e4: pawn pseudo-legal includes e4; simulate → king safe → apply; turn=Black.
  3. Black tries e7→e5 similarly.
  4. Illegal: White e4→e6 (pawn can’t jump two after first move / wrong geometry) → rejected.
  5. Self-check: after a sequence that pins a piece, that piece’s pseudo-legal capture along the pin is filtered out by _is_safe_after.
  6. Mate: when side to move has zero legal moves and king is attacked → CHECKMATE; further make_move returns False.
You don’t need a full PGN. Narrate geometry vs filter vs status update — enough to prove you know where check lives.

Extensibility follow-ups

  • Castling — store castling rights on Game (or per-king/rook moved flags). King pseudo-legal includes ±2 files when rights + path clear + squares not attacked; make_move also moves the rook.
  • En passant — remember last double-pawn push as an ep target square; pawn pseudo-legal may include that capture; clear the captured pawn off the adjacent file on apply.
  • Undo / history — Move as Command: store captured + rights + ep target; undo pops and restores. Same stack feeds PGN export later.
  • Bot opponent — keep Player as data; BotEngine.choose_move(game) -> Move searches legal moves (minimax / alpha-beta). Don’t shove AI into the Piece hierarchy.
  • Chess960 / fairy pieces — new Piece subclass + setup; Game’s legality filter stays untouched if you respected the split.

Common interview pitfalls

These mistakes show up constantly on this prompt. Name the trap, then show the fix in your design — don’t wait for the interviewer to catch you.

  • Implementing full FIDE rules in 35 minutes — never finishes.
  • Missing Piece polymorphism — giant switch on type in Game.
  • Confusing pseudo-legal moves with legal moves (king left in check).
  • No concept of side to move / castling rights called out as deferred.
  • Mutating board during move generation without undo/copy discipline.
  • UI-driven piece classes that know about sprites.

Interview script (say this)

Read this once out loud before a mock. It’s the spine of a strong answer — not a script to recite robotically.

  1. Scope hard: 8×8, six piece types, turns, check detection; defer en passant/castling if needed but name them.
  2. Piece ABC with pseudo_legal_moves(board, pos); King/Pawn special cases.
  3. Game.try_move: filter legal = pseudo-legal where own king not in check after apply.
  4. Trace: moving pinned piece illegally rejected; checkmate detection sketch.
  5. Staff: AI engine as consumer of legal move generator.

Extra verification traces

Walk these three traces on the board. If you can narrate them cleanly, your implementation section usually follows.

def legal_moves(self, color):
    moves = []
    for pos, piece in self.board.pieces(color):
        for dest in piece.pseudo_legal(self.board, pos):
            snapshot = self.board.copy()
            snapshot.apply(pos, dest)
            if not snapshot.in_check(color):
                moves.append((pos, dest))
    return moves
List<MovePair> legalMoves(Color color) {
    List<MovePair> moves = new ArrayList<>();
    for (Map.Entry<Position, Piece> e : board.pieces(color)) {
        Position pos = e.getKey();
        Piece piece = e.getValue();
        for (Position dest : piece.pseudoLegal(board, pos)) {
            Board snapshot = board.copy();
            snapshot.apply(pos, dest);
            if (!snapshot.inCheck(color)) {
                moves.add(new MovePair(pos, dest));
            }
        }
    }
    return moves;
}

Staff-level follow-ups

At staff+, they twist the prompt. Answer in one sentence that names the seam — don’t redesign the whole board.

  • Full rules? — Incremental rule modules (CastlingRights, EnPassantSquare) on BoardState.
  • Engine? — Search over legal_moves; evaluation separate.
  • Variants Chess960? — SetupFactory for initial positions; Piece movement reused.
  • Clocks? — Clock service observing move events — Game emits MoveMade.

Complete solution notes (Chess)

Chess is usually a single-threaded rules engine in LLD. If the prompt adds a multiplayer HTTP API, wrap each game id with a lock so two moves can’t interleave on the same board.
from threading import Lock

class GameService:
    def __init__(self):
        self.games: dict[str, object] = {}
        self._locks: dict[str, Lock] = {}
        self._map_lock = Lock()

    def _lock_for(self, game_id: str) -> Lock:
        with self._map_lock:
            return self._locks.setdefault(game_id, Lock())

    def make_move(self, game_id: str, *args):
        with self._lock_for(game_id):
            return self.games[game_id].make_move(*args)

# Different game ids → different locks → parallel matches.
import java.util.*;
import java.util.concurrent.locks.ReentrantLock;

class GameService {
    Map<String, Object> games = new HashMap<>();
    Map<String, ReentrantLock> locks = new HashMap<>();
    private final ReentrantLock mapLock = new ReentrantLock();

    ReentrantLock lockFor(String gameId) {
        mapLock.lock();
        try {
            return locks.computeIfAbsent(gameId, k -> new ReentrantLock());
        } finally {
            mapLock.unlock();
        }
    }

    Object makeMove(String gameId, Object... args) {
        ReentrantLock lock = lockFor(gameId);
        lock.lock();
        try {
            // return games.get(gameId).makeMove(...);
            return null;
        } finally {
            lock.unlock();
        }
    }
}

// Different game ids → different locks → parallel matches.

Concurrency cases

  • Two HTTP moves on one game — without per-game lock, turn checks race.
  • Spectators reading board — copy board under lock or accept briefly stale reads.
  • Matchmaking — separate lock from game rules lock.

← Lattice