Skip to main content

Documentation Index

Fetch the complete documentation index at: https://resources.devweekends.com/llms.txt

Use this file to discover all available pages before exploring further.

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

CategoryQuestionTypical Scope
PlayersHuman vs Human? Human vs AI?Usually both players human for LLD
RulesWhich special moves to include?Castling, en passant often in scope
FeaturesUndo/redo support?Often asked as extension
UIGUI or console-based?Console for LLD interviews
TimerChess clock functionality?Usually out of scope
VariantsFischer random? 3-check?Out of scope

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

EntityResponsibilitiesPattern Used
Piece (abstract)Define movement interfaceTemplate Method
King, Queen, etc.Implement specific movementsPolymorphism
BoardTrack piece positions, validate moves-
GameManage turns, check game statusState Pattern (optional)
MoveEncapsulate move action for undo/redoCommand Pattern
MoveHistoryStore moves for undoMemento Pattern

📐 Step 3: Class Diagram

┌─────────────────────────────────────────────────────────────┐
│                          Game                               │
├─────────────────────────────────────────────────────────────┤
│ - board: Board                                              │
│ - players: List[Player]                                     │
│ - currentTurn: Player                                       │
│ - status: GameStatus                                        │
│ - moveHistory: List[Move]                                   │
├─────────────────────────────────────────────────────────────┤
│ + start(): void                                             │
│ + makeMove(from, to): bool                                  │
│ + undo(): bool                                              │
│ + getStatus(): GameStatus                                   │
└─────────────────────────────────────────────────────────────┘

                              │ 1

┌─────────────────────────────────────────────────────────────┐
│                          Board                              │
├─────────────────────────────────────────────────────────────┤
│ - cells: Cell[8][8]                                         │
│ - capturedPieces: List[Piece]                              │
├─────────────────────────────────────────────────────────────┤
│ + getCell(row, col): Cell                                  │
│ + movePiece(from, to): bool                                │
│ + isValidMove(piece, from, to): bool                       │
│ + isKingInCheck(color): bool                               │
└─────────────────────────────────────────────────────────────┘

                              │ 64

┌─────────────────────────────────────────────────────────────┐
│                          Cell                               │
├─────────────────────────────────────────────────────────────┤
│ - row: int                                                  │
│ - col: int                                                  │
│ - piece: Piece                                              │
├─────────────────────────────────────────────────────────────┤
│ + isEmpty(): bool                                           │
│ + getPiece(): Piece                                         │
│ + setPiece(piece): void                                     │
└─────────────────────────────────────────────────────────────┘

                     ┌─────────────────┐
                     │    <<abstract>> │
                     │      Piece      │
                     ├─────────────────┤
                     │ - color: Color  │
                     │ - hasMoved: bool│
                     ├─────────────────┤
                     │ + canMove()     │
                     │ + getValidMoves()│
                     └─────────────────┘

          ┌────────┬────────┼────────┬────────┬────────┐
          │        │        │        │        │        │
       ┌──┴──┐ ┌───┴──┐ ┌───┴──┐ ┌───┴──┐ ┌───┴──┐ ┌───┴──┐
       │King │ │Queen │ │ Rook │ │Bishop│ │Knight│ │ Pawn │
       └─────┘ └──────┘ └──────┘ └──────┘ └──────┘ └──────┘

Step 4: Implementation

Enums and Constants

from enum import Enum
from typing import Optional, List, Tuple
from abc import ABC, abstractmethod
from dataclasses import dataclass
import copy

class Color(Enum):
    WHITE = "white"
    BLACK = "black"
    
    def opposite(self):
        return Color.BLACK if self == Color.WHITE else Color.WHITE

class GameStatus(Enum):
    ACTIVE = "active"
    WHITE_WIN = "white_wins"
    BLACK_WIN = "black_wins"
    STALEMATE = "stalemate"
    RESIGNED = "resigned"

@dataclass
class Position:
    row: int
    col: int
    
    def __hash__(self):
        return hash((self.row, self.col))
    
    def is_valid(self) -> bool:
        return 0 <= self.row < 8 and 0 <= self.col < 8
    
    def __add__(self, other):
        return Position(self.row + other.row, self.col + other.col)

Move Class

