Engineering Atlas › Low-Level Design

Code a chess game with move rules

14 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

This lesson is about the object model and in-process rules for one game. The distributed side (one owner per game, clocks and lag, matchmaking) is covered in Design Online Chess.

Requirements

Functional:

Non-functional:

Out of scope: chess clocks and networking (see Design Online Chess), engine strength, and variants such as Chess960 (mentioned under Extensibility).

Entities and relationships

Relationships: a game composes a board and a history of moves; a board aggregates pieces by square. Move rules are shared, stateless objects, one per kind.

Class diagram

classDiagram
    class Game {
      -board: Board
      -sideToMove: Color
      -castling: CastlingRights
      -epSquare: Square
      -halfmoveClock: int
      -history: Move[]
      -status: GameStatus
      +legalMoves(from) Move[]
      +play(move) GameStatus
      +undo()
      +resign(color)
    }
    class Board {
      -squares: Piece[64]
      +pieceAt(sq) Piece
      +isAttacked(sq, by) bool
      +kingSquare(color) Square
    }
    class Piece {
      +color: Color
      +kind: Kind
    }
    class MoveRule {
      <<interface>>
      +pseudoMoves(board, from) Square[]
    }
    class Move {
      +from: Square
      +to: Square
      +promotion: Kind
      +captured: Piece
      +prevCastling: CastlingRights
      +prevEpSquare: Square
      +prevHalfmove: int
    }
    class Player {
      <<interface>>
      +chooseMove(game) Move
    }
    Game "1" *-- "1" Board
    Game "1" *-- "many" Move
    Board "1" o-- "0..32" Piece
    Game --> MoveRule
    SlidingRule ..|> MoveRule
    LeapingRule ..|> MoveRule
    PawnRule ..|> MoveRule
    HumanPlayer ..|> Player
    EnginePlayer ..|> Player

Key flows

Every move takes the same path: parse, check it belongs to the side to move, check it is among the legal moves, apply it, then evaluate the new position for the opponent.

sequenceDiagram
    participant P as Player
    participant G as Game
    participant R as MoveRule
    participant B as Board
    P->>G: play(e7, e8, promote=Q)
    G->>G: status terminal? side to move?
    G->>R: pseudoMoves(board, e7)
    R-->>G: e8, d8x, f8x
    loop each candidate
        G->>B: apply on a copy
        G->>B: isAttacked(own king, by opponent)
    end
    G->>G: e8 is legal, record undo info
    G->>B: apply move, promote
    G->>G: update rights, ep square, clocks, history
    G->>G: opponent in check? any legal move? repetition?
    G-->>P: CHECK

Design patterns used and why

Resist making Board or Game a singleton. A server runs thousands of games, and an engine searches millions of positions; both need many instances.

Concurrency and edge cases

Down the stack

Layer 1The contract

legalMoves(square) returns exactly the moves the laws of chess allow for the piece on that square, for the side to move, and an empty list otherwise. play either applies a legal move and returns the new status, or rejects it and changes nothing. Undoing a move restores every part of the position, including castling rights, en passant square and clocks, so that play followed by undo is the identity. The status after a move is a pure function of the position and the history.

Layer 2The mechanism

Board representations trade simplicity for speed. An 8 by 8 array is the clearest and fine for an application. A 10 by 12 mailbox pads the board with sentinel squares so that a knight jump off the edge lands on "off board" instead of needing bounds checks. 0x88 uses a 128-entry array where square & 0x88 is non-zero exactly when a move left the board, a one-instruction bounds test. Bitboards use one 64-bit integer per piece kind and colour; move generation becomes shifts, masks and table lookups, and sliding attacks use precomputed "magic" multiplication tables.

Legality is the generate-then-test loop. Engines avoid copying the board by make/unmake: apply the move in place, test, then reverse it using the saved undo record. Repetition detection uses Zobrist hashing: a random 64-bit key per (piece, square) plus keys for side, castling and en passant, XORed together, updated incrementally on each move with a couple of XORs. Comparing the current hash against earlier positions since the last capture or pawn move finds repetitions quickly. Perft counts leaf nodes of the legal-move tree to a fixed depth from known positions; published counts exist for tricky positions with castling, en passant and promotions, and a mismatch pinpoints a bug by splitting the count per first move.

Layer 3The machine

An 8 by 8 board of one-byte codes is 64 bytes, exactly one cache line, so copying the whole board per candidate move (as the practice does) costs a few nanoseconds and is simpler than make/unmake. The practice code checks each candidate by scanning every enemy piece's moves, roughly 30 candidates times 16 attackers per position, tens of thousands of simple operations: well under a millisecond, fine for validating human moves. Engines need millions of positions per second, so they move to bitboards, where "is this square attacked by a knight" is one AND of a precomputed 64-bit mask with the knight bitboard, and counting pieces is the POPCNT instruction. Zobrist keys need a table of 12 x 64 keys (6 KB) that stays hot in L1 cache.

Why it's built this way

  1. The laws of chess define legality in terms of the king's safety after the move, so the cheapest correct implementation applies the move and checks for attack.
  2. Some rules depend on history that the board cannot show (castling rights, en passant, repetition), so the game, not the board, owns that state.
  3. Undo, replay and repetition all need the past, so moves are recorded as commands that carry their own undo information.
  4. Hand-written test positions miss rare interactions (en passant discovering check), so correctness is measured by perft against published counts.
  5. Speed matters only for engines, so applications keep the simple array and engines pay the complexity of bitboards behind the same interface.

Extensibility

Leadership lens

Check yourself

  1. Why does generate-then-filter handle pins without any pin-specific code?

    Show answer

    A pinned piece's illegal moves are exactly those that leave the king attacked after the move, which the filter tests directly by applying each candidate and checking the king's square.

  2. Why should a Piece not store its own square?

    Show answer

    The board already stores positions; a second copy in the piece can drift after captures, promotions or undo. One source of truth keeps moves and undo simple.

  3. Two positions have identical piece placement but in one of them white can still castle. Are they the same position for threefold repetition?

    Show answer

    No. Repetition compares placement, side to move, castling rights and en passant possibilities, so differing castling rights make them different positions.

  4. Your move generator plays thousands of test games without an illegal move, but perft at depth 4 from a tricky position is off by 2. What do you do next?

    Show answer

    Split perft by first move (divide), compare each count with a reference, recurse into the move whose count differs, and repeat until one position shows the missing or extra move.

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →