Home › Low-Level Design › Design Chess
D

Design Chess

Chess is the LLD problem that separates people who model nouns from people who model rules. The board is easy. The rules are where candidates drown. I like this problem because every piece pushes on your design, and the hard parts (check, castling, en passant) force real decisions instead of hand-waving. I have given this in loops, and I can usually tell inside ten minutes who will pass: the ones who pass draw the validator before they draw the king.

Low-Level DesignHard12 min read

If you remember five things

The whole answer in one glance. Everything below defends these five calls.

  1. Pieces propose, the validator disposes. A bishop knows it moves diagonally. It does not know that moving exposes its king. Geometry lives in the piece, legality lives in one validator.
  2. Check detection is simulate, test, revert. Apply the move on the real board, ask if your king is attacked, roll it back. No analytic pin math, no clever tricks.
  3. Checkmate and stalemate are the same question. "Do you have any legal move?" No legal move plus in check is mate, no legal move and safe is stalemate. One line apart.
  4. The Move object is a command. It carries captured piece, type, and the rook's old square, so undo and the check simulation are both free.
  5. History is a first-class store. En passant, threefold repetition, and the fifty-move rule all read the move log, so some rules cannot live in a piece at all.

How to budget the 40 minutes.

1Clarify the requirements#

Chess looks fully specified and is not. "Full rules" can mean five things or fifteen, so pin it down before drawing anything.

Questions I actually ask:

  • Two human players, local turns? Or a clock, networking, an AI opponent? I assume two local players for the core and keep room for the rest.
  • Do we validate moves or just record them? Validation is the whole point. We validate.
  • Which rules: castling, en passant, promotion, check, checkmate, stalemate, fifty-move, threefold? Yes to the first five, draw-by-repetition as a follow-up.
  • Move history and undo? Yes, because it also powers repetition detection and gives us free replay.

Functional scope I commit to:

  • An 8x8 board with 32 pieces at start, 16 per side.
  • Generate and validate legal moves per piece.
  • Enforce turn order (White first).
  • Detect check, checkmate, stalemate.
  • Support castling, en passant, promotion.
  • Track move history.

Non-functional scope:

  • Extensible rules: a new variant or fairy piece touches one class, not ten.
  • Correctness over raw speed. A full-board scan per move is fine for 64 squares and a human cadence of one move every few seconds. This is a design interview, not a bitboard contest.
  • No I/O coupling. I want to set up a back-rank mate in a unit test and assert GameStatus.CHECKMATE without rendering anything.
From the interviewer's chair

This opening is scored before you draw a single class. I am listening for whether you narrow scope on your own and whether you say "we validate, that is the whole point" without being prompted. Candidates who start listing all 15 rules as mandatory have told me they cannot cut scope under pressure.

2Identify the core entities#

One line of reasoning per noun, and why each earns its place.

  • Board. Owns the 8x8 grid and knows what sits where. The single source of truth for positions.
  • Cell (or Square). A coordinate plus an optional occupying piece. A real Cell object (not a bare Piece[][]) gives a clean home for color and position, and lets a Move hold references to real objects instead of loose row/col ints.
  • Piece. Abstract. Color, current cell, and the one method that matters: "give me your candidate moves from here." Each concrete piece (King, Queen, Rook, Bishop, Knight, Pawn) overrides movement.
  • Player. Color and whether it is a human. Thin on purpose: the thinness is the seam where an AI opponent plugs in later.
  • Move. Start cell, end cell, piece moved, anything captured, and flags for special moves. This object is gold for undo and history.
  • Game. The orchestrator. Holds the board, players, whose turn it is, the move log, and the status. The thing the outside world talks to.
  • MoveValidator. The brain. Given a proposed move, is it legal, including "does this leave my own king in check"? Pulled out of Piece deliberately: pieces know geometry, the validator knows the rules of the game.
  • GameStatus. An enum: ACTIVE, CHECK, CHECKMATE, STALEMATE, DRAW.
The split that carries the whole design

Pieces propose, the validator disposes. A bishop knows it moves diagonally. It does not know that moving exposes its king. Say this out loud first, so the interviewer knows you are modeling rules, not just boxes.

3Class design#

The spine is a Piece hierarchy using polymorphism for movement, a Board that stores state, and a Game that coordinates turns and hands legality to a MoveValidator.

Why the check logic lives in the validator, not the piece:

  • Each Piece implements candidateMoves(board): raw geometry, pretending check does not exist.
  • The validator filters those candidates: is the path blocked, does the destination hold a friendly piece, and would this move leave my own king attacked?
  • That last filter is identical for every piece, so it belongs in one place.
  • Put it inside each piece and you write it six times and get it wrong at least once.
classDiagram class Game { -Board board -Player white -Player black -Player current -List~Move~ history -GameStatus status +makeMove(from, to) boolean +undo() void } class Board { -Cell[][] cells +cellAt(row, col) Cell +place(piece, cell) void +findKing(color) Cell } class Cell { -int row -int col -Piece piece +isEmpty() boolean } class Piece { <> -Color color -boolean hasMoved +candidateMoves(board, from) List~Cell~ +symbol() char } class King class Queen class Rook class Bishop class Knight class Pawn class Player { -Color color -boolean isHuman } class Move { -Cell from -Cell to -Piece moved -Piece captured -MoveType type } class MoveValidator { +isLegal(board, move, color) boolean +isInCheck(board, color) boolean +hasLegalMove(board, color) boolean } Piece <|-- King Piece <|-- Queen Piece <|-- Rook Piece <|-- Bishop Piece <|-- Knight Piece <|-- Pawn Game *-- Board Game *-- Player Game o-- Move Board *-- Cell Cell o-- Piece Game --> MoveValidator
Read the diamonds. Game owns a Board and two Players with filled diamonds (composition: they die with the game). It aggregates Moves with a hollow diamond (the history outlives any single turn) and depends on MoveValidator for the hard calls. The hollow diamond on Cell to Piece is the tell that a cell can be empty.

4Key implementation#

Start with the piece abstraction and share the ray walk.

  • The sliding pieces (rook, bishop, queen) all walk in straight lines, so factor that out.
  • A rook is four directions, a bishop is the four diagonals, a queen is all eight.
  • One helper, three thin subclasses.
public abstract class Piece {
    protected final Color color;
    protected boolean hasMoved = false;

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

    // Geometry only. No check logic here.
    public abstract List<Cell> candidateMoves(Board board, Cell from);

    public Color color() { return color; }
    public boolean hasMoved() { return hasMoved; }
    public void markMoved() { this.hasMoved = true; }

    // Shared ray walk for sliding pieces.
    protected List<Cell> slide(Board board, Cell from, int[][] dirs) {
        List<Cell> moves = new ArrayList<>();
        for (int[] d : dirs) {
            int r = from.row() + d[0], c = from.col() + d[1];
            while (board.inBounds(r, c)) {
                Cell next = board.cellAt(r, c);
                if (next.isEmpty()) {
                    moves.add(next);
                } else {
                    if (next.piece().color() != color) moves.add(next); // capture
                    break; // blocked either way
                }
                r += d[0];
                c += d[1];
            }
        }
        return moves;
    }
}

With the shared slide helper, a concrete sliding piece becomes almost nothing. The bishop is two lines of real code.

public class Bishop extends Piece {
    private static final int[][] DIRS = {{1,1},{1,-1},{-1,1},{-1,-1}};
    public Bishop(Color color) { super(color); }

    @Override
    public List<Cell> candidateMoves(Board board, Cell from) {
        return slide(board, from, DIRS);
    }
    @Override public char symbol() { return color == Color.WHITE ? 'B' : 'b'; }
}