@dataclass
class Move:
    piece: 'Piece'
    from_pos: Position
    to_pos: Position
    captured_piece: Optional['Piece'] = None
    is_castling: bool = False
    is_en_passant: bool = False
    is_promotion: bool = False
    promotion_piece: Optional['Piece'] = None
    
    def __str__(self):
        piece_symbol = self.piece.__class__.__name__[0]
        if isinstance(self.piece, Knight):
            piece_symbol = 'N'
        if isinstance(self.piece, Pawn):
            piece_symbol = ''
        
        capture = 'x' if self.captured_piece else ''
        to_square = chr(ord('a') + self.to_pos.col) + str(self.to_pos.row + 1)
        
        return f"{piece_symbol}{capture}{to_square}"

Piece Base Class

class Piece(ABC):
    def __init__(self, color: Color):
        self.color = color
        self.has_moved = False
    
    @abstractmethod
    def get_possible_moves(self, position: Position, board: 'Board') -> List[Position]:
        """Get all possible moves (may include moves that leave king in check)"""
        pass
    
    @abstractmethod
    def symbol(self) -> str:
        """Return piece symbol for display"""
        pass
    
    def _get_moves_in_direction(
        self, 
        position: Position, 
        board: 'Board',
        direction: Tuple[int, int],
        max_steps: int = 8
    ) -> List[Position]:
        """Helper for sliding pieces (Rook, Bishop, Queen)"""
        moves = []
        current = position
        
        for _ in range(max_steps):
            current = Position(current.row + direction[0], current.col + direction[1])
            
            if not current.is_valid():
                break
            
            cell = board.get_cell(current)
            if cell.piece is None:
                moves.append(current)
            elif cell.piece.color != self.color:
                moves.append(current)  # Can capture
                break
            else:
                break  # Own piece blocks
        
        return moves

Concrete Piece Classes

class King(Piece):
    def symbol(self) -> str:
        return '♔' if self.color == Color.WHITE else '♚'
    
    def get_possible_moves(self, position: Position, board: 'Board') -> List[Position]:
        moves = []
        directions = [
            (-1, -1), (-1, 0), (-1, 1),
            (0, -1),           (0, 1),
            (1, -1),  (1, 0),  (1, 1)
        ]
        
        for d in directions:
            new_pos = Position(position.row + d[0], position.col + d[1])
            if new_pos.is_valid():
                cell = board.get_cell(new_pos)
                if cell.piece is None or cell.piece.color != self.color:
                    moves.append(new_pos)
        
        # Castling
        if not self.has_moved:
            moves.extend(self._get_castling_moves(position, board))
        
        return moves
    
    def _get_castling_moves(self, position: Position, board: 'Board') -> List[Position]:
        moves = []
        row = position.row
        
        # King-side castling
        if self._can_castle_kingside(row, board):
            moves.append(Position(row, 6))
        
        # Queen-side castling
        if self._can_castle_queenside(row, board):
            moves.append(Position(row, 2))
        
        return moves
    
    def _can_castle_kingside(self, row: int, board: 'Board') -> bool:
        rook_cell = board.get_cell(Position(row, 7))
        if not rook_cell.piece or not isinstance(rook_cell.piece, Rook):
            return False
        if rook_cell.piece.has_moved:
            return False
        
        # Check path is clear
        for col in [5, 6]:
            if board.get_cell(Position(row, col)).piece is not None:
                return False
        
        # Check king doesn't pass through check
        for col in [4, 5, 6]:
            if board.is_square_attacked(Position(row, col), self.color.opposite()):
                return False
        
        return True
    
    def _can_castle_queenside(self, row: int, board: 'Board') -> bool:
        rook_cell = board.get_cell(Position(row, 0))
        if not rook_cell.piece or not isinstance(rook_cell.piece, Rook):
            return False
        if rook_cell.piece.has_moved:
            return False
        
        # Check path is clear
        for col in [1, 2, 3]:
            if board.get_cell(Position(row, col)).piece is not None:
                return False
        
        # Check king doesn't pass through check
        for col in [2, 3, 4]:
            if board.is_square_attacked(Position(row, col), self.color.opposite()):
                return False
        
        return True


class Queen(Piece):
    def symbol(self) -> str:
        return '♕' if self.color == Color.WHITE else '♛'
    
    def get_possible_moves(self, position: Position, board: 'Board') -> List[Position]:
        moves = []
        # Queen combines Rook and Bishop movements
        directions = [
            (-1, 0), (1, 0), (0, -1), (0, 1),  # Rook directions
            (-1, -1), (-1, 1), (1, -1), (1, 1)  # Bishop directions
        ]
        
        for direction in directions:
            moves.extend(self._get_moves_in_direction(position, board, direction))
        
        return moves


