Low-Level Design: Chess Game (OOP Interview)

Low-Level Design: Chess Game

Designing a chess game is a classic LLD interview question that tests your ability to model complex rules, state machines, and OOP principles. This guide walks through a complete object-oriented design.

Requirements

  • Support standard chess rules: piece movement, check, checkmate, stalemate
  • Two-player turn-based gameplay
  • Track game history (move log)
  • Detect special moves: castling, en passant, pawn promotion
  • Support resign and draw offers

Core Classes

Piece Hierarchy

from abc import ABC, abstractmethod
from enum import Enum

class Color(Enum):
    WHITE = "white"
    BLACK = "black"

class PieceType(Enum):
    KING = "king"
    QUEEN = "queen"
    ROOK = "rook"
    BISHOP = "bishop"
    KNIGHT = "knight"
    PAWN = "pawn"

class Piece(ABC):
    def __init__(self, color: Color, piece_type: PieceType):
        self.color = color
        self.piece_type = piece_type
        self.has_moved = False

    @abstractmethod
    def get_valid_moves(self, board: 'Board', position: 'Position') -> list['Position']:
        pass

    def __repr__(self):
        return f"{self.color.value[0].upper()}{self.piece_type.value[0].upper()}"

class King(Piece):
    def __init__(self, color: Color):
        super().__init__(color, PieceType.KING)

    def get_valid_moves(self, board, position):
        moves = []
        row, col = position.row, position.col
        directions = [(-1,-1),(-1,0),(-1,1),(0,-1),(0,1),(1,-1),(1,0),(1,1)]
        for dr, dc in directions:
            new_pos = Position(row + dr, col + dc)
            if new_pos.is_valid() and not board.has_friendly_piece(new_pos, self.color):
                moves.append(new_pos)
        # Castling
        if not self.has_moved:
            moves.extend(self._get_castling_moves(board, position))
        return moves

    def _get_castling_moves(self, board, position):
        castling = []
        row = position.row
        # Kingside
        rook_pos = Position(row, 7)
        rook = board.get_piece(rook_pos)
        if (rook and rook.piece_type == PieceType.ROOK and not rook.has_moved
                and board.is_path_clear(position, rook_pos)):
            castling.append(Position(row, 6))
        # Queenside
        rook_pos = Position(row, 0)
        rook = board.get_piece(rook_pos)
        if (rook and rook.piece_type == PieceType.ROOK and not rook.has_moved
                and board.is_path_clear(position, rook_pos)):
            castling.append(Position(row, 2))
        return castling

class Queen(Piece):
    def __init__(self, color: Color):
        super().__init__(color, PieceType.QUEEN)

    def get_valid_moves(self, board, position):
        # Queen = Rook + Bishop combined
        rook = Rook(self.color)
        bishop = Bishop(self.color)
        return (rook.get_valid_moves(board, position) +
                bishop.get_valid_moves(board, position))

class Rook(Piece):
    def __init__(self, color: Color):
        super().__init__(color, PieceType.ROOK)

    def get_valid_moves(self, board, position):
        return board.get_sliding_moves(position, self.color,
            [(0,1),(0,-1),(1,0),(-1,0)])

class Bishop(Piece):
    def __init__(self, color: Color):
        super().__init__(color, PieceType.BISHOP)

    def get_valid_moves(self, board, position):
        return board.get_sliding_moves(position, self.color,
            [(1,1),(1,-1),(-1,1),(-1,-1)])

class Knight(Piece):
    def __init__(self, color: Color):
        super().__init__(color, PieceType.KNIGHT)

    def get_valid_moves(self, board, position):
        moves = []
        row, col = position.row, position.col
        jumps = [(-2,-1),(-2,1),(-1,-2),(-1,2),(1,-2),(1,2),(2,-1),(2,1)]
        for dr, dc in jumps:
            new_pos = Position(row + dr, col + dc)
            if new_pos.is_valid() and not board.has_friendly_piece(new_pos, self.color):
                moves.append(new_pos)
        return moves

class Pawn(Piece):
    def __init__(self, color: Color):
        super().__init__(color, PieceType.PAWN)

    def get_valid_moves(self, board, position):
        moves = []
        row, col = position.row, position.col
        direction = 1 if self.color == Color.WHITE else -1
        start_row = 1 if self.color == Color.WHITE else 6

        # Forward move
        one_forward = Position(row + direction, col)
        if one_forward.is_valid() and not board.get_piece(one_forward):
            moves.append(one_forward)
            # Double move from start
            if row == start_row:
                two_forward = Position(row + 2 * direction, col)
                if not board.get_piece(two_forward):
                    moves.append(two_forward)

        # Diagonal captures
        for dc in [-1, 1]:
            diag = Position(row + direction, col + dc)
            if diag.is_valid():
                if board.has_enemy_piece(diag, self.color):
                    moves.append(diag)
                # En passant
                elif board.en_passant_target == diag:
                    moves.append(diag)

        return moves

Board and Position

