Skip to main content
Chess Game Low Level Design
Difficulty: 🟡 Intermediate | Time: 45-60 minutes | Patterns: Template Method, Command, Memento

🎯 Problem Statement

Design a chess game that can:
  • Support two players (human or computer)
  • Validate moves according to chess rules
  • Detect check, checkmate, and stalemate
  • Track game history and support undo
  • Handle special moves (castling, en passant, pawn promotion)
Why This Problem? Chess is THE showcase for polymorphism — each piece has different movement rules but shares a common interface. The Piece base class defines get_possible_moves(), and each concrete piece (King, Queen, Rook, Bishop, Knight, Pawn) implements its own movement logic. When the Board validates moves, it does not care which piece it is dealing with — it just calls the polymorphic method. This is the textbook demonstration that polymorphism eliminates type-checking if/elif chains. Interviewers also love this problem because it naturally tests your ability to separate concerns: piece logic vs. board validation vs. game rules vs. move history.

📋 Step 1: Clarify Requirements

Interview Tip: Chess has MANY rules. Always clarify which ones are in scope!

Questions to Ask the Interviewer

Functional Requirements

  • Two players take turns (white moves first)
  • Each piece type has unique movement rules
  • Validate that moves don’t leave own king in check
  • Detect game-ending conditions (checkmate, stalemate)
  • Display current board state
  • Support undo/redo of moves

Non-Functional Requirements

  • Clean separation of concerns
  • Easy to add new piece types (for variants)
  • Testable move validation logic
  • Command pattern for move history

🧩 Step 2: Identify Core Objects

Key Insight: Chess is a perfect example of polymorphism - all pieces share get_valid_moves() but each implements it differently.

Game Elements

Game, Board, Cell/Square

Pieces

King, Queen, Rook, Bishop, Knight, Pawn

Players & Moves

Player, Move, GameStatus

Entity-Responsibility Mapping


📐 Step 3: Class Diagram

Step 4: Implementation

Enums and Constants

Move Class

Piece Base Class

Concrete Piece Classes

Cell Class

Board Class

Player Class

Game Class

Step 5: Usage Example

Key Design Decisions

All pieces share common attributes (color, has_moved) and behaviors (can be captured, belong to a player). Each piece type overrides get_possible_moves() with its unique movement pattern. This is classic polymorphism — and it has a concrete design payoff: when the Board checks for check/checkmate, it iterates over all opponent pieces and calls get_possible_moves() uniformly. Without polymorphism, you would need a separate code path for each piece type, turning the Board into a tangled mess of type-checking. The abstract class also provides the shared _get_moves_in_direction() helper for sliding pieces (Queen, Rook, Bishop), demonstrating the Template Method pattern within the hierarchy.
The Piece class only knows about its own movement rules. The Board class handles higher-level concerns like check detection and move legality. This follows Single Responsibility Principle.
Moves need metadata (captured piece, special moves, notation). The Move class also enables undo functionality and move history - following Command pattern principles.
Store complete board state in Move object or implement inverse operations. For each move type, you’d need to restore: piece positions, has_moved flags, captured pieces, and special move states.

Interview Deep-Dive Questions

Strong answer:
  • The polymorphic approach means each piece subclass owns its own movement logic inside get_possible_moves(). Adding a new piece type (say for Chess960 or a custom variant) is a single new class — you never touch existing piece code. With an enum + switch, every function that cares about piece behavior (canMove, getSymbol, getValue) becomes a sprawling switch-case that grows linearly with each new type.
  • This is the textbook Open/Closed Principle win: the Piece hierarchy is open for extension (new subclasses) but closed for modification (no edits to Board or Game). In practice, I have seen codebases where a PieceType enum approach started simple but became unmaintainable once special-case logic piled up — en passant rules leaking into the board, castling logic duplicated in three places.
  • The tradeoff is that polymorphism introduces a class-per-piece overhead and makes it slightly harder to do cross-piece operations (like “list all movement directions across all types for a threat map”). But for a domain where each piece has genuinely distinct behavior, inheritance wins decisively.
  • Example: The Knight is the clearest proof — its L-shaped, non-blocking movement has zero overlap with sliding pieces. Forcing that into a shared function with Rook logic via if piece_type == KNIGHT is a code smell you can detect from orbit.
Red flag answer: “I would just use an enum because classes are overkill for this” — this signals the candidate has never dealt with a codebase where behavioral branching on type enums became a maintenance nightmare. It also misses the entire polymorphism discussion that the interviewer is testing.Follow-ups:
  1. The Queen’s movement is literally Rook + Bishop combined. How does the _get_moves_in_direction helper method avoid duplicating sliding logic across three classes, and what design pattern does this resemble?
  2. If you needed to add a “custom piece” feature where users define movement rules at runtime (e.g., a piece that moves like a Knight but captures like a Bishop), how would you refactor the hierarchy to support composition over inheritance?
