Code a chess game with move rules
Open in the interactive app: practice, quiz, mock interview →In one minute
- Split the model three ways: the Board knows where pieces are, each piece type knows how it moves on an empty board, and the Game knows the rules that depend on history and on the whole position (turns, check, castling rights, en passant, draws). Most weak answers put everything in
Piece. - Legal moves are pseudo-legal moves minus the ones that leave your own king attacked. Generate, try, test. Pins, discovered checks, double checks and "you must get out of check" all fall out of that one filter without special cases.
- A move is a value with enough information to undo it: from, to, piece, captured piece, promotion, and the castling rights, en passant square and halfmove clock it replaced. That one record gives you undo, history, notation, repetition detection and replay.
- The game can end in more ways than checkmate: stalemate, threefold repetition, the fifty-move rule, insufficient material, resignation, time and agreement. Model the status as an explicit state and reject moves once it is terminal.
- Test with perft, not with a few hand-written positions. Counting the leaf nodes of the move tree from known positions (20, 400, 8,902, 197,281 from the start) catches move-generation bugs that no example game will.
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:
- Two players, white and black, alternate moves on an 8 by 8 board from the standard starting position. White moves first.
- The system accepts a move from the side to move only if it is legal under the laws of chess, including castling, en passant and promotion (the player chooses the piece).
- After each move, the system reports the status for the side to move: active, check, checkmate or stalemate, plus draws by threefold repetition, the fifty-move rule and insufficient material.
- Players can resign or agree a draw. The game keeps a full move history and can export it in standard notation.
- Undo is available in analysis mode (not in rated play).
Non-functional:
- Move validation is exact. A single illegal move accepted in a rated game is a correctness incident.
- Validation is fast: well under a millisecond per move, so the same code can serve a UI, a server and a simple engine.
- Positions can be imported and exported as text so that bug reports and tests are reproducible.
Out of scope: chess clocks and networking (see Design Online Chess), engine strength, and variants such as Chess960 (mentioned under Extensibility).
Entities and relationships
- Game is the façade and the rule keeper. It owns the board, the side to move, the status, the move history and the position state that the board alone cannot express: castling rights, the en passant target square and the halfmove clock.
- Board holds 64 squares and answers "what is on this square" and "is this square attacked by this colour". It does not know whose turn it is.
- Piece is a value: colour and kind. A piece does not store its own square; the board does. Keeping position out of the piece avoids two sources of truth that drift apart after a capture or an undo.
- MoveRule per kind describes movement on the board: knights and kings jump by fixed offsets, rooks, bishops and queens slide along directions until blocked, pawns are special (direction depends on colour, captures differ from pushes, double step from the start rank, promotion, en passant).
- Move is an immutable record: from, to, promotion piece, plus the undo information captured when it is played.
- Player has a colour and an identity; human, engine and remote players all implement the same interface that returns a move.
- GameStatus is an enum:
ACTIVE,CHECK,CHECKMATE,STALEMATE,DRAW_REPETITION,DRAW_FIFTY,DRAW_MATERIAL,DRAW_AGREED,RESIGNED,TIMEOUT.
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 ..|> PlayerKey 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: CHECKDesign patterns used and why
- Polymorphism or a table for movement. Six kinds with different movement is a natural fit for a
MoveRuleinterface. A table is equally good and often better: rook, bishop and queen differ only in their direction lists, knight and king only in their offset lists, so two rule classes (sliding and leaping) plus a pawn rule cover everything. Say this in the interview; it shows you see the data behind the classes. - Command for moves. A move object carries what it needs to apply and to undo itself. History is a list of commands, undo pops and reverses, and notation export walks the list.
- Memento for position state that is cheap to save whole: castling rights, en passant square and halfmove clock fit in a few bytes, so each move stores the previous values instead of recomputing them on undo.
- State for the game status, as an enum with guarded transitions. Only
ACTIVEandCHECKaccept moves; every other status is terminal. - Factory for setup.
Game.standard()andGame.fromFEN(text)build positions; tests use FEN strings so a bug report is one line of text. - Strategy for players. A human UI, an engine and a network peer all implement
chooseMove. The game loop does not care which one it is talking to. - Observer for the UI, a sound player and a clock. The game publishes "move played" and "status changed" events after the state is consistent.
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
- Pins and discovered checks. A piece that shields its king cannot leave the line. Generate-then-filter handles this; trying to special-case pins is where hand-written validators break.
- Double check. Only a king move can answer two checkers at once. Again the filter handles it, because no block or capture removes both attacks.
- Castling. Legal only if neither the king nor that rook has moved, the squares between them are empty, the king is not in check, and the king does not pass through or land on an attacked square. Store castling rights as four flags and clear them when a king or rook moves or a rook is captured on its home square.
- En passant. Available for exactly one move after a two-square pawn push, and the capture can expose the capturer's king along the rank (both pawns leave the rank at once). The legality filter catches that case only if it applies the move fully, including removing the captured pawn from its real square.
- Promotion. The player chooses queen, rook, bishop or knight; underpromotion to a knight sometimes gives mate. A move to the last rank without a choice is incomplete, not "default to queen", in a rated game.
- Draws. Threefold repetition compares full positions (pieces, side to move, castling rights and en passant possibilities), not just piece placement. Under FIDE rules a player may claim a draw at threefold repetition or after 50 moves by each side without a capture or pawn move, and the arbiter ends the game automatically at fivefold repetition or 75 moves.
- Concurrency. One game is a single-owner object: two players must never mutate it at once. In process, put it behind one lock or one actor; across a network, route every move for a game to one owner and number moves so retries are idempotent. That is the design in Design Online Chess, and the reasoning is in How to handle Concurrency Scenarios.
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
- 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.
- Some rules depend on history that the board cannot show (castling rights, en passant, repetition), so the game, not the board, owns that state.
- Undo, replay and repetition all need the past, so moves are recorded as commands that carry their own undo information.
- Hand-written test positions miss rare interactions (en passant discovering check), so correctness is measured by perft against published counts.
- Speed matters only for engines, so applications keep the simple array and engines pay the complexity of bitboards behind the same interface.
Extensibility
- Chess960: only the starting position factory and the castling rule change (the king and rook land on the usual squares from varied starting squares). If castling is its own rule object, nothing else moves.
- Clocks: a clock observer that starts on "move played" and a
TIMEOUTstatus. In online play the clock lives on the server, as covered in Design Online Chess. - Engine opponent: an
EnginePlayerthat searches with make/unmake on a private copy of the game. It calls the samelegalMoves, which is why that method must be fast and exact. - Notation: export to PGN by walking the history; each move needs disambiguation (which knight moved) computed from the legal moves at that point, so notation is produced during play or by replay.
- Variants with new pieces: a new leaping or sliding rule with its own offsets or directions. Variants with new win conditions (three-check, king of the hill) add a status rule, not a new board.
- Analysis mode: undo and branching become a tree of moves rather than a list; the Move command stays the same.
Leadership lens
- Build first: move generation and the legality filter with a perft test suite. UI, notation and clocks are straightforward once moves are exact, and an illegal move accepted in production destroys trust in the product.
- Ask in review: where does castling and en passant state live; does undo restore all of it; how are repetitions compared; is the same rules module used by the client preview and the server authority.
- Share one rules implementation between client and server (compiled to WebAssembly or shared TypeScript), with the server authoritative. Two independent implementations will disagree on an edge case, and players will find it.
- Ownership: the rules engine is a small, high-trust library with exhaustive tests and few owners. Game service, UI and engine teams consume it through a narrow interface.
- In the interview, spend the first ten minutes on entities and the legality filter, then go deep on two edge cases (castling through check, en passant discovering check). That shows you can find the hard parts, not just draw classes.
Check yourself
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.
Why should a
Piecenot 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.
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.
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
- Coding Adventure: Chess (Sebastian Lague): board representation, legal move generation, and testing with perft, built step by step.
- Command Pattern (Christopher Okhravi): the pattern behind moves with undo.
Free resources
- Rules of chess (Wikipedia): castling, en passant, promotion and the draw rules in one place.
- Board Representation (Chess Programming Wiki): 8 by 8 arrays, mailbox, 0x88 and bitboards compared.
- Perft (Chess Programming Wiki): how perft and divide work, with reference counts.
- Forsyth-Edwards Notation (Chess Programming Wiki): the one-line position format for tests and bug reports.
- Threefold repetition (Wikipedia): what counts as the same position, with examples.
- Command pattern (Refactoring.Guru): commands with undo, in several languages.