AlgoInsight

Worked system design solution

Design a Chess Game

A two-player chess engine with move validation, special rules (castling, en passant, promotion), check/checkmate detection, and game history. Exercises polymorphism (piece movement rules), the Command pattern (moves), and the State pattern (game phase).

LLDHard22 min readoopgamepolymorphismstate-machine

Clarify Requirements

Standard chess + the special rules

Functional requirements

Non-functional requirements

Out of scope (v1)

Edge cases

Edge case Expected behavior
Move that leaves your own king in check Reject as illegal
Castling through check / from check Reject
En passant only valid for one move after the pawn's two-square push Tracked via lastMove
Pawn promotion — must choose Q/R/B/N API requires the choice in the move
Resign during opponent's turn Allowed
50-move + 75-move (forced draw) rules Implement both: claim vs auto

Interview: Chess is enormous. State scope before sketching — pick the rules you'll implement and explicitly note (with one sentence each) what you skip. Otherwise the interview gets lost in details.

Core Entities

Board, Piece hierarchy, Move, GameState

Domain nouns

Piece polymorphism

classDiagram
  class Piece {
    <<abstract>>
    +color: Color
    +hasMoved: bool
    +pseudoLegalMoves(board, from): Position[]
    +symbol(): string
  }
  Piece <|-- King
  Piece <|-- Queen
  Piece <|-- Rook
  Piece <|-- Bishop
  Piece <|-- Knight
  Piece <|-- Pawn

Each piece subclass overrides pseudoLegalMoves(board, from) — returns squares the piece can move to ignoring whether the move would expose its own king.

"Pseudo-legal" vs "legal"

A pseudo-legal move follows piece movement rules. A legal move is pseudo-legal AND doesn't leave the mover's own king in check. The Game filters pseudo-legal moves by simulating each and checking king safety.

Classes & Relationships

Class diagram + the move-then-undo trick

Class diagram

classDiagram
  class Color { <<enum>> White Black }
  class Position { +row: int; +col: int }
  class Piece {
    <<abstract>>
    +color: Color
    +hasMoved: bool
    +pseudoLegalMoves(board, from): Position[]
  }
  class King
  class Queen
  class Rook
  class Bishop
  class Knight
  class Pawn
  class Move {
    +from: Position
    +to: Position
    +piece: Piece
    +captured: Piece?
    +special: SpecialMove?
    +promotion: PieceType?
  }
  class Board {
    +grid: Square[8][8]
    +at(pos): Piece?
    +place(piece, pos)
    +remove(pos)
    +clone(): Board
  }
  class Game {
    -board: Board
    -history: Move[]
    -turn: Color
    -state: GameState
    +makeMove(from, to, promotion?): Move
    +legalMoves(color): Move[]
    +isInCheck(color): bool
    +resign(color)
  }
  Piece <|-- King
  Piece <|-- Queen
  Piece <|-- Rook
  Piece <|-- Bishop
  Piece <|-- Knight
  Piece <|-- Pawn
  Game *-- Board
  Game *-- Move
  Move *-- Piece

The make-undo trick for legality

To check if a move leaves my king in check:

applyMove(move);                     // mutate board
const inCheck = isAttacked(myKingPos, opponentColor);
undoMove(move);                      // restore board
return !inCheck;

Cheaper than cloning the whole board for every candidate move. Requires reversible mutations (every applyMove must remember enough to undo).

Check detection

A king is in check if any opponent piece's pseudo-legal moves include the king's square. To check, scan all opponent pieces and test their attack squares.

For performance at scale (chess engines), precompute attack maps. For interview-scope: scan is fine.

Code Implementation

TypeScript core (Java + C++ follow same patterns)

Core TypeScript

export enum Color { White = 'W', Black = 'B' }
export enum PieceType { King = 'K', Queen = 'Q', Rook = 'R', Bishop = 'B', Knight = 'N', Pawn = 'P' }
export interface Position { row: number; col: number; }

export abstract class Piece {
  hasMoved = false;
  constructor(public readonly color: Color) {}
  abstract type(): PieceType;
  abstract pseudoLegalMoves(board: Board, from: Position): Position[];
}

export class Pawn extends Piece {
  type() { return PieceType.Pawn; }
  pseudoLegalMoves(board: Board, from: Position): Position[] {
    const dir = this.color === Color.White ? -1 : 1;
    const moves: Position[] = [];
    const oneAhead = { row: from.row + dir, col: from.col };
    if (board.inBounds(oneAhead) && !board.at(oneAhead)) {
      moves.push(oneAhead);
      const twoAhead = { row: from.row + 2*dir, col: from.col };
      if (!this.hasMoved && !board.at(twoAhead)) moves.push(twoAhead);
    }
    for (const dc of [-1, 1]) {
      const cap = { row: from.row + dir, col: from.col + dc };
      if (board.inBounds(cap) && board.at(cap)?.color === oppositeColor(this.color)) {
        moves.push(cap);
      }
      // En passant — check Game.lastMove
    }
    return moves;
  }
}

export class Knight extends Piece {
  type() { return PieceType.Knight; }
  pseudoLegalMoves(board: Board, from: Position): Position[] {
    const deltas = [[-2,-1],[-2,1],[-1,-2],[-1,2],[1,-2],[1,2],[2,-1],[2,1]];
    return deltas
      .map(([dr, dc]) => ({ row: from.row+dr, col: from.col+dc }))
      .filter(p => board.inBounds(p) && board.at(p)?.color !== this.color);
  }
}

export class Bishop extends Piece {
  type() { return PieceType.Bishop; }
  pseudoLegalMoves(board: Board, from: Position): Position[] {
    return slidingMoves(board, from, this.color, [[-1,-1],[-1,1],[1,-1],[1,1]]);
  }
}

export class Rook extends Piece {
  type() { return PieceType.Rook; }
  pseudoLegalMoves(board: Board, from: Position): Position[] {
    return slidingMoves(board, from, this.color, [[-1,0],[1,0],[0,-1],[0,1]]);
  }
}

export class Queen extends Piece {
  type() { return PieceType.Queen; }
  pseudoLegalMoves(board: Board, from: Position): Position[] {
    return slidingMoves(board, from, this.color,
      [[-1,-1],[-1,0],[-1,1],[0,-1],[0,1],[1,-1],[1,0],[1,1]]);
  }
}

export class King extends Piece {
  type() { return PieceType.King; }
  pseudoLegalMoves(board: Board, from: Position): Position[] {
    const deltas = [[-1,-1],[-1,0],[-1,1],[0,-1],[0,1],[1,-1],[1,0],[1,1]];
    return deltas
      .map(([dr, dc]) => ({ row: from.row+dr, col: from.col+dc }))
      .filter(p => board.inBounds(p) && board.at(p)?.color !== this.color);
    // Castling handled in Game.legalMoves
  }
}

function slidingMoves(board: Board, from: Position, color: Color, dirs: number[][]): Position[] {
  const out: Position[] = [];
  for (const [dr, dc] of dirs) {
    let r = from.row + dr, c = from.col + dc;
    while (board.inBounds({row:r, col:c})) {
      const p = board.at({ row: r, col: c });
      if (!p) { out.push({ row: r, col: c }); }
      else { if (p.color !== color) out.push({ row: r, col: c }); break; }
      r += dr; c += dc;
    }
  }
  return out;
}

function oppositeColor(c: Color): Color { return c === Color.White ? Color.Black : Color.White; }

export class Board {
  private grid: (Piece | null)[][] = Array.from({ length: 8 }, () => Array(8).fill(null));

  static initial(): Board {
    const b = new Board();
    // Place pawns, then back rank in standard order…
    for (let c = 0; c < 8; c++) {
      b.grid[1][c] = new Pawn(Color.Black);
      b.grid[6][c] = new Pawn(Color.White);
    }
    const backRank = (color: Color, row: number) => {
      b.grid[row] = [new Rook(color), new Knight(color), new Bishop(color), new Queen(color),
                     new King(color), new Bishop(color), new Knight(color), new Rook(color)];
    };
    backRank(Color.Black, 0);
    backRank(Color.White, 7);
    return b;
  }

  at(pos: Position): Piece | null {
    if (!this.inBounds(pos)) return null;
    return this.grid[pos.row][pos.col];
  }
  inBounds(pos: Position): boolean { return pos.row >= 0 && pos.row < 8 && pos.col >= 0 && pos.col < 8; }
  place(pos: Position, piece: Piece | null): void { this.grid[pos.row][pos.col] = piece; }

  findKing(color: Color): Position {
    for (let r = 0; r < 8; r++) for (let c = 0; c < 8; c++) {
      const p = this.grid[r][c];
      if (p?.color === color && p.type() === PieceType.King) return { row: r, col: c };
    }
    throw new Error('King not found');
  }
}

export type GameState = { kind: 'in_progress' } | { kind: 'check'; color: Color }
  | { kind: 'checkmate'; winner: Color } | { kind: 'stalemate' } | { kind: 'draw'; reason: string }
  | { kind: 'resigned'; winner: Color };

export class Game {
  private board = Board.initial();
  private turn: Color = Color.White;
  private state: GameState = { kind: 'in_progress' };
  private readonly history: Move[] = [];

  makeMove(from: Position, to: Position, promotion?: PieceType): Move {
    if (this.state.kind !== 'in_progress' && this.state.kind !== 'check') throw new Error('game over');
    const piece = this.board.at(from);
    if (!piece || piece.color !== this.turn) throw new Error('no piece of yours at from');
    const legal = this.legalMovesFor(piece, from);
    if (!legal.some(p => p.row === to.row && p.col === to.col)) throw new Error('illegal move');

    const captured = this.board.at(to);
    const move: Move = { from, to, piece, captured, promotion };
    this.applyMove(move);
    this.history.push(move);
    this.turn = oppositeColor(this.turn);
    this.updateState();
    return move;
  }

