LLD · OOP Deep Dive

Low Level Design: Chess Game (OOP Design + Move Validation)

Chess is where inheritance genuinely earns its place. Build a piece hierarchy, validate moves the way real engines do, detect checkmate correctly, and implement undo with the Command pattern, in Java that passes perft.

✍️ Pranjal Jain, Ex-Microsoft · IIT Kanpur 📅 10 October 2026 ⏱ 23 min read
Glass chessboard with glowing pieces and arrows showing legal moves and an undone move
TL;DR
  • Chess is the LLD problem where an inheritance hierarchy is justified: pieces differ in behaviour. Factor shared logic into SlidingPiece (rook, bishop, queen) and StepPiece (knight, king).
  • Validate in two layers: pieces generate pseudo-legal moves; the game filters out moves that leave your king in check by make, test, unmake.
  • Checkmate = no legal moves and in check. Stalemate = no legal moves and not in check. Do not special-case escapes.
  • Each Move is a Command with execute/undo, storing captured piece, capture square (en passant), promotion and rook squares (castling).
  • The Java below handles castling, en passant and promotion and reproduces the published perft counts (20, 400, 8,902 from the start; 48 and 2,039 for the "Kiwipete" test position), verified with a line-by-line port.

Most LLD problems punish you for inheritance. Vehicles in a parking lot do not need subclasses; snakes and ladders are the same thing pointing in different directions. Chess is the exception, and that is precisely why interviewers use it: they want to see whether you reach for a hierarchy because it fits or because it is a reflex, and whether your hierarchy survives the hard parts: pins, castling through check, en passant, and undo.

This guide builds a complete, correct two-player chess model. It goes further than the usual "Piece has canMove()" answer, because that answer breaks the moment the interviewer asks "what if moving this bishop exposes your king?" If you need a refresher on the OOP fundamentals behind it, the OOP interview questions guide covers polymorphism and abstraction with examples.

Scope and Requirements for Chess LLD

In scope

  1. Two players alternate moves on a standard 8×8 board from the standard starting position.
  2. Only legal moves are accepted, including castling, en passant and promotion (with under-promotion).
  3. The game reports check, checkmate and stalemate.
  4. Moves can be undone (and, as an extension, redone).
  5. Moves are entered in coordinate notation such as e2e4 or e7e8n.

Out of scope (say so explicitly)

  • A chess engine (AI). Mention it as a Player strategy extension.
  • Clocks, online play, rating. These are system-design concerns.
  • Draw by threefold repetition, the 50-move rule and insufficient material. Easy to add; covered in extensions.

Scoping like this in the first five minutes matters. Full chess rules are big, and an interviewer would rather see a correct core with clear extension points than a sprawling half-finished rulebook.

Chess Domain Model

ClassResponsibilityKey decision
PositionImmutable square (file, rank), parsing "e4"Record with validation; offset() returns null off-board
ColorWHITE, BLACK, opposite()Enum
Piece hierarchySquares attacked; pseudo-legal movesAbstract class; pieces do not store their position
BoardGrid of pieces, isAttacked, findKingSingle source of truth for where pieces are
MoveCommand: execute and undo itselfStores everything needed to reverse
GameTurn, history, legal move filter, statusThe only class that knows the rules of turns
⚠️
Don't store position inside PieceIf both the board and the piece know where the piece is, they will disagree after a capture or an undo. Keep position on the board only and pass it into piece methods. This one decision prevents a whole category of bugs.

The Piece Hierarchy: When Inheritance Is Right

┌───────────────────────────────┐ │ «abstract» Piece │ │ color, moveCount │ │ attackedSquares(board, from) │ │ pseudoMoves(board, from, last)│ └──────┬─────────────┬──────────┘ ┌─────────────┘ └──────────────┐ ┌────────┴─────────┐ ┌──────────────────┐ ┌──────┴──────┐ │ «abstract» │ │ «abstract» │ │ Pawn │ │ SlidingPiece │ │ StepPiece │ │ own moves, │ │ directions() │ │ offsets() │ │ en passant, │ └──┬──────┬─────┬──┘ └────┬────────┬────┘ │ promotion │ Rook Bishop Queen Knight King └─────────────┘ (+ castling)

Why a hierarchy here and not in the parking lot? Because the subclasses differ in behaviour, not just in a label. A rook walks orthogonally until blocked; a knight jumps to eight fixed offsets; a pawn moves one way, captures another way, and transforms on the last rank. Polymorphism lets Game ask every piece the same question without a switch on type.

The two intermediate classes remove duplication. Rook, bishop and queen are the same algorithm with different direction tables, so SlidingPiece implements the walk once and each subclass supplies directions. Knight and king are "check a fixed list of offsets", so StepPiece does that once. This is the Template Method idea: the base class owns the algorithm, subclasses fill in data.

Inheritance vs composition for movement

A popular alternative is composition: one Piece class holding a list of MovementStrategy objects (slide orthogonal, slide diagonal, knight jump). The queen gets two sliding strategies. This is more flexible for fairy-chess variants ("a piece that moves like a knight or a rook"). For standard chess, the hierarchy is simpler to read. Mention both; pick one; explain why. The SOLID principles guide discusses when to favour composition.

Why separate attackedSquares from pseudoMoves?

Two reasons that usually surface only when someone has actually written a chess program:

  • Pawns attack differently than they move. A pawn on e4 attacks d5 and f5 even if they are empty (which matters for "can the king step there?"), but it can only move to e5.
  • Castling would recurse. Castling requires checking that the king does not pass through attacked squares. If "attacked" were computed from pseudo-moves, the opponent king's castling generation would call back into attack detection, potentially forever. Attacks never include castling, so there is no recursion.

Move Validation: Pseudo-Legal, Then Legal

Move validation is the heart of the interview. Weak answers put a isValidMove(from, to) on each piece and stop. That misses the rule that dominates real games: you may not make any move that leaves your own king in check. That rule covers pins, moving the king into an attacked square, ignoring a check, and the notorious en passant discovered check along a rank.

  1. Geometry (piece level). Generate target squares from movement rules: directions, offsets, pawn pushes.
  2. Occupancy (piece level). Stop sliding at the first occupied square; allow it only if it holds an enemy (capture).
  3. Special-move preconditions (piece level). Castling rights and empty/unattacked squares; en passant only immediately after the enemy pawn's double step; promotion on the last rank.
  4. King safety (game level). For each pseudo-legal move: execute it, check whether your king's square is attacked, undo it. Keep it only if safe.
  5. Turn (game level). Only pieces of the side to move are considered.

The make-test-unmake approach is how real move generators handle legality in the simple case (strong engines optimise it with pin detection, but the semantics are identical). It is short, it is obviously correct, and it reuses the Command pattern you need for undo anyway. That reuse is a strong point to make out loud.

Detecting Check, Checkmate and Stalemate

StatusKing attacked?Legal moves exist?
Normal playNoYes
CheckYesYes
CheckmateYesNo
Stalemate (draw)NoNo

That table is the entire algorithm. Candidates who try to detect checkmate directly ("can the king move? can something capture the attacker? can something block?") almost always miss a case: double check (only king moves help), a blocker that is itself pinned, or capturing the checking pawn en passant. Generating legal moves and counting them handles all of it, because the legality filter already encodes every rule.

Is the king attacked? Scan the opponent's pieces and ask whether any of them has the king's square in attackedSquares. That is O(pieces × reach), at most a few hundred square checks, which is negligible for a game UI. Engines invert it (look outward from the king along rays and knight jumps), which is a good optimisation to mention.

Special Moves: Castling, En Passant, Promotion

Castling: five conditions

  1. The king has never moved.
  2. The chosen rook has never moved.
  3. All squares between them are empty.
  4. The king is not currently in check.
  5. The king does not pass through or land on an attacked square (for queen-side, the b-file square must be empty but may be attacked).

"Has never moved" is tracked by a moveCount on each piece, incremented by execute and decremented by undo. A boolean hasMoved is a classic bug: after undoing the king's first move you cannot know whether to reset it to false. A counter restores exactly.

En passant

Legal only on the move immediately after an enemy pawn advances two squares and lands beside your pawn. The capture lands on the square the enemy pawn skipped, but removes the pawn from a different square. That is why Move stores capturedAt separately from to. The "immediately after" rule means the move generator needs the last move, which the game's history stack provides.

Promotion

A pawn reaching the last rank becomes a queen, rook, bishop or knight. Generate all four as separate moves (under-promotion to a knight sometimes delivers mate). The Move stores the new piece; undo puts the original pawn back.

Command Pattern for Undo (and Redo)

Each Move implements Command: execute(board) and undo(board). The game keeps a stack of executed moves. Undo pops and reverses; redo (an extension) pushes onto a second stack that is cleared when a new move is played, the same model every text editor uses.

What a move must remember

FieldWhy undo needs it
from, to, pieceMove the piece back
capturedRestore the captured piece
capturedAtEn passant captures from a square other than to
promotionReplace the new piece with the original pawn
rookFrom, rookToCastling moves two pieces
piece moveCount (on the piece)Restores castling rights and pawn double-step eligibility

Command vs Memento

The alternative is Memento: snapshot the whole board before every move and restore it on undo. It is simpler to get right and costs 64 references per move, which is trivial for a human game. Commands are cheaper per move, double as the move log for PGN export, and are needed anyway for make-unmake validation, so they win here. Saying "Memento would also work, here is why I chose Command" is the kind of trade-off reasoning the round rewards. For a broader catalogue of these patterns, see top 10 design patterns for interviews.

Full Java Code for the Chess Game

Java 17+, single file, run with java ChessDemo.java. Coordinates are zero-based: file 0 = a, rank 0 = 1.

Java
import java.util.*;

public class ChessDemo {

    enum Color {
        WHITE, BLACK;
        Color opposite() { return this == WHITE ? BLACK : WHITE; }
    }

    enum Status { ACTIVE, CHECK, CHECKMATE, STALEMATE }

    record Position(int file, int rank) {
        Position {
            if (!inside(file, rank)) throw new IllegalArgumentException("Off board: " + file + "," + rank);
        }
        static boolean inside(int f, int r) { return f >= 0 && f < 8 && r >= 0 && r < 8; }
        Position offset(int df, int dr) {
            return inside(file + df, rank + dr) ? new Position(file + df, rank + dr) : null;
        }
        static Position parse(String s) { return new Position(s.charAt(0) - 'a', s.charAt(1) - '1'); }
        @Override public String toString() { return "" + (char) ('a' + file) + (rank + 1); }
    }

    // ===================== Moves as Commands =====================
    interface Command {
        void execute(Board b);
        void undo(Board b);
    }

    static final class Move implements Command {
        final Position from, to;
        final Piece piece;
        final Piece captured;        // null if none
        final Position capturedAt;   // differs from 'to' only for en passant
        final Piece promotion;       // null unless promoting
        final Position rookFrom, rookTo;   // castling only

        private Move(Position from, Position to, Piece piece, Piece captured, Position capturedAt,
                     Piece promotion, Position rookFrom, Position rookTo) {
            this.from = from; this.to = to; this.piece = piece;
            this.captured = captured; this.capturedAt = capturedAt;
            this.promotion = promotion; this.rookFrom = rookFrom; this.rookTo = rookTo;
        }

        static Move quiet(Position f, Position t, Piece p) { return new Move(f, t, p, null, null, null, null, null); }
        static Move capture(Position f, Position t, Piece p, Piece c) { return new Move(f, t, p, c, t, null, null, null); }
        static Move promotion(Position f, Position t, Piece p, Piece c, Piece promo) {
            return new Move(f, t, p, c, c == null ? null : t, promo, null, null);
        }
        static Move enPassant(Position f, Position t, Piece p, Piece c, Position cAt) {
            return new Move(f, t, p, c, cAt, null, null, null);
        }
        static Move castle(Position f, Position t, Piece king, Position rf, Position rt) {
            return new Move(f, t, king, null, null, null, rf, rt);
        }

        public void execute(Board b) {
            if (captured != null) b.set(capturedAt, null);
            b.set(from, null);
            b.set(to, promotion != null ? promotion : piece);
            piece.moveCount++;
            if (rookFrom != null) {
                Piece rook = b.get(rookFrom);
                b.set(rookFrom, null);
                b.set(rookTo, rook);
                rook.moveCount++;
            }
        }

        public void undo(Board b) {
            if (rookFrom != null) {
                Piece rook = b.get(rookTo);
                b.set(rookTo, null);
                b.set(rookFrom, rook);
                rook.moveCount--;
            }
            piece.moveCount--;
            b.set(to, null);
            b.set(from, piece);
            if (captured != null) b.set(capturedAt, captured);
        }

        @Override public String toString() {
            return "" + from + to + (promotion == null ? "" : String.valueOf(Character.toLowerCase(promotion.letter())));
        }
    }

    // ===================== Pieces =====================
    static final int[][] ORTHOGONAL = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}};
    static final int[][] DIAGONAL = {{1, 1}, {1, -1}, {-1, 1}, {-1, -1}};
    static final int[][] ALL_DIRECTIONS = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}, {1, 1}, {1, -1}, {-1, 1}, {-1, -1}};
    static final int[][] KNIGHT_JUMPS = {{1, 2}, {2, 1}, {2, -1}, {1, -2}, {-1, -2}, {-2, -1}, {-2, 1}, {-1, 2}};

    abstract static class Piece {
        final Color color;
        int moveCount;   // castling rights; restored exactly on undo

        Piece(Color color) { this.color = color; }

        abstract char letter();

        char symbol() { return color == Color.WHITE ? letter() : Character.toLowerCase(letter()); }

        /** Squares this piece controls (used for check detection). Never includes castling. */
        abstract List<Position> attackedSquares(Board b, Position from);

        /** Moves by movement rules only; may leave own king in check (Game filters those). */
        List<Move> pseudoMoves(Board b, Position from, Move lastMove) {
            List<Move> out = new ArrayList<>();
            for (Position to : attackedSquares(b, from)) {
                Piece target = b.get(to);
                if (target == null) out.add(Move.quiet(from, to, this));
                else if (target.color != color) out.add(Move.capture(from, to, this, target));
            }
            return out;
        }
    }

    abstract static class SlidingPiece extends Piece {
        SlidingPiece(Color c) { super(c); }
        abstract int[][] directions();

        List<Position> attackedSquares(Board b, Position from) {
            List<Position> out = new ArrayList<>();
            for (int[] d : directions()) {
                Position p = from.offset(d[0], d[1]);
                while (p != null) {
                    out.add(p);
                    if (b.get(p) != null) break;   // blocked beyond this square
                    p = p.offset(d[0], d[1]);
                }
            }
            return out;
        }
    }

    abstract static class StepPiece extends Piece {
        StepPiece(Color c) { super(c); }
        abstract int[][] offsets();

        List<Position> attackedSquares(Board b, Position from) {
            List<Position> out = new ArrayList<>();
            for (int[] o : offsets()) {
                Position p = from.offset(o[0], o[1]);
                if (p != null) out.add(p);
            }
            return out;
        }
    }

    static final class Rook extends SlidingPiece {
        Rook(Color c) { super(c); }
        char letter() { return 'R'; }
        int[][] directions() { return ORTHOGONAL; }
    }

    static final class Bishop extends SlidingPiece {
        Bishop(Color c) { super(c); }
        char letter() { return 'B'; }
        int[][] directions() { return DIAGONAL; }
    }

    static final class Queen extends SlidingPiece {
        Queen(Color c) { super(c); }
        char letter() { return 'Q'; }
        int[][] directions() { return ALL_DIRECTIONS; }
    }

    static final class Knight extends StepPiece {
        Knight(Color c) { super(c); }
        char letter() { return 'N'; }
        int[][] offsets() { return KNIGHT_JUMPS; }
    }

    static final class King extends StepPiece {
        King(Color c) { super(c); }
        char letter() { return 'K'; }
        int[][] offsets() { return ALL_DIRECTIONS; }

        @Override
        List<Move> pseudoMoves(Board b, Position from, Move lastMove) {
            List<Move> out = super.pseudoMoves(b, from, lastMove);
            if (moveCount == 0 && !b.isAttacked(from, color.opposite())) {
                tryCastle(b, from, out, 7, 5, 6);   // king side: rook h->f, king e->g
                tryCastle(b, from, out, 0, 3, 2);   // queen side: rook a->d, king e->c
            }
            return out;
        }

        private void tryCastle(Board b, Position from, List<Move> out, int rookFile, int rookToFile, int kingToFile) {
            Position rookPos = new Position(rookFile, from.rank());
            Piece rook = b.get(rookPos);
            if (!(rook instanceof Rook) || rook.color != color || rook.moveCount != 0) return;
            int step = rookFile > from.file() ? 1 : -1;
            for (int f = from.file() + step; f != rookFile; f += step) {
                if (b.get(new Position(f, from.rank())) != null) return;          // path must be empty
            }
            for (int f = from.file() + step; f != kingToFile + step; f += step) {
                if (b.isAttacked(new Position(f, from.rank()), color.opposite())) return;  // no passing through check
            }
            out.add(Move.castle(from, new Position(kingToFile, from.rank()), this,
                                rookPos, new Position(rookToFile, from.rank())));
        }
    }

    static final class Pawn extends Piece {
        Pawn(Color c) { super(c); }
        char letter() { return 'P'; }
        private int dir() { return color == Color.WHITE ? 1 : -1; }

        List<Position> attackedSquares(Board b, Position from) {
            List<Position> out = new ArrayList<>();
            for (int df : new int[] {-1, 1}) {
                Position p = from.offset(df, dir());
                if (p != null) out.add(p);
            }
            return out;
        }

        @Override
        List<Move> pseudoMoves(Board b, Position from, Move lastMove) {
            List<Move> out = new ArrayList<>();
            int lastRank = color == Color.WHITE ? 7 : 0;
            Position one = from.offset(0, dir());
            if (one != null && b.get(one) == null) {
                addWithPromotion(out, from, one, null, lastRank);
                int startRank = color == Color.WHITE ? 1 : 6;
                if (from.rank() == startRank) {
                    Position two = from.offset(0, 2 * dir());
                    if (b.get(two) == null) out.add(Move.quiet(from, two, this));
                }
            }
            for (Position diag : attackedSquares(b, from)) {
                Piece target = b.get(diag);
                if (target != null && target.color != color) addWithPromotion(out, from, diag, target, lastRank);
            }
            // En passant: only right after an adjacent enemy pawn's double step
            if (lastMove != null && lastMove.piece instanceof Pawn && lastMove.piece.color != color
                    && Math.abs(lastMove.to.rank() - lastMove.from.rank()) == 2
                    && lastMove.to.rank() == from.rank()
                    && Math.abs(lastMove.to.file() - from.file()) == 1) {
                Position target = new Position(lastMove.to.file(), from.rank() + dir());
                out.add(Move.enPassant(from, target, this, lastMove.piece, lastMove.to));
            }
            return out;
        }

        private void addWithPromotion(List<Move> out, Position from, Position to, Piece captured, int lastRank) {
            if (to.rank() == lastRank) {
                for (Piece promo : List.of(new Queen(color), new Rook(color), new Bishop(color), new Knight(color))) {
                    out.add(Move.promotion(from, to, this, captured, promo));
                }
            } else {
                out.add(captured == null ? Move.quiet(from, to, this) : Move.capture(from, to, this, captured));
            }
        }
    }

    // ===================== Board =====================
    static final class Board {
        private final Piece[][] grid = new Piece[8][8];   // [file][rank]

        Piece get(Position p) { return grid[p.file()][p.rank()]; }
        void set(Position p, Piece piece) { grid[p.file()][p.rank()] = piece; }

        static Board standard() {
            Board b = new Board();
            for (Color c : Color.values()) {
                int back = c == Color.WHITE ? 0 : 7;
                int pawns = c == Color.WHITE ? 1 : 6;
                Piece[] row = {new Rook(c), new Knight(c), new Bishop(c), new Queen(c),
                               new King(c), new Bishop(c), new Knight(c), new Rook(c)};
                for (int f = 0; f < 8; f++) {
                    b.grid[f][back] = row[f];
                    b.grid[f][pawns] = new Pawn(c);
                }
            }
            return b;
        }

        boolean isAttacked(Position target, Color by) {
            for (int f = 0; f < 8; f++) {
                for (int r = 0; r < 8; r++) {
                    Piece p = grid[f][r];
                    if (p != null && p.color == by && p.attackedSquares(this, new Position(f, r)).contains(target)) {
                        return true;
                    }
                }
            }
            return false;
        }

        Position findKing(Color c) {
            for (int f = 0; f < 8; f++)
                for (int r = 0; r < 8; r++)
                    if (grid[f][r] instanceof King && grid[f][r].color == c) return new Position(f, r);
            throw new IllegalStateException("No " + c + " king");
        }

        String render() {
            StringBuilder sb = new StringBuilder();
            for (int r = 7; r >= 0; r--) {
                sb.append(r + 1).append(' ');
                for (int f = 0; f < 8; f++) sb.append(grid[f][r] == null ? '.' : grid[f][r].symbol()).append(' ');
                sb.append('\n');
            }
            return sb.append("  a b c d e f g h").toString();
        }
    }

    // ===================== Game =====================
    static final class Game {
        private final Board board;
        private Color turn = Color.WHITE;
        private final Deque<Move> history = new ArrayDeque<>();

        Game(Board board) { this.board = board; }

        Board board() { return board; }
        Color turn() { return turn; }

        List<Move> legalMoves() {
            List<Move> legal = new ArrayList<>();
            Move last = history.peekLast();
            for (int f = 0; f < 8; f++) {
                for (int r = 0; r < 8; r++) {
                    Position from = new Position(f, r);
                    Piece p = board.get(from);
                    if (p == null || p.color != turn) continue;
                    for (Move m : p.pseudoMoves(board, from, last)) {
                        m.execute(board);                                   // make
                        boolean unsafe = board.isAttacked(board.findKing(turn), turn.opposite());
                        m.undo(board);                                      // unmake
                        if (!unsafe) legal.add(m);
                    }
                }
            }
            return legal;
        }

        /** Coordinate notation: "e2e4", promotion "e7e8n" (defaults to queen). */
        void play(String uci) {
            Position from = Position.parse(uci.substring(0, 2));
            Position to = Position.parse(uci.substring(2, 4));
            char promo = uci.length() > 4 ? Character.toLowerCase(uci.charAt(4)) : 'q';
            for (Move m : legalMoves()) {
                if (m.from.equals(from) && m.to.equals(to)
                        && (m.promotion == null || Character.toLowerCase(m.promotion.letter()) == promo)) {
                    apply(m);
                    return;
                }
            }
            throw new IllegalArgumentException("Illegal move: " + uci);
        }

        void undo() {
            Move m = history.pollLast();
            if (m == null) throw new IllegalStateException("Nothing to undo");
            m.undo(board);
            turn = turn.opposite();
        }

        Status status() {
            boolean inCheck = board.isAttacked(board.findKing(turn), turn.opposite());
            boolean hasMoves = !legalMoves().isEmpty();
            if (hasMoves) return inCheck ? Status.CHECK : Status.ACTIVE;
            return inCheck ? Status.CHECKMATE : Status.STALEMATE;
        }

        /** Counts leaf nodes of the legal move tree: the standard move-generator test. */
        long perft(int depth) {
            if (depth == 0) return 1;
            long nodes = 0;
            for (Move m : legalMoves()) {
                apply(m);
                nodes += perft(depth - 1);
                undo();
            }
            return nodes;
        }

        private void apply(Move m) {
            m.execute(board);
            history.addLast(m);
            turn = turn.opposite();
        }
    }

    public static void main(String[] args) {
        Game game = new Game(Board.standard());
        for (int d = 1; d <= 3; d++) System.out.println("perft(" + d + ") = " + game.perft(d));

        // Scholar's mate
        for (String mv : "e2e4 e7e5 f1c4 b8c6 d1h5 g8f6 h5f7".split(" ")) game.play(mv);
        System.out.println(game.board().render());
        System.out.println("Status: " + game.status());
        game.undo();
        System.out.println("After undo: " + game.status() + ", " + game.turn() + " to move");

        // En passant
        Game ep = new Game(Board.standard());
        for (String mv : "e2e4 a7a6 e4e5 d7d5 e5d6".split(" ")) ep.play(mv);
        System.out.println(ep.board().render());
    }
}

