Design a Chess Game

Topics Covered

Requirements and Use Cases

Core Use Cases

What We Defer

Core Entities

Board and Piece Modeling

Board Representation

The Piece Hierarchy

Implementing Each Piece Type

Why Sliding Logic is Shared

Move Validation and Rules

The Validation Pipeline

The King Safety Check

Why Board.copy() Matters

Separating Validation from Execution

Game State and Special Moves

Castling

En Passant

Pawn Promotion

The Command Pattern for Moves

Game State Machine

Check, Checkmate, and Game End

Detecting Check

Detecting Checkmate

Detecting Stalemate

Updating Game State After Each Move

Additional Draw Conditions

Practice

Chess is the gold standard of OOD interview problems. It exercises polymorphism at its purest: six piece types that share an interface but behave completely differently. Before writing any code, you need to nail down what the system does and what it does not do.

Core Use Cases

The system supports a complete two-player chess game:

  • Start a new game: initialize the board with 32 pieces in standard starting positions. White moves first.
  • Make a move: a player selects a piece and a destination square. The system validates the move and updates the board.
  • Detect check: after every move, determine if the opponent's king is under attack.
  • Detect checkmate and stalemate: if a player has no legal moves, the game ends. Checkmate (king is in check) is a loss. Stalemate (king is not in check) is a draw.
  • Handle special moves: castling, en passant, and pawn promotion follow additional rules beyond basic piece movement.
  • Track move history: record every move for undo functionality and algebraic notation display.
Key Insight

Chess is not about building a chess engine or AI. Interviewers want to see how you model entities, use polymorphism for piece behavior, and handle complex validation rules. Focus on the OOD: clean class hierarchy, single responsibility, and extensibility.

What We Defer

We explicitly exclude: AI opponents, network multiplayer, timers, tournament management, and GUI rendering. These are real features but they expand scope beyond a 45-minute design session. The system is a local two-player game with a programmatic interface.

Core Entities

The key objects fall into natural groups:

  • Game: orchestrates the match. Tracks whose turn it is, manages game state (active, check, checkmate, stalemate, resigned), and coordinates between players and the board.
  • Board: 8x8 grid of squares. Handles piece placement, removal, and lookup by position.
  • Square: a single cell on the board. Holds coordinates (row, column) and optionally a piece.
  • Piece: abstract base class with concrete subtypes: King, Queen, Rook, Bishop, Knight, Pawn. Each implements its own movement logic.
  • Player: represents a human player with a color (white or black) and a collection of captured pieces.
  • Move: encapsulates a move: source square, destination square, piece moved, piece captured (if any), and special move type.
  • MoveHistory: ordered list of moves with undo capability.

The relationships are straightforward: a Game has two Players, one Board, and one MoveHistory. The Board has 64 Squares arranged in an 8x8 grid. Each Square holds at most one Piece. Each Piece belongs to one Player (via color).

The board and piece hierarchy are the structural foundation of the system. Get this right and everything else, move validation, check detection, special moves, falls into place naturally.

Board Representation

The Board is an 8x8 grid of Square objects. Each Square stores its row (0-7) and column (0-7) and holds an optional reference to a Piece.

python
1class Board:
2    def __init__(self):
3        self.grid = [[Square(r, c) for c in range(8)] for r in range(8)]
4
5    def get_square(self, row: int, col: int) -> Square:
6        return self.grid[row][col]
7
8    def get_piece(self, row: int, col: int) -> Piece | None:
9        return self.grid[row][col].piece

The Board does not know chess rules. It provides spatial operations: place a piece, remove a piece, get the piece at a position, check if a position is within bounds. The Board is the data structure. The Game and MoveValidator are the algorithms.

Piece polymorphism showing different valid moves for each piece type

The Piece Hierarchy

Every piece in chess shares three properties: a color (white or black), a position (row, column), and a method to compute its valid moves. But each piece type computes valid moves differently. This is textbook polymorphism.

python
1from abc import ABC, abstractmethod
2
3class Piece(ABC):
4    def __init__(self, color: str, row: int, col: int):
5        self.color = color
6        self.row = row
7        self.col = col
8        self.has_moved = False
9
10    @abstractmethod
11    def get_valid_moves(self, board: 'Board') -> list[tuple[int, int]]:
12        pass
13
14    def is_opponent(self, other: 'Piece') -> bool:
15        return other is not None and other.color != self.color

The has_moved flag is critical for three rules: castling (king and rook must not have moved), pawn double advance (only from starting position), and en passant detection.