class Rook(Piece):
    def symbol(self) -> str:
        return '♖' if self.color == Color.WHITE else '♜'
    
    def get_possible_moves(self, position: Position, board: 'Board') -> List[Position]:
        moves = []
        directions = [(-1, 0), (1, 0), (0, -1), (0, 1)]
        
        for direction in directions:
            moves.extend(self._get_moves_in_direction(position, board, direction))
        
        return moves


class Bishop(Piece):
    def symbol(self) -> str:
        return '♗' if self.color == Color.WHITE else '♝'
    
    def get_possible_moves(self, position: Position, board: 'Board') -> List[Position]:
        moves = []
        directions = [(-1, -1), (-1, 1), (1, -1), (1, 1)]
        
        for direction in directions:
            moves.extend(self._get_moves_in_direction(position, board, direction))
        
        return moves


class Knight(Piece):
    def symbol(self) -> str:
        return '♘' if self.color == Color.WHITE else '♞'
    
    def get_possible_moves(self, position: Position, board: 'Board') -> List[Position]:
        moves = []
        # L-shaped moves
        offsets = [
            (-2, -1), (-2, 1), (-1, -2), (-1, 2),
            (1, -2), (1, 2), (2, -1), (2, 1)
        ]
        
        for offset in offsets:
            new_pos = Position(position.row + offset[0], position.col + offset[1])
            if new_pos.is_valid():
                cell = board.get_cell(new_pos)
                if cell.piece is None or cell.piece.color != self.color:
                    moves.append(new_pos)
        
        return moves


class Pawn(Piece):
    def symbol(self) -> str:
        return '♙' if self.color == Color.WHITE else '♟'
    
    def get_possible_moves(self, position: Position, board: 'Board') -> List[Position]:
        moves = []
        direction = 1 if self.color == Color.WHITE else -1
        start_row = 1 if self.color == Color.WHITE else 6
        
        # Forward move
        forward = Position(position.row + direction, position.col)
        if forward.is_valid() and board.get_cell(forward).piece is None:
            moves.append(forward)
            
            # Double move from starting position
            if position.row == start_row:
                double = Position(position.row + 2 * direction, position.col)
                if board.get_cell(double).piece is None:
                    moves.append(double)
        
        # Diagonal captures
        for col_offset in [-1, 1]:
            capture_pos = Position(position.row + direction, position.col + col_offset)
            if capture_pos.is_valid():
                cell = board.get_cell(capture_pos)
                if cell.piece and cell.piece.color != self.color:
                    moves.append(capture_pos)
                
                # En passant
                if self._can_en_passant(position, capture_pos, board):
                    moves.append(capture_pos)
        
        return moves
    
    def _can_en_passant(self, from_pos: Position, to_pos: Position, board: 'Board') -> bool:
        if not board.last_move:
            return False
        
        last_move = board.last_move
        if not isinstance(last_move.piece, Pawn):
            return False
        
        # Was it a double pawn move?
        if abs(last_move.from_pos.row - last_move.to_pos.row) != 2:
            return False
        
        # Is it adjacent to us?
        if last_move.to_pos.row != from_pos.row:
            return False
        if abs(last_move.to_pos.col - from_pos.col) != 1:
            return False
        
        # Target square matches en passant capture
        return last_move.to_pos.col == to_pos.col

Cell Class

class Cell:
    def __init__(self, row: int, col: int):
        self.row = row
        self.col = col
        self.piece: Optional[Piece] = None
    
    @property
    def position(self) -> Position:
        return Position(self.row, self.col)
    
    def is_empty(self) -> bool:
        return self.piece is None
    
    def __str__(self):
        if self.piece:
            return self.piece.symbol()
        return '·'

Board Class