The pawn is the ugly one, and that is expected.

  • It moves one way, captures another, has a two-step opener, and triggers en passant and promotion.
  • Keep the geometry here, and let the validator handle en-passant legality against history.
  • Call this out at the whiteboard: the pawn proposes a diagonal-to-empty square as a candidate, the validator decides whether that candidate is a real en-passant capture or garbage.
public class Pawn extends Piece {
    public Pawn(Color color) { super(color); }

    @Override
    public List<Cell> candidateMoves(Board board, Cell from) {
        List<Cell> moves = new ArrayList<>();
        int dir = (color == Color.WHITE) ? -1 : 1; // White moves up the array
        int r = from.row(), c = from.col();

        // One forward, must be empty.
        Cell one = board.safeCell(r + dir, c);
        if (one != null && one.isEmpty()) {
            moves.add(one);
            // Two forward from the start rank.
            Cell two = board.safeCell(r + 2 * dir, c);
            if (!hasMoved && two != null && two.isEmpty()) moves.add(two);
        }
        // Diagonal captures (including the en-passant target square).
        for (int dc : new int[]{-1, 1}) {
            Cell diag = board.safeCell(r + dir, c + dc);
            if (diag == null) continue;
            if (!diag.isEmpty() && diag.piece().color() != color) moves.add(diag);
            else moves.add(diag); // en passant: validator confirms it's real
        }
        return moves;
    }
    @Override public char symbol() { return color == Color.WHITE ? 'P' : 'p'; }
}
Pause before you read on: how do you decide whether a move leaves your own king in check, without reasoning about pins and discovered attacks?
Reveal the answer
You do not reason about it at all. Apply the move on the real board, call isInCheck for your own color, then revert. If the king is attacked in that simulated position, the move is illegal. Cheap, correct, and it handles pins and discovered checks for free because they are just "is the king attacked after this move." Candidates who try to track pins analytically tie themselves in knots.

Now the piece I care about most for correctness: the validator.

  • The trick for "does this leave my king in check" is simple and reliable: simulate, test, revert.
  • No clever math. People who reason about pins and discovered checks analytically tie themselves in knots. Make the move, look, unmake it.
public class MoveValidator {

    public boolean isLegal(Board board, Move move, Color toMove) {
        Piece p = move.moved();
        if (p == null || p.color() != toMove) return false;

        List<Cell> candidates = p.candidateMoves(board, move.from());
        if (!candidates.contains(move.to())) return false;

        // Simulate, test king safety, undo.
        Piece captured = move.to().piece();
        board.apply(move);
        boolean selfCheck = isInCheck(board, toMove);
        board.revert(move, captured);

        return !selfCheck;
    }

    public boolean isInCheck(Board board, Color color) {
        Cell king = board.findKing(color);
        for (Cell cell : board.allOccupied()) {
            Piece attacker = cell.piece();
            if (attacker.color() == color) continue;
            if (attacker.candidateMoves(board, cell).contains(king)) return true;
        }
        return false;
    }

    // Checkmate vs stalemate falls out of this: if no legal move exists,
    // you're mated if in check, stalemated if not.
    public boolean hasLegalMove(Board board, Color color) {
        for (Cell cell : board.allOccupied()) {
            Piece p = cell.piece();
            if (p.color() != color) continue;
            for (Cell to : p.candidateMoves(board, cell)) {
                if (isLegal(board, new Move(cell, to, p), color)) return true;
            }
        }
        return false;
    }
}

A word on cost, because a sharp interviewer will ask.

  • isInCheck scans every occupied piece and asks for its candidate moves, so it is roughly O(pieces x moves-per-piece).
  • hasLegalMove calls isLegal once per candidate, and each isLegal runs a full isInCheck. That nests to something like O(moves-squared) per status check.
  • On a 64-square board with at most 16 pieces a side, that is a few thousand operations, nothing a human-paced game notices.
  • A real engine would precompute attack sets. I say that out loud, then keep the simple version, because correctness is what is being graded.