Expected output

Output
perft(1) = 20
perft(2) = 400
perft(3) = 8902
8 r . b q k b . r
7 p p p p . Q p p
6 . . n . . n . .
5 . . . . p . . .
4 . . B . P . . .
3 . . . . . . . .
2 P P P P . P P P
1 R N B . K . N R
  a b c d e f g h
Status: CHECKMATE
After undo: ACTIVE, WHITE to move
8 r n b q k b n r
7 . p p . p p p p
6 p . . P . . . .
5 . . . . . . . .
4 . . . . . . . .
3 . . . . . . . .
2 P P P P . P P P
1 R N B Q K B N R
  a b c d e f g h

In the scholar's mate, the queen on f7 checks the king on e8. The king cannot take it (the bishop on c4 defends f7), e7 is covered by the queen, d8 and f8 are occupied by Black's own pieces, and nothing can capture or block an adjacent checker. The legality filter finds zero moves while the king is attacked, so the status is checkmate with no special-case code. In the second board, White's e5 pawn captured d5 en passant: the pawn now stands on d6 and the black d-pawn is gone.

Testing a Chess Move Generator with Perft

How do you know your move generator is right? Not by playing a few games. The standard technique is perft (performance test): count every leaf of the legal move tree to depth N and compare with published numbers, such as those on the Chess Programming Wiki perft page.