Strong answer:
  • Validation is deliberately split into three layers, each with a single responsibility. Layer 1 — Game level: Game.make_move_by_position() checks that the game is active and the selected piece belongs to the current player. This is authorization, not chess logic. Layer 2 — Board level: Board.get_valid_moves() calls the piece’s get_possible_moves() and then filters out any move that would leave the player’s own king in check via _would_leave_king_in_check(). This is the legality filter. Layer 3 — Piece level: Each piece’s get_possible_moves() generates raw candidate squares based purely on movement geometry — the Rook slides along ranks and files, the Knight jumps in L-shapes, etc. This layer knows nothing about check or game state.
  • The critical subtlety is _would_leave_king_in_check(). For every candidate move, it temporarily executes the move on the board, asks is_king_in_check(), then rolls back. This simulate-then-revert pattern is simple but has O(n) cost per candidate move since is_king_in_check iterates all opponent pieces. For a typical midgame position with ~30 legal moves and ~16 opponent pieces, that is roughly 480 get_possible_moves calls per turn — acceptable for a chess game, but worth noting.
  • The layering also matters for special moves: castling validation lives in the King class but calls back to Board.is_square_attacked() because the king cannot castle through check. This cross-layer dependency is intentional and contained.
  • Example: A pinned Bishop (one that blocks a Rook attack on your King). The Bishop’s get_possible_moves() happily returns diagonal squares. But _would_leave_king_in_check() catches every one of them, correctly returning an empty valid-move list. The piece does not need to know it is pinned — the board figures it out.
Red flag answer: “The piece class validates everything” — this conflates geometric movement rules with game-state legality. A piece cannot know if moving it would expose the king; that requires board-level simulation. Candidates who lump all validation into one place have not thought about separation of concerns.Follow-ups:
  1. The simulate-and-revert approach in _would_leave_king_in_check mutates the board temporarily. What could go wrong in a multithreaded or async context, and how would you make this thread-safe without deep-copying the entire board?
  2. How would you optimize check detection if you needed this engine to evaluate thousands of positions per second for an AI player — what data structures or algorithms would replace the brute-force “iterate all opponent pieces” approach?
Strong answer:
  • Both conditions share the same trigger: the opponent has zero legal moves. The difference is purely whether the opponent’s king is currently in check. _update_game_status() first calls _has_any_valid_moves(opponent_color). If false, it checks is_king_in_check(opponent_color). Check + no moves = checkmate (decisive result). No check + no moves = stalemate (draw). The code correctly handles this as two branches of the same condition, not two separate detection paths.
  • _has_any_valid_moves is an early-exit scan: it iterates every piece of the given color, and for each, calls Board.get_valid_moves(). The moment it finds a single legal move, it returns True. This is important — in the common case (game continues), it exits almost immediately. Worst case (stalemate/checkmate), it must exhaustively check every piece, which involves the full simulate-and-revert pipeline for each candidate move of each piece.
  • Architecturally, the distinction matters because checkmate changes GameStatus to a win, while stalemate changes it to a draw. If you conflated them, you would have a game-breaking bug where a player with an overwhelming advantage accidentally stalemating the opponent counts as a win. In competitive chess, stalemate-as-draw is a fundamental rule that creates deep endgame strategy.
  • Example: King + Queen vs lone King. A naive engine that does not distinguish stalemate might let the dominant side push the opponent’s King into a corner with no legal moves but no check — and incorrectly declare victory instead of a draw.
Red flag answer: “Checkmate is when the king is in check and stalemate is when it is not” without explaining who has no legal moves and how the detection works algorithmically. Reciting the rule definition without demonstrating understanding of the traversal and early-exit logic is surface-level.Follow-ups:
  1. The current _has_any_valid_moves does a full get_valid_moves per piece which includes the expensive simulate-and-revert. Could you short-circuit this further — for instance, if a piece is not near any opponent pieces and is not pinned, can you skip the check simulation?
  2. Chess has other draw conditions: threefold repetition, the 50-move rule, and insufficient material. How would you extend _update_game_status() to handle these without making it a monolithic method?