Implementing Each Piece Type

Rook: moves in straight lines along rows and columns. Slides until hitting the board edge, a friendly piece (blocked), or an opponent piece (can capture, then stop).

python
1class Rook(Piece):
2    def get_valid_moves(self, board):
3        moves = []
4        directions = [(0, 1), (0, -1), (1, 0), (-1, 0)]
5        for dr, dc in directions:
6            r, c = self.row + dr, self.col + dc
7            while 0 <= r < 8 and 0 <= c < 8:
8                target = board.get_piece(r, c)
9                if target is None:
10                    moves.append((r, c))
11                elif self.is_opponent(target):
12                    moves.append((r, c))
13                    break
14                else:
15                    break
16                r, c = r + dr, c + dc
17        return moves

Bishop: identical sliding logic but with diagonal directions: (1,1), (1,-1), (-1,1), (-1,-1).

Queen: combines Rook and Bishop directions. Eight directions total. This is why the Queen is the most powerful piece: it has the most movement freedom.

Knight: the only piece that jumps. It moves in an L-shape: two squares in one direction, one square perpendicular. It ignores all pieces in between.

python
1class Knight(Piece):
2    def get_valid_moves(self, board):
3        moves = []
4        offsets = [(-2,-1),(-2,1),(-1,-2),(-1,2),
5                   (1,-2),(1,2),(2,-1),(2,1)]
6        for dr, dc in offsets:
7            r, c = self.row + dr, self.col + dc
8            if 0 <= r < 8 and 0 <= c < 8:
9                target = board.get_piece(r, c)
10                if target is None or self.is_opponent(target):
11                    moves.append((r, c))
12        return moves

King: moves one square in any direction (8 possibilities). Additionally handles castling (covered in the special moves section).

Pawn: the most complex piece despite being the weakest. It moves forward one square, optionally two from starting position, captures diagonally, and promotes upon reaching the last rank. Direction depends on color (white moves up, black moves down).

Interview Tip

In interviews, implement Rook or Knight first to demonstrate the pattern, then say 'Bishop and Queen follow the same sliding logic with different direction vectors.' Do not waste time implementing all six pieces in full: show the pattern and move on to the interesting parts like check detection.

Why Sliding Logic is Shared

Rook, Bishop, and Queen all use the same algorithm: pick a direction, advance one square at a time, stop when hitting a boundary or piece. The only difference is the direction vectors. You can extract this into a helper method on Piece:

python
1def get_sliding_moves(self, board, directions):
2    moves = []
3    for dr, dc in directions:
4        r, c = self.row + dr, self.col + dc
5        while 0 <= r < 8 and 0 <= c < 8:
6            target = board.get_piece(r, c)
7            if target is None:
8                moves.append((r, c))
9            elif self.is_opponent(target):
10                moves.append((r, c))
11                break
12            else:
13                break
14            r, c = r + dr, c + dc
15    return moves

Then Rook calls self.get_sliding_moves(board, [(0,1),(0,-1),(1,0),(-1,0)]) and Bishop calls self.get_sliding_moves(board, [(1,1),(1,-1),(-1,1),(-1,-1)]). Queen calls it with all eight directions. This eliminates code duplication while keeping each piece class focused on its own unique behavior.

A move is not valid just because a piece can physically reach a destination. Chess has layered validation rules, and your design must enforce all of them cleanly.

The Validation Pipeline

Every move attempt passes through four checks, in order:

  1. Piece ownership: the piece belongs to the current player (correct color and it is their turn).
  2. Piece reachability: the destination is in the piece's get_valid_moves() result.
  3. Path clearance: for sliding pieces (Rook, Bishop, Queen), no friendly or opponent piece blocks the path. The Knight skips this check entirely because it jumps.
  4. King safety: after making the move on a temporary board, the player's own king must not be in check. This is the most subtle rule: you cannot make a move that exposes your own king to capture.
Move validation pipeline checking reachability, path clearance, and king safety

Steps 2 and 3 are already handled inside each piece's get_valid_moves(): the sliding logic only returns squares the piece can actually reach. Step 4 is the one that requires additional work.

The King Safety Check

This is the single most important validation rule in chess. A player can never leave their own king in check. This means:

  • You cannot move your king into an attacked square.
  • You cannot move a piece that is currently blocking an attack on your king (a "pinned" piece).
  • If your king is in check, your move must resolve the check (move the king, block the attack, or capture the attacker).

The implementation simulates the move on a copy of the board, then checks whether any opponent piece can reach the king's square:

python
1class MoveValidator:
2    @staticmethod
3    def is_valid_move(board, move, current_color):
4        piece = board.get_piece(move.src_row, move.src_col)
5        if piece is None or piece.color != current_color:
6            return False
7
8        valid_squares = piece.get_valid_moves(board)
9        if (move.dst_row, move.dst_col) not in valid_squares:
10            return False
11
12        # Simulate the move and check king safety
13        temp_board = board.copy()
14        temp_board.apply_move(move)
15        return not MoveValidator.is_in_check(temp_board, current_color)
16
17    @staticmethod
18    def is_in_check(board, color):
19        king_pos = board.find_king(color)
20        opponent_color = 'black' if color == 'white' else 'white'
21        for piece in board.get_pieces(opponent_color):
22            if king_pos in piece.get_valid_moves(board):
23                return True
24        return False

Why Board.copy() Matters

The king safety check must simulate the move without modifying the actual board. If you mutate the real board to test king safety, you have to undo the mutation afterward: which introduces bugs if the undo logic has errors. A defensive copy is safer: copy the board, apply the move on the copy, check if the king is safe, discard the copy.

The cost is copying 64 squares and up to 32 piece references per validation. This is negligible for a turn-based game: even a brute-force AI evaluating thousands of moves per second can afford shallow copies.

Common Pitfall

The most common bug in chess implementations is forgetting the king safety check. A move that looks valid for the piece may expose the king to capture. Every single move, including captures and special moves, must pass the king safety simulation before being accepted.

Separating Validation from Execution

The MoveValidator is a stateless utility. It does not modify the board. The Game class calls the validator before executing any move:

python
1class Game:
2    def make_move(self, src_row, src_col, dst_row, dst_col):
3        move = Move(src_row, src_col, dst_row, dst_col)
4        if not MoveValidator.is_valid_move(self.board, move, self.current_turn):
5            return False
6        self.board.apply_move(move)
7        self.history.record(move)
8        self.switch_turn()
9        self.update_game_state()
10        return True

This separation means you can unit test validation independently of game state. You can also reuse the validator for move suggestion features, puzzle solving, or AI move generation.

Chess has three special moves that break the normal movement rules. Each one adds complexity to your design, and interviewers love asking about them because they test how well your class hierarchy handles exceptions to the rules.

Castling

Castling is the only move where two pieces move simultaneously. The king moves two squares toward a rook, and the rook jumps to the other side of the king.

Castling move showing king and rook positions before and after

Preconditions (all must be true):

  • The King has never moved (has_moved == False)
  • The Rook has never moved (has_moved == False)
  • No pieces between the King and Rook
  • The King is not currently in check
  • The King does not pass through any square that is under attack
  • The King does not land on a square that is under attack

This is where the has_moved flag on Piece pays off. Without it, you would need to scan the entire move history to determine if the King or Rook has moved: an O(n) operation for every castling check.

En Passant

En passant is a pawn capture that can only occur on the move immediately after an opponent's pawn advances two squares. Your pawn captures the opponent's pawn "in passing" by moving diagonally to the square the opponent's pawn skipped.

Preconditions:

  • Your pawn is on the 5th rank (row 3 for white, row 4 for black)
  • An adjacent opponent pawn just advanced two squares (on the immediately preceding move)
  • Your pawn captures by moving diagonally to the square behind the opponent's pawn

The "just advanced" condition requires access to the last move in the MoveHistory. This is why MoveHistory is a first-class object: en passant validation needs it.

Pawn Promotion

When a pawn reaches the opposite end of the board (8th rank for white, 1st rank for black), it must be promoted to another piece: usually a Queen, but sometimes a Knight, Rook, or Bishop (underpromotion).

The Move class needs a promotion_piece field to specify what the pawn becomes. The Board removes the pawn and places the promoted piece in the same square.

The Command Pattern for Moves

Special moves demonstrate why the Command pattern is essential. Each Move object encapsulates everything needed to execute and undo the action:

python
1class Move:
2    def __init__(self, src_row, src_col, dst_row, dst_col,
3                 captured_piece=None, is_castling=False,
4                 is_en_passant=False, promotion_piece=None):
5        self.src_row = src_row
6        self.src_col = src_col
7        self.dst_row = dst_row
8        self.dst_col = dst_col
9        self.captured_piece = captured_piece
10        self.is_castling = is_castling
11        self.is_en_passant = is_en_passant
12        self.promotion_piece = promotion_piece

For undo, the Move stores the captured piece so it can be restored, the castling flag so the rook can be moved back, and the promotion piece so the pawn can be re-created. Without this information, undo is impossible without replaying the entire game from the start.

Game State Machine

The Game tracks its state through a simple state machine:

  • ACTIVE: the game is in progress, players alternate turns.
  • CHECK: the current player's king is in check. They must resolve it on their next move.
  • CHECKMATE: the current player is in check and has no legal moves. The opponent wins.
  • STALEMATE: the current player is not in check but has no legal moves. The game is a draw.
  • RESIGNED: a player has resigned. The opponent wins.

After every move, the Game updates its state by checking if the opponent is now in check and whether they have any legal moves.

The endgame logic is where your design comes together. Check and checkmate detection uses every component you have built: piece movement, board queries, move validation, and the king safety simulation.

Detecting Check

A king is in check if any opponent piece includes the king's square in its valid moves. The implementation iterates all opponent pieces and calls get_valid_moves() on each:

python
1def is_in_check(board, color):
2    king_pos = board.find_king(color)
3    opponent = 'black' if color == 'white' else 'white'
4    for piece in board.get_pieces(opponent):
5        if king_pos in piece.get_valid_moves(board):
6            return True
7    return False

This is an O(p * m) operation where p is the number of opponent pieces (up to 16) and m is the average number of valid moves per piece. For a typical mid-game position, this scans roughly 100-200 squares: trivial for modern hardware.

Checkmate detection evaluating all escape moves and finding none

Detecting Checkmate

Checkmate occurs when a player is in check AND has no legal move that escapes the check. There are exactly three ways to escape check:

  1. Move the King to a square that is not under attack.
  2. Block the attack by placing a piece between the attacker and the King (only works against sliding pieces: not Knights or Pawns).
  3. Capture the attacker with any of your pieces.

Rather than implementing these three cases separately, use a brute-force approach that is both simpler and more correct: try every legal move for every piece of the checked player. If any move results in the king no longer being in check, it is not checkmate.

python
1def is_checkmate(board, color):
2    if not is_in_check(board, color):
3        return False
4    return not has_any_legal_move(board, color)
5
6def has_any_legal_move(board, color):
7    for piece in board.get_pieces(color):
8        for move in piece.get_valid_moves(board):
9            temp = board.copy()
10            temp.apply_move(Move(piece.row, piece.col, move[0], move[1]))
11            if not is_in_check(temp, color):
12                return True
13    return False

Detecting Stalemate

Stalemate is identical to checkmate detection except the king is NOT in check. The player has no legal moves but is not under attack. This is a draw, not a loss.

python
1def is_stalemate(board, color):
2    if is_in_check(board, color):
3        return False
4    return not has_any_legal_move(board, color)

Notice that has_any_legal_move() is shared between checkmate and stalemate detection. The only difference is the initial check condition. This is a sign of good decomposition: the complex logic (enumerating all legal moves) is written once and reused.

Updating Game State After Each Move

After every move, the Game checks the opponent's situation:

python
1def update_game_state(self):
2    opponent = 'black' if self.current_turn == 'white' else 'white'
3    if is_checkmate(self.board, opponent):
4        self.state = GameState.CHECKMATE
5    elif is_stalemate(self.board, opponent):
6        self.state = GameState.STALEMATE
7    elif is_in_check(self.board, opponent):
8        self.state = GameState.CHECK
9    else:
10        self.state = GameState.ACTIVE

The order matters: checkmate is checked before stalemate because checkmate requires the king to be in check. If you check stalemate first, a checkmate position (no legal moves + king in check) would incorrectly be classified as stalemate.

Common Pitfall

A common bug is checking stalemate before checkmate. Both conditions share 'no legal moves available,' but checkmate additionally requires the king to be in check. Always check checkmate first, then stalemate. Getting this order wrong turns losses into draws.

Additional Draw Conditions

Beyond stalemate, chess recognizes several other draw conditions that a complete implementation should handle:

  • Threefold repetition: the same board position occurs three times (requires position hashing in MoveHistory).
  • Fifty-move rule: 50 consecutive moves by each player with no capture and no pawn move.
  • Insufficient material: neither player has enough pieces to checkmate (e.g., King vs King, King+Bishop vs King).

These are tracked through MoveHistory and piece counting: further justifying why MoveHistory is a rich object rather than a simple list of strings.

Practice

Apply everything you have learned about chess OOD. These problems test piece polymorphism, move validation, and game state detection.

Loading problem...

Loading editor...

Loading problem...

Loading editor...

Loading problem...

Loading editor...