PositionDepthExpected nodesWhat it exercises
Start position120Pawn single/double pushes, knight jumps
Start position2400Both sides' replies
Start position38,902First captures and checks
"Kiwipete" test position148Both castles, pins, many captures
"Kiwipete" test position22,039Castling through attacked squares, promotions nearby

The design in this article was checked against all five numbers using a line-by-line port of the same classes and logic. Perft is also a great interview talking point: it shows you think about verification, not just implementation. If the counts disagree, "divide" (print per-first-move counts) and compare with a reference engine to find the exact buggy move type.

Complexity

~35
Typical legal moves per position (middlegame)
O(M × A)
Legal move generation: M pseudo-moves × attack scan
O(1)
Undo one move (Command)

For a human-vs-human game this is instant. For an engine searching millions of positions per second you would switch to bitboards (one 64-bit integer per piece type), incremental attack tables and pin detection instead of make-unmake for every move. Mentioning that boundary shows you know where the simple design stops.

Extensions Interviewers Ask

Players: human, AI, remote

Introduce interface Player { Move chooseMove(Game game); }. HumanPlayer reads input, RandomPlayer picks any legal move, MinimaxPlayer searches using legalMoves, play and undo (the Command pattern makes search trivial). A remote player wraps a network connection. The game loop does not change. This is Strategy.