  private legalMovesFor(piece: Piece, from: Position): Position[] {
    const pseudo = piece.pseudoLegalMoves(this.board, from);
    return pseudo.filter(to => this.isMoveSafe(piece, from, to));
  }

  private isMoveSafe(piece: Piece, from: Position, to: Position): boolean {
    const captured = this.board.at(to);
    this.board.place(to, piece);
    this.board.place(from, null);
    const kingPos = this.board.findKing(piece.color);
    const safe = !this.isAttacked(kingPos, oppositeColor(piece.color));
    // undo
    this.board.place(from, piece);
    this.board.place(to, captured);
    return safe;
  }

  private isAttacked(pos: Position, byColor: Color): boolean {
    for (let r = 0; r < 8; r++) for (let c = 0; c < 8; c++) {
      const p = this.board.at({ row: r, col: c });
      if (p?.color === byColor) {
        const moves = p.pseudoLegalMoves(this.board, { row: r, col: c });
        if (moves.some(m => m.row === pos.row && m.col === pos.col)) return true;
      }
    }
    return false;
  }

  private applyMove(move: Move): void {
    this.board.place(move.from, null);
    let piece = move.piece;
    if (move.promotion) {
      const PromCls = { Q: Queen, R: Rook, B: Bishop, N: Knight }[move.promotion];
      if (PromCls) piece = new PromCls(move.piece.color);
    }
    this.board.place(move.to, piece);
    piece.hasMoved = true;
    // (castling rook move + en-passant capture logic also here in a complete impl)
  }

  private updateState(): void {
    const inCheck = this.isAttacked(this.board.findKing(this.turn), oppositeColor(this.turn));
    const hasLegalMove = this.hasAnyLegalMove(this.turn);
    if (inCheck && !hasLegalMove) this.state = { kind: 'checkmate', winner: oppositeColor(this.turn) };
    else if (!inCheck && !hasLegalMove) this.state = { kind: 'stalemate' };
    else if (inCheck) this.state = { kind: 'check', color: this.turn };
    else this.state = { kind: 'in_progress' };
  }

  private hasAnyLegalMove(color: Color): boolean {
    for (let r = 0; r < 8; r++) for (let c = 0; c < 8; c++) {
      const p = this.board.at({ row: r, col: c });
      if (p?.color === color && this.legalMovesFor(p, { row: r, col: c }).length > 0) return true;
    }
    return false;
  }

  resign(color: Color): GameState {
    if (this.state.kind === 'checkmate' || this.state.kind === 'resigned') return this.state;
    this.state = { kind: 'resigned', winner: oppositeColor(color) };
    return this.state;
  }

  getState(): GameState { return this.state; }
}

export interface Move {
  from: Position; to: Position;
  piece: Piece; captured: Piece | null;
  promotion?: PieceType;
}

(Castling + en-passant omitted for brevity — same pattern: special-case in legalMovesFor for King's castling moves; applyMove for the rook move; en-passant tracked via Game.lastMove and special-cased in Pawn.)

Java / C++ equivalents

The patterns transfer directly — abstract Piece + concrete classes, immutable Position records, Game as the aggregate root.

Edge Cases & Tests

Common chess gotchas

Critical tests

describe('Chess corner cases', () => {
  it('rejects pinned-piece move', () => {
    // Setup: white king e1, white rook e2 (pinned by black queen e8)
    // Moving the rook off the e-file exposes the king → illegal
  });

  it('detects checkmate (Fool's Mate)', () => {
    const g = new Game();
    g.makeMove({row:6,col:5},{row:5,col:5}); // 1. f3
    g.makeMove({row:1,col:4},{row:3,col:4}); // 1... e5
    g.makeMove({row:6,col:6},{row:4,col:6}); // 2. g4
    g.makeMove({row:0,col:3},{row:4,col:7}); // 2... Qh4#
    expect(g.getState().kind).toBe('checkmate');
  });

  it('stalemate is a draw', () => { /* classic K+Q vs K stalemate position */ });
});

Extensibility & Trade-offs

AI hooks, network, persistence

Extensions

Trade-offs

Trade-off Got Gave up
make-undo for legality O(legal-moves) per turn Mutable board — careful with concurrent reads
Polymorphic piece classes Clean OOP, easy to add variant pieces More classes than a flat data structure
No precomputed attack maps Simpler code Slower at very high move-rate (chess engines do precompute)
50/threefold rules unimplemented Saves complexity Spec gap

Interview: Chess is famously a "show your scoping discipline" interview. Strong candidates pick a subset (e.g., "I'll implement standard moves + check + checkmate, skipping castling + en passant for time") and ship that cleanly rather than half-implementing everything.

Practice this question