Strong answer:
  • The Move dataclass is already halfway to a Command object — it stores the piece, origin, destination, and captured piece. To support full undo, each Move must capture everything that changed: the has_moved flag of the piece before the move (critical for castling rights — if you undo a King’s first move, has_moved must revert to False), the has_moved flag of the Rook in a castling move, the last_move reference on the Board (needed for en passant legality on the previous turn), and for pawn promotion, the original Pawn object that was replaced by a Queen.
  • The undo operation reverses each mutation: move the piece back to from_pos, restore the captured piece to to_pos (or to the en passant capture square), restore all has_moved flags, and pop the move from move_history. Redo simply re-executes the stored move. The move_history list acts as a stack; you also maintain a redo_stack that gets moves pushed onto it during undo and cleared whenever a new move is made (because making a new move invalidates the redo branch).
  • The gotcha most candidates miss: en passant eligibility depends on board.last_move. If you undo move N, the board’s last_move must revert to move N-1, not stay at N. This means undo must also restore the board’s last_move pointer — otherwise en passant validation will be wrong on the restored state.
  • Example: White plays e2-e4 (pawn’s first move, has_moved becomes True). Black captures en passant on the next turn. Now undo Black’s en passant: restore Black’s pawn, restore the captured White pawn to e4, and critically, restore board.last_move to the e2-e4 move so that en passant remains legal if Black wants to play a different move.
Red flag answer: “Just deep-copy the entire board before each move” — this works but is lazy and O(n) memory per move where n is board size. It signals the candidate does not understand incremental state capture, which is the entire point of the Command/Memento pattern. In a real engine evaluating millions of positions, deep copies would be catastrophically slow.Follow-ups:
  1. The Memento pattern suggests storing opaque state snapshots. The Command pattern suggests storing operations with explicit execute() and undo() methods. Which is more appropriate here, and what are the tradeoffs in terms of memory, complexity, and debuggability?
  2. If two players are watching the game remotely and one requests “go back 5 moves to review,” but the other player does not want to, how would you architect a “view-only rewind” that does not mutate the actual game state?
Strong answer:
  • Define a GameEventListener interface (or abstract class) with callback methods like on_move_made(move), on_check(color), on_game_over(status), and on_undo(move). The Game class maintains a list of listeners and calls the appropriate method at each event point. A console UI listener would re-render the board on on_move_made. A PGN logger would append notation. An AI engine might recalculate evaluation scores.
  • The key architectural benefit: the Game class’s core logic stays clean — no if has_ui: render() conditionals scattered through the code. Adding a new listener (say, a WebSocket broadcaster for remote spectators) is zero changes to Game. This is the Observer pattern’s core value and why it appears in virtually every event-driven system.
  • In Python specifically, you could implement this with a simple list of callables, a more formal abc.ABC-based listener, or even Python’s built-in signal-like patterns. For this scale, a typed interface is clearest. One subtlety: listeners should not mutate game state inside callbacks. If a listener throws an exception, the game should catch it and continue — a buggy logger should never crash the game.
  • Example: A tournament system needs to broadcast moves to spectators with a 15-second delay. You register a DelayedBroadcastListener that buffers moves in a queue and emits them on a timer. The Game class has no idea this delay exists — it just fires on_move_made and moves on.
Red flag answer: “Just call board.display() after every move in the Game class” — this tightly couples rendering to game logic, makes testing harder (every test prints to console), and makes it impossible to add new consumers without editing Game. It is the procedural approach that Observer exists to replace.Follow-ups:
  1. If a listener like an AI engine needs to process events asynchronously (evaluation takes 2 seconds) but the game should not wait, how would you handle async listeners without blocking the game loop?
  2. Some events are compound — castling is really two piece movements. Should listeners receive one on_castling event or two on_piece_moved events? What are the tradeoffs for each approach in terms of listener complexity?
Strong answer:
  • Precondition 1 — King has not moved (self.has_moved is False): Chess rules say castling is a privilege lost permanently once the King moves — even if it moves back. The has_moved boolean on the Piece base class tracks this. This is why undo must restore has_moved to False.
  • Precondition 2 — The relevant Rook has not moved (rook_cell.piece.has_moved is False): Same logic as the King. Each Rook independently tracks whether it has moved, so you can lose king-side castling rights while retaining queen-side.
  • Precondition 3 — The Rook actually exists at its starting position (isinstance(rook_cell.piece, Rook)): The Rook may have been captured. The code checks both that the cell contains a piece and that it is a Rook.
  • Precondition 4 — All squares between King and Rook are empty: For king-side, columns 5 and 6 must be clear. For queen-side, columns 1, 2, and 3. A single piece in the way blocks castling.
  • Precondition 5 — The King does not pass through or land on an attacked square (is_square_attacked for each square in the path): The King cannot castle out of check, through check, or into check. The code checks columns 4, 5, 6 for king-side (current, transit, destination). Note: the Rook can pass through an attacked square — only the King’s path matters.
  • Example: A common interview gotcha: the King is not in check but column 5 (f1/f8) is attacked by an opponent’s Bishop. King-side castling is illegal even though the destination (g1/g8) is safe. Many implementations miss the “transit square” check.