Draw rules

  • Fifty-move rule: a half-move counter reset on pawn moves and captures; store the previous value in the Move so undo restores it.
  • Threefold repetition: hash each position (Zobrist hashing: XOR of random 64-bit keys per piece-square, side to move, castling rights and en passant file) and count occurrences in a map; decrement on undo.
  • Insufficient material: king vs king, king and bishop vs king, king and knight vs king, and same-coloured bishops.

FEN and PGN

FEN describes a position in one line (useful for loading puzzles and tests); a BoardFactory.fromFen is a natural Factory. PGN records a game in standard algebraic notation; generating it from the move history needs disambiguation ("Nbd2" vs "Nfd2"), a nice small exercise.

Online multiplayer and clocks

The game model stays the same; you add a server that owns Game instances, validates every incoming move with play(), and pushes updates to both clients. Real-time push options are compared in WebSockets vs SSE vs long polling. A chess clock is a per-player remaining-time counter with increment, updated on each move server-side.

Observer for UI and logs

A board view, a captured-pieces panel and a move list subscribe to "move played" and "move undone" events. The domain stays free of UI code.

How to Present Chess in an LLD Interview

  1. Scope (3–5 min). Standard rules, two local players, undo. Declare engine, clocks and draw rules out of scope.
  2. Model (5–10 min). Position, Color, Piece hierarchy with the two intermediate classes, Board, Move, Game. State that pieces do not store position.
  3. Validation story (5 min). Pseudo-legal at piece level, legal via make-test-unmake at game level; checkmate and stalemate from the legal move count.
  4. Code (20 min). Sliding and step pieces first, then pawn, then king safety filter. Leave castling and en passant as named methods if time is short and describe them.
  5. Undo and testing (5 min). Command fields, moveCount counters, perft as verification.