class Board:
    def __init__(self):
        self.cells: List[List[Cell]] = [
            [Cell(row, col) for col in range(8)] for row in range(8)
        ]
        self.captured_pieces: List[Piece] = []
        self.last_move: Optional[Move] = None
        self._initialize_pieces()
    
    def _initialize_pieces(self):
        # Place pawns
        for col in range(8):
            self.cells[1][col].piece = Pawn(Color.WHITE)
            self.cells[6][col].piece = Pawn(Color.BLACK)
        
        # Place other pieces
        piece_order = [Rook, Knight, Bishop, Queen, King, Bishop, Knight, Rook]
        for col, piece_class in enumerate(piece_order):
            self.cells[0][col].piece = piece_class(Color.WHITE)
            self.cells[7][col].piece = piece_class(Color.BLACK)
    
    def get_cell(self, position: Position) -> Cell:
        return self.cells[position.row][position.col]
    
    def find_king(self, color: Color) -> Position:
        for row in range(8):
            for col in range(8):
                piece = self.cells[row][col].piece
                if isinstance(piece, King) and piece.color == color:
                    return Position(row, col)
        raise ValueError(f"King not found for {color}")
    
    def is_square_attacked(self, position: Position, by_color: Color) -> bool:
        """Check if a square is attacked by any piece of given color"""
        for row in range(8):
            for col in range(8):
                piece = self.cells[row][col].piece
                if piece and piece.color == by_color:
                    moves = piece.get_possible_moves(Position(row, col), self)
                    if position in moves:
                        return True
        return False
    
    def is_king_in_check(self, color: Color) -> bool:
        king_pos = self.find_king(color)
        return self.is_square_attacked(king_pos, color.opposite())
    
    def get_valid_moves(self, position: Position) -> List[Position]:
        """Get all legal moves for piece at position (filters out moves that leave king in check)"""
        piece = self.get_cell(position).piece
        if not piece:
            return []
        
        possible_moves = piece.get_possible_moves(position, self)
        valid_moves = []
        
        for move_pos in possible_moves:
            # Simulate move
            if not self._would_leave_king_in_check(position, move_pos, piece.color):
                valid_moves.append(move_pos)
        
        return valid_moves
    
    def _would_leave_king_in_check(self, from_pos: Position, to_pos: Position, color: Color) -> bool:
        """Simulate move and check if king would be in check"""
        # Save state
        from_cell = self.get_cell(from_pos)
        to_cell = self.get_cell(to_pos)
        moving_piece = from_cell.piece
        captured_piece = to_cell.piece
        
        # Make temporary move
        to_cell.piece = moving_piece
        from_cell.piece = None
        
        # Check if king is in check
        in_check = self.is_king_in_check(color)
        
        # Restore state
        from_cell.piece = moving_piece
        to_cell.piece = captured_piece
        
        return in_check
    
    def make_move(self, from_pos: Position, to_pos: Position) -> Optional[Move]:
        """Execute a move on the board"""
        from_cell = self.get_cell(from_pos)
        to_cell = self.get_cell(to_pos)
        piece = from_cell.piece
        
        if not piece:
            return None
        
        # Create move record
        move = Move(
            piece=piece,
            from_pos=from_pos,
            to_pos=to_pos,
            captured_piece=to_cell.piece
        )
        
        # Handle special moves
        if isinstance(piece, King) and abs(from_pos.col - to_pos.col) == 2:
            self._execute_castling(from_pos, to_pos)
            move.is_castling = True
        elif isinstance(piece, Pawn):
            if abs(from_pos.col - to_pos.col) == 1 and to_cell.piece is None:
                # En passant
                captured_pawn_pos = Position(from_pos.row, to_pos.col)
                move.captured_piece = self.get_cell(captured_pawn_pos).piece
                self.get_cell(captured_pawn_pos).piece = None
                move.is_en_passant = True
            elif to_pos.row in [0, 7]:
                # Pawn promotion (default to Queen)
                move.is_promotion = True
                move.promotion_piece = Queen(piece.color)
                piece = move.promotion_piece
        
        # Execute move
        if move.captured_piece:
            self.captured_pieces.append(move.captured_piece)
        
        to_cell.piece = piece
        from_cell.piece = None
        piece.has_moved = True
        self.last_move = move
        
        return move
    
    def _execute_castling(self, king_from: Position, king_to: Position):
        """Move rook for castling"""
        row = king_from.row
        if king_to.col == 6:  # King-side
            rook_from = Position(row, 7)
            rook_to = Position(row, 5)
        else:  # Queen-side
            rook_from = Position(row, 0)
            rook_to = Position(row, 3)
        
        rook = self.get_cell(rook_from).piece
        self.get_cell(rook_to).piece = rook
        self.get_cell(rook_from).piece = None
        rook.has_moved = True
    
    def display(self):
        print("\n  a b c d e f g h")
        for row in range(7, -1, -1):
            print(f"{row + 1} ", end="")
            for col in range(8):
                print(f"{self.cells[row][col]} ", end="")
            print(f"{row + 1}")
        print("  a b c d e f g h\n")

Player Class