class Position:
    def __init__(self, row: int, col: int):
        self.row = row
        self.col = col

    def is_valid(self):
        return 0 <= self.row < 8 and 0 <= self.col < 8

    def __eq__(self, other):
        return self.row == other.row and self.col == other.col

    def __hash__(self):
        return hash((self.row, self.col))

    def to_algebraic(self):
        return chr(ord('a') + self.col) + str(self.row + 1)

class Board:
    def __init__(self):
        self.grid = [[None] * 8 for _ in range(8)]
        self.en_passant_target = None
        self._setup_pieces()

    def _setup_pieces(self):
        # White pieces
        back_row = [Rook, Knight, Bishop, Queen, King, Bishop, Knight, Rook]
        for col, piece_cls in enumerate(back_row):
            self.grid[0][col] = piece_cls(Color.WHITE)
            self.grid[7][col] = piece_cls(Color.BLACK)
        for col in range(8):
            self.grid[1][col] = Pawn(Color.WHITE)
            self.grid[6][col] = Pawn(Color.BLACK)

    def get_piece(self, pos: Position):
        return self.grid[pos.row][pos.col]

    def set_piece(self, pos: Position, piece):
        self.grid[pos.row][pos.col] = piece

    def has_friendly_piece(self, pos: Position, color: Color):
        piece = self.get_piece(pos)
        return piece is not None and piece.color == color

    def has_enemy_piece(self, pos: Position, color: Color):
        piece = self.get_piece(pos)
        return piece is not None and piece.color != color

    def get_sliding_moves(self, position, color, directions):
        moves = []
        for dr, dc in directions:
            row, col = position.row + dr, position.col + dc
            while 0 <= row < 8 and 0 <= col  from_pos.row else -1)
        dc = 0 if to_pos.col == from_pos.col else (1 if to_pos.col > from_pos.col else -1)
        r, c = from_pos.row + dr, from_pos.col + dc
        while (r, c) != (to_pos.row, to_pos.col):
            if self.grid[r][c]:
                return False
            r += dr
            c += dc
        return True

    def find_king(self, color: Color) -> Position:
        for row in range(8):
            for col in range(8):
                piece = self.grid[row][col]
                if piece and piece.piece_type == PieceType.KING and piece.color == color:
                    return Position(row, col)
        return None

    def is_in_check(self, color: Color) -> bool:
        king_pos = self.find_king(color)
        enemy_color = Color.BLACK if color == Color.WHITE else Color.WHITE
        for row in range(8):
            for col in range(8):
                piece = self.grid[row][col]
                if piece and piece.color == enemy_color:
                    if king_pos in piece.get_valid_moves(self, Position(row, col)):
                        return True
        return False

Game Controller

from dataclasses import dataclass

@dataclass
class Move:
    from_pos: Position
    to_pos: Position
    piece: Piece
    captured: Piece = None
    promotion: PieceType = None
    is_castling: bool = False
    is_en_passant: bool = False

class GameStatus(Enum):
    ACTIVE = "active"
    WHITE_WINS = "white_wins"
    BLACK_WINS = "black_wins"
    DRAW = "draw"
    STALEMATE = "stalemate"