Mistakes that sink chess LLD answers

  • canMove(from, to) per piece with no king-safety check. Pins and moving into check go unhandled.
  • A giant switch (pieceType) in the board class, defeating the hierarchy.
  • Storing position in both piece and board.
  • A hasMoved boolean that cannot be undone correctly.
  • Detecting checkmate by hand-coded escape checks instead of counting legal moves.
  • Forgetting that en passant removes a pawn from a different square than the destination.
  • A Player class that holds a list of pieces in addition to the board: two sources of truth again.

If chess feels heavy, warm up with snake and ladder for game-loop structure, then come back. For how LLD rounds are evaluated at Indian product companies, see the LLD interview guide and the machine coding round guide.

Frequently Asked Questions

How do you design a chess game using OOP?▾
Use an abstract Piece class with subclasses for each piece type, a Board that stores pieces by position, a Move object that knows how to execute and undo itself, and a Game that tracks turn, history and status. Sliding pieces (rook, bishop, queen) share a direction-walking base class and stepping pieces (knight, king) share an offset base class.
How do you validate a chess move in code?▾
Generate pseudo-legal moves from each piece's movement rules, then filter out any move that leaves your own king in check by making the move on the board, testing whether the king's square is attacked, and undoing it. This make-unmake filter correctly handles pins, discovered checks and en passant edge cases.
How do you detect checkmate in a chess program?▾
Compute all legal moves for the side to move. If there are none and the king is attacked, it is checkmate; if there are none and the king is not attacked, it is stalemate. Trying to detect checkmate by special-casing escapes, blocks and captures is error-prone.
Which design patterns are used in chess low level design?▾
Command for moves with execute and undo, Template Method or Strategy for movement rules (sliding vs stepping pieces), Factory for creating pieces from notation, and Strategy again for players so a human and an AI can be swapped. Observer is used for UIs and move logs.
How is undo implemented in chess?▾
Each move is a command that stores everything needed to reverse it: source and target squares, the moving piece, any captured piece and where it stood (different for en passant), the promotion piece, and the rook squares for castling. Undo pops the last command from a history stack and reverses it.
What is perft in chess programming?▾
Perft counts all leaf nodes of the legal move tree to a given depth. From the starting position the known counts are 20, 400 and 8,902 for depths 1 to 3. Matching published perft numbers is the standard way to prove a move generator is correct.
Should each chess piece know about the board?▾
A piece should compute its candidate moves given a board passed in, but should not store a reference to the board or its own position. Keeping position on the board avoids two sources of truth, which is a common source of bugs after captures and undos.

Level Up Your LLD Game

Prepflix's interview course takes you from parking lot to chess-level LLD problems with guided design reviews, so you learn when inheritance, composition and patterns actually pay off.

Explore the Prepflix Course →
Pranjal Jain
Pranjal Jain

Ex-Microsoft SDE · IIT Kanpur · Founder of Prepflix. Helps engineers crack startup and product company interviews across India.