Red flag answer: “The king and rook have not moved and there are no pieces in between” — this gets only 3 of 5 preconditions and misses the attacked-square check entirely. In a real implementation, this bug would allow castling through check, which is a critical rule violation.Follow-ups:
  1. The code calls is_square_attacked three times for king-side castling (squares e1, f1, g1). Each call iterates all opponent pieces. How would you optimize this if profiling showed castling validation as a bottleneck in an AI search?
  2. In Chess960 (Fischer Random), the King and Rooks start on random squares but castling still exists with modified rules. How would you refactor _can_castle_kingside to support configurable starting positions without hardcoding column indices?
Strong answer:
  • The Pawn has at least four behaviors no other piece has: directional movement (it can only advance forward, never retreat — the direction variable is +1 for White, -1 for Black), asymmetric capture (moves forward but captures diagonally — the only piece where movement and capture directions differ), double-move from start (can advance two squares from its starting row, but only if both the intermediate and destination squares are empty), and en passant (can capture a pawn that just made a double-move, but only on the immediately following turn).
  • En passant is the trickiest to implement because it depends on temporal state — what happened on the previous turn. The code checks board.last_move to see if the last move was a double pawn advance to an adjacent square. This creates a hidden dependency: last_move must be correctly maintained (and correctly restored on undo) or en passant breaks silently.
  • Pawn promotion adds another layer: when a pawn reaches the opposite end (row 0 or 7), it must transform into another piece. The current code defaults to Queen, but a production implementation should allow the player to choose (under-promotion to a Knight is a real tactical choice in competitive chess). The Move object stores is_promotion and promotion_piece to support undo — you need to know to replace the Queen back with the original Pawn.
  • Example: In the code, _can_en_passant checks four conditions: last move exists, last move was a Pawn, last move was a double advance (abs(row diff) == 2), and the pawn that double-advanced is adjacent. Missing any one of these creates a bug — either allowing illegal en passant or blocking legal ones.
Red flag answer: “A pawn just moves forward one square and captures diagonally” — this ignores en passant, promotion, the double-move, and directional asymmetry. It is like saying “a database just stores data” — technically true but misses everything important.Follow-ups:
  1. The current implementation defaults pawn promotion to Queen. How would you design the interface so the Game can prompt the player for their promotion choice, without the Pawn class needing to know anything about UI or input?
  2. En passant has a one-turn window — it must be played immediately after the opponent’s double pawn advance. If you add a “takeback” feature that lets a player undo their last move, does this re-open the en passant window for the previous turn? How would you handle this edge case?
Strong answer:
  • The current design makes this relatively easy because the Player class is already decoupled from input source. You would create a ComputerPlayer subclass (or use a Strategy pattern for move selection) that implements a choose_move(board) method. The Game class’s turn loop would check if the current player is human or computer: human waits for input, computer calls its strategy. The Board.get_valid_moves() API already returns all legal moves for any piece, which is exactly what an AI needs to enumerate its options.
  • For the AI algorithm itself, the classic approach is Minimax with alpha-beta pruning. You need a board evaluation function (material count, piece positioning, king safety) and a search depth. The Board class needs a way to make/unmake moves efficiently for the search tree — and here the current design has a friction point: _would_leave_king_in_check does simulate-and-revert, but a full AI search would need make_move / undo_move pairs that are much faster than the current approach. You would want a dedicated undo_move(move) method on Board that uses the Move object’s stored state to revert incrementally rather than deep-copying.
  • The Observer pattern integration discussed earlier becomes valuable here too: the AI can register as a listener to receive opponent moves and start computing its response during the opponent’s think time (pondering).
  • Example: At the simplest level, a random AI is trivial with the current API: collect all pieces of the AI’s color, call get_valid_moves for each, pick a random valid move, call game.make_move_by_position. Getting from random to Stockfish-level is an algorithm problem, but the architectural interface is already clean.
Red flag answer: “Just add an if-else in the Game loop to call a minimax function” — this conflates the architectural change (abstracting player input) with the algorithm (minimax). A good candidate separates the two concerns: how the game gets a move (interface) and how the AI computes a move (strategy).Follow-ups:
  1. Alpha-beta pruning requires evaluating millions of board states per second. The current is_king_in_check iterates all 16 opponent pieces for every candidate move. What data structures (attack maps, bitboards, Zobrist hashing) would you introduce to make this feasible?
  2. If the AI player needs to run on a separate thread to avoid blocking the UI, how do you synchronize its move submission with the Game’s turn management? What happens if the human player resigns while the AI is mid-computation?

Extension Points

Interview Extensions - Be ready to discuss:
  • AI Player: Minimax algorithm with alpha-beta pruning
  • Time Control: Add clock with increment
  • PGN Export: Standard chess notation format
  • Draw Conditions: 50-move rule, threefold repetition
  • Variants: Chess960, King of the Hill