class ChessGame:
    def __init__(self):
        self.board = Board()
        self.current_turn = Color.WHITE
        self.move_history: list[Move] = []
        self.status = GameStatus.ACTIVE

    def make_move(self, from_pos: Position, to_pos: Position,
                  promotion: PieceType = None) -> bool:
        piece = self.board.get_piece(from_pos)
        if not piece or piece.color != self.current_turn:
            return False

        valid_moves = self._get_legal_moves(from_pos)
        if to_pos not in valid_moves:
            return False

        move = self._execute_move(from_pos, to_pos, promotion)
        self.move_history.append(move)
        self._switch_turn()
        self._update_status()
        return True

    def _get_legal_moves(self, position: Position) -> list[Position]:
        """Filter moves that would leave own king in check."""
        piece = self.board.get_piece(position)
        pseudo_legal = piece.get_valid_moves(self.board, position)
        legal = []
        for target in pseudo_legal:
            if self._is_legal_move(position, target, piece):
                legal.append(target)
        return legal

    def _is_legal_move(self, from_pos, to_pos, piece):
        # Simulate move and check if king is safe
        captured = self.board.get_piece(to_pos)
        self.board.set_piece(to_pos, piece)
        self.board.set_piece(from_pos, None)
        in_check = self.board.is_in_check(piece.color)
        # Undo
        self.board.set_piece(from_pos, piece)
        self.board.set_piece(to_pos, captured)
        return not in_check

    def _execute_move(self, from_pos, to_pos, promotion):
        piece = self.board.get_piece(from_pos)
        captured = self.board.get_piece(to_pos)
        move = Move(from_pos, to_pos, piece, captured)

        # Handle castling
        if piece.piece_type == PieceType.KING and abs(to_pos.col - from_pos.col) == 2:
            move.is_castling = True
            rook_col = 7 if to_pos.col == 6 else 0
            new_rook_col = 5 if to_pos.col == 6 else 3
            rook = self.board.get_piece(Position(from_pos.row, rook_col))
            self.board.set_piece(Position(from_pos.row, new_rook_col), rook)
            self.board.set_piece(Position(from_pos.row, rook_col), None)
            rook.has_moved = True

        # Handle en passant
        if (piece.piece_type == PieceType.PAWN and
                self.board.en_passant_target == to_pos):
            move.is_en_passant = True
            ep_row = from_pos.row
            captured = self.board.get_piece(Position(ep_row, to_pos.col))
            self.board.set_piece(Position(ep_row, to_pos.col), None)
            move.captured = captured

        # Set en passant target for double pawn push
        self.board.en_passant_target = None
        if piece.piece_type == PieceType.PAWN and abs(to_pos.row - from_pos.row) == 2:
            ep_row = (from_pos.row + to_pos.row) // 2
            self.board.en_passant_target = Position(ep_row, from_pos.col)

        # Move piece
        self.board.set_piece(to_pos, piece)
        self.board.set_piece(from_pos, None)
        piece.has_moved = True

        # Pawn promotion
        if (piece.piece_type == PieceType.PAWN and
                (to_pos.row == 0 or to_pos.row == 7)):
            promo_type = promotion or PieceType.QUEEN
            promo_map = {PieceType.QUEEN: Queen, PieceType.ROOK: Rook,
                        PieceType.BISHOP: Bishop, PieceType.KNIGHT: Knight}
            self.board.set_piece(to_pos, promo_map[promo_type](piece.color))
            move.promotion = promo_type

        return move

    def _switch_turn(self):
        self.current_turn = (Color.BLACK if self.current_turn == Color.WHITE
                            else Color.WHITE)

    def _update_status(self):
        color = self.current_turn
        has_legal_moves = any(
            self._get_legal_moves(Position(r, c))
            for r in range(8) for c in range(8)
            if self.board.grid[r][c] and self.board.grid[r][c].color == color
        )
        if not has_legal_moves:
            if self.board.is_in_check(color):
                self.status = (GameStatus.WHITE_WINS if color == Color.BLACK
                             else GameStatus.BLACK_WINS)
            else:
                self.status = GameStatus.STALEMATE

    def get_move_notation(self, move: Move) -> str:
        """Generate algebraic notation for a move."""
        if move.is_castling:
            return "O-O" if move.to_pos.col == 6 else "O-O-O"
        piece_symbol = "" if move.piece.piece_type == PieceType.PAWN else move.piece.piece_type.value[0].upper()
        capture = "x" if move.captured else ""
        dest = move.to_pos.to_algebraic()
        promo = "=" + move.promotion.value[0].upper() if move.promotion else ""
        return f"{piece_symbol}{capture}{dest}{promo}"

Key Design Decisions

  • Abstract Piece class: Each piece type overrides get_valid_moves() — Open/Closed principle
  • Pseudo-legal + legality filter: Pieces generate candidate moves; Game filters those that expose the king to check. Separates concerns cleanly.
  • Board as service object: Board provides helper methods (is_in_check, get_sliding_moves) rather than pieces querying the board directly
  • Move record: Dataclass captures full move context for history, undo, and notation generation
  • State machine: GameStatus enum models game lifecycle transitions cleanly

Interview Follow-Up Discussion Points

  • Fifty-move rule: Track half-move clock in Move; draw if no capture or pawn move in 50 moves
  • Threefold repetition: Hash board state (piece positions + castling rights + en passant target); draw if same state occurs 3 times
  • Undo/redo: Stack of Move objects; reverse each move’s effects including castling rook movement
  • AI opponent: Minimax with alpha-beta pruning; evaluation function weights material, center control, piece activity
  • Multiplayer online: WebSocket for real-time move broadcast; server validates moves server-side to prevent cheating
  • Persistence: PGN (Portable Game Notation) format for storing game history; FEN for board state snapshots

Complexity Analysis

  • Move generation per piece: O(1) to O(n) where n = board dimension (sliding pieces)
  • Legal move filtering: O(p × m) where p = pieces, m = moves per piece — requires board simulation
  • Checkmate detection: O(p × m) — must verify no legal moves exist
  • Full game tree (minimax depth d): O(b^d) where b ≈ 35 average branching factor

🏢 Asked at: Meta Interview Guide 2026: Facebook, Instagram, WhatsApp Engineering

🏢 Asked at: Apple Interview Guide 2026: iOS Systems, Hardware-Software Integration, and iCloud Architecture

🏢 Asked at: Atlassian Interview Guide

🏢 Asked at: Snap Interview Guide

🏢 Asked at: LinkedIn Interview Guide 2026: Social Graph Engineering, Feed Ranking, and Professional Network Scale

🏢 Asked at: Uber Interview Guide 2026: Dispatch Systems, Geospatial Algorithms, and Marketplace Engineering

🏢 Asked at: Shopify Interview Guide

Scroll to Top