class Player:
    def __init__(self, color: Color, name: str = None):
        self.color = color
        self.name = name or f"Player ({color.value})"
        self.captured_pieces: List[Piece] = []
    
    def __str__(self):
        return self.name

Game Class

class Game:
    def __init__(self, player1_name: str = None, player2_name: str = None):
        self.board = Board()
        self.players = [
            Player(Color.WHITE, player1_name),
            Player(Color.BLACK, player2_name)
        ]
        self.current_player_index = 0
        self.status = GameStatus.ACTIVE
        self.move_history: List[Move] = []
    
    @property
    def current_player(self) -> Player:
        return self.players[self.current_player_index]
    
    def make_move(self, from_notation: str, to_notation: str) -> bool:
        """Make a move using algebraic notation (e.g., 'e2', 'e4')"""
        from_pos = self._notation_to_position(from_notation)
        to_pos = self._notation_to_position(to_notation)
        return self.make_move_by_position(from_pos, to_pos)
    
    def make_move_by_position(self, from_pos: Position, to_pos: Position) -> bool:
        """Make a move using Position objects"""
        if self.status != GameStatus.ACTIVE:
            print("Game is not active!")
            return False
        
        piece = self.board.get_cell(from_pos).piece
        
        # Validate piece belongs to current player
        if not piece or piece.color != self.current_player.color:
            print("Invalid piece selection!")
            return False
        
        # Validate move is legal
        valid_moves = self.board.get_valid_moves(from_pos)
        if to_pos not in valid_moves:
            print("Invalid move!")
            return False
        
        # Execute move
        move = self.board.make_move(from_pos, to_pos)
        self.move_history.append(move)
        
        if move.captured_piece:
            self.current_player.captured_pieces.append(move.captured_piece)
            print(f"Captured {move.captured_piece.symbol()}!")
        
        # Check game status
        self._update_game_status()
        
        # Switch turns
        self.current_player_index = 1 - self.current_player_index
        
        return True
    
    def _update_game_status(self):
        """Check for checkmate, stalemate after a move"""
        opponent_color = self.current_player.color.opposite()
        
        # Check if opponent has any valid moves
        has_valid_moves = self._has_any_valid_moves(opponent_color)
        is_in_check = self.board.is_king_in_check(opponent_color)
        
        if not has_valid_moves:
            if is_in_check:
                # Checkmate!
                self.status = (GameStatus.WHITE_WIN 
                              if self.current_player.color == Color.WHITE 
                              else GameStatus.BLACK_WIN)
                print(f"Checkmate! {self.current_player} wins!")
            else:
                # Stalemate
                self.status = GameStatus.STALEMATE
                print("Stalemate! Game is a draw.")
        elif is_in_check:
            print("Check!")
    
    def _has_any_valid_moves(self, color: Color) -> bool:
        """Check if player has any valid moves"""
        for row in range(8):
            for col in range(8):
                piece = self.board.cells[row][col].piece
                if piece and piece.color == color:
                    if self.board.get_valid_moves(Position(row, col)):
                        return True
        return False
    
    def _notation_to_position(self, notation: str) -> Position:
        """Convert 'e4' to Position(3, 4)"""
        col = ord(notation[0].lower()) - ord('a')
        row = int(notation[1]) - 1
        return Position(row, col)
    
    def display(self):
        print(f"\n=== {self.current_player}'s turn ===")
        self.board.display()
        
        if self.board.is_king_in_check(self.current_player.color):
            print("⚠️ Your king is in CHECK!")
    
    def get_move_history(self) -> List[str]:
        """Return move history in algebraic notation"""
        history = []
        for i, move in enumerate(self.move_history):
            move_num = i // 2 + 1
            if i % 2 == 0:
                history.append(f"{move_num}. {move}")
            else:
                history[-1] += f" {move}"
        return history

Step 5: Usage Example

# Create a new game
game = Game("Alice", "Bob")

# Display initial board
game.display()

# Make some moves (Scholar's Mate)
game.make_move("e2", "e4")  # White pawn
game.display()

game.make_move("e7", "e5")  # Black pawn
game.display()

game.make_move("f1", "c4")  # White bishop
game.display()

game.make_move("b8", "c6")  # Black knight
game.display()

game.make_move("d1", "h5")  # White queen
game.display()

game.make_move("g8", "f6")  # Black knight (blunder!)
game.display()

game.make_move("h5", "f7")  # Checkmate!
game.display()

# Print move history
print("Move History:")
for move in game.get_move_history():
    print(move)

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