And Game ties it together, resolving status after every move.

public boolean makeMove(Cell from, Cell to) {
    Move move = buildMove(from, to); // sets MoveType: NORMAL, CASTLE, EN_PASSANT, PROMOTION
    if (!validator.isLegal(board, move, current.color())) return false;

    board.apply(move);
    history.add(move);
    current = (current == white) ? black : white;

    status = resolveStatus();
    return true;
}

private GameStatus resolveStatus() {
    boolean inCheck = validator.isInCheck(board, current.color());
    boolean canMove = validator.hasLegalMove(board, current.color());
    if (!canMove) return inCheck ? GameStatus.CHECKMATE : GameStatus.STALEMATE;
    return inCheck ? GameStatus.CHECK : GameStatus.ACTIVE;
}

Checkmate and stalemate are not special cases. They are the same question (do you have any legal move?) with different answers depending on whether you are in check. This is the single cleanest signal that you understand the domain and not just the class diagram: no legal move plus in check is mate, no legal move and safe is stalemate, one line apart.

The candidate who collapses mate and stalemate into one legal-move question has already told me they understand chess, not just class diagrams.

5Design decisions and patterns used#

One pattern per decision, each pulling real weight.

  • Strategy / polymorphism for movement. Each Piece subclass owns its geometry. A fairy piece for a variant means one new class, zero edits elsewhere. The open-closed principle doing its job.
  • State pattern for the game lifecycle. GameStatus as an enum is the lightweight version. If the interviewer wants richer behavior (a CheckmateState that rejects all moves), promote it to a GameState interface. Start simple, name the upgrade path, because over-engineering the state machine in minute five is its own red flag.
  • Command pattern via Move. Each Move carries enough context (captured piece, type, the rook's old square for castling) to replay or reverse itself. That is what makes undo() and the check-simulation one-liners. The revert(move, captured) call in the validator is the command undoing itself.
  • Factory for board setup. BoardFactory.standardBoard() places all 32 pieces. Swap it for chess960Board() and the rest of the system does not blink.
  • Separation of concerns. Pieces compute geometry, the validator enforces rules, the board stores state, the game orchestrates. No class does two jobs. This is the single thing most interviewers are grading.

6Handling edge cases and concurrency#

The rules that trip people up, and how the design absorbs each one.

  • Check prevention. Handled centrally by simulate-test-revert in isLegal. Every move, every piece, one code path. No per-piece check logic to get wrong. This is the whole reason the validator exists as its own class.
  • Castling. Legal only if king and rook both have hasMoved == false, the squares between are empty, and (the part people forget) the king is not currently in check and does not pass through or land on an attacked square. Verify the king's path with isInCheck on each transit square. For White kingside: the king cannot be in check on e1, cannot pass through f1 under attack, and cannot land on g1 under attack. The hasMoved flag exists precisely for this and for the pawn's double step.
  • En passant. Legal only immediately after the opponent's two-square pawn advance. That is why it needs history: the validator peeks at the last move. If the last move was a pawn double-step landing beside my pawn, the diagonal-to-empty capture is allowed and removes the passed pawn. The one-move window is the detail that catches people. It expires the instant the opponent plays anything else, so this legality cannot live in the pawn, only in something that can see the move log.
  • Promotion. When a pawn reaches the last rank, the Move is tagged PROMOTION and the board swaps in the chosen piece (default queen). The piece choice is an input, not an assumption. Underpromotion to a knight is a real and sometimes winning choice, so pass the target type into the move rather than hard-coding a queen.
  • Stalemate vs checkmate. Same hasLegalMove check, disambiguated by isInCheck. Falls out for free.
  • Threefold repetition and fifty-move rule. The move history plus a board hash lets you count repeated positions and moves since the last capture or pawn move. A Zobrist-style position hash per state makes "has this exact position occurred three times" a counter lookup rather than a board compare.
Red flag that fails you here

Hand-waving castling or en passant. If you say "castling just swaps the king and rook" and skip the three attacked-square checks, or put en-passant legality inside the Pawn class where it cannot see the move log, I know you have not actually implemented the rules. These two are where I spend my hardest follow-ups precisely because they separate the memorizers from the modelers.

On concurrency.

  • A single game is turn-based, so there is no real parallelism inside one match.
  • Concurrency bites on a server hosting many games: lock per game instance, never share a Board across threads, treat each makeMove as the atomic unit.
  • If moves arrive over a network, add a sequence number per game and reject out-of-order or stale moves.
  • The sequence number is cheap insurance: a client that fires the same move twice on a flaky connection gets the second one rejected because its sequence is stale.

7Extensions and follow-ups#

Where interviewers push, and the answer each time.

  • Add an AI opponent. candidateMoves plus isLegal already generate the legal move set, exactly the input a minimax or alpha-beta search needs. An AIPlayer implements the same seat as a HumanPlayer, so the game loop does not change.
  • Networked multiplayer. Keep the model server-side as source of truth. Clients send intended moves, the server validates and broadcasts the resulting Move. The Move object is already a clean wire format.
  • Chess variants (Chess960, custom pieces). New BoardFactory method and/or new Piece subclasses. The validator and game loop stay untouched. The open-closed principle paying rent.
  • Clocks and time controls. A Clock per player, decremented on turn switch inside makeMove. Timeout becomes another GameStatus.
  • Serialization / replay. The history list of Moves is your replay log. Export to PGN or a position hash and you get save, load, and analysis for nearly free.
Follow-ups the interviewer will ask (and your answers)
"Why is check detection not inside each piece?"
Because the rule is identical for every piece: after this move, is my king attacked? If I put it in each piece subclass I write it six times and get it wrong at least once. It lives in the validator as one simulate-test-revert path that every move flows through.
"Where does en-passant legality live, and why not in the Pawn?"
In the validator, because it needs the move history. En passant is legal only immediately after the opponent's two-square advance, a one-move window that expires the instant they play anything else. The Pawn can propose the diagonal-to-empty square as a candidate, but only something that can read the move log can confirm it is a real capture.
"How do you tell checkmate from stalemate?"
Same question, different answer. hasLegalMove returns false in both. If the side to move is also in check it is checkmate, if it is safe it is stalemate. One isInCheck call disambiguates. I never write separate mate and stalemate detectors.
"The simulate-test-revert approach feels slow. Is it?"
Per status check it nests to about O(moves-squared), a few thousand operations on a 64-square board with 16 pieces a side. A human-paced game never notices. A real engine precomputes attack sets and uses bitboards, but for a correctness-graded interview the simple version is the right call, and I say so instead of prematurely optimizing.
"How would you add an AI opponent without rewriting the game?"
The legal move set is already there from candidateMoves plus isLegal, which is exactly what a minimax or alpha-beta search consumes. An AIPlayer sits in the same Player seat as a HumanPlayer, so the game loop does not change. That thin Player abstraction was the seam I left open on purpose in step 2.

What you can now explain in the room

  • Why pieces compute geometry while a single validator decides legality.
  • Why check detection is simulate-test-revert, and how that handles pins for free.
  • Why checkmate and stalemate collapse into one legal-move question.
  • Why the Move object as a command makes undo and check simulation nearly free.
  • Why castling needs three attacked-square checks and en passant needs the move log.
  • How the Player and BoardFactory seams let an AI opponent or Chess960 plug in without a rewrite.

If you liked the piece hierarchy and the pull-the-rules-into-a-validator move, try Design a Deck of Cards or Design Tic-Tac-Toe next, and read up on the Strategy and State patterns.

Get a prep plan built around your Design Chess interview

Tell us the role and your interview date. We will send back a dated, day by day plan. Free.

Thanks. Check your inbox, your Design Chess prep plan is on the way.