Six pieces, each with a rule a child can learn. Then castling, en passant, promotion, and the one rule that overrides them all: you may never leave your own king in check.
Chess is a favourite low-level design prompt because the class hierarchy is obvious and the trap is not. Everyone draws a Piece base class, six subclasses and an is_valid_move() on each. Then the interviewer asks about a pinned bishop, and the design falls apart: the bishop can see its diagonals, but not the rook lining up on its king behind it. Legality depends on the whole board, on whose turn it is, and on history. A piece can't know any of that.
This lesson splits the two questions. Where can this piece go? belongs to the piece. Is this move allowed? belongs to the game, which answers it the simplest correct way: make the move, look at the king, take it back. Then it checks itself against published reference counts.
How the round runs#
"Design a chess game for two human players. Validate every move. Detect check, checkmate and stalemate. Support castling, en passant and promotion, and let players undo. No computer opponent, no clocks. I want working code."
- 0–10 min — clarify: move format, which rules, which endings, undo depth, scope
- 10–25 min — the classes, and the key decision: pieces generate moves, the game decides legality
- 25–45 min — the pieces: sliders, steppers and the pawn
- 45–70 min — the game: push and pop, make-and-test legality, castling rights, en passant
- 70–80 min — a demo: fool's mate, each special move, a stalemate, perft
- 80–90 min — follow-ups: draws, clocks, online play, notation, a computer opponent
Requirements and clarifying questions#
| You ask | They answer | What it changes |
|---|---|---|
| How are moves entered? | From and to squares: e2e4, e7e8q to promote. |
Square parsing; the promotion defaults to a queen |
| Castling, en passant, promotion? | All three, standard rules. | The game tracks castling rights and the en passant square |
| Which endings? | Checkmate and stalemate; other draws later. | status() from "any legal moves?" and "in check?" |
| Start from a given position? | Yes, for tests, as FEN. | A small FEN constructor |
| Online play? | Later, but keep it thread-safe. | One lock per game; a turn check |
Why can't a piece decide whether its own move is legal?
White: king e1, bishop e2. Black: rook e8. The bishop's rules allow e2 to d3. Why is the move illegal, and where does that put legality checking?
One strong answer
The bishop is pinned: leaving the e-file exposes its king, and no move may leave your own king in check. The geometry is right; the reason it's illegal involves three pieces. Castling and en passant depend on history too. So pieces produce pseudo-legal moves and the game filters them: play the move, see if the mover's king is attacked, take it back. That one check also yields check, checkmate and stalemate.
The classes#
%% title: Chess: the classes
classDiagram
class Game {
+board Board
+turn Color
+rights frozenset
+ep Square
+history list
+move(uci, by) Status
+undo() Move
+legal_moves() list
+status() Status
}
class Board {
+squares dict
+king(color) Square
+attacked(square, by) bool
}
class Piece {
<<abstract>>
+color Color
+attacks(board, at) iterator
+moves(board, at, ep) iterator
}
class Move {
+frm Square
+to Square
+promotion str
+castle bool
+en_passant bool
}
Game *-- Board
Game "1" o-- "many" Undo
Board "1" o-- "many" Piece
Undo --> Move
Piece <|-- Slider
Piece <|-- Stepper
Piece <|-- Pawn
Slider <|-- Rook
Slider <|-- Bishop
Slider <|-- Queen
Stepper <|-- Knight
Stepper <|-- KingBoard knows where the pieces are and whether a square is attacked. Piece knows geometry. Game knows everything that needs history or the turn. And a piece does not store its own square: with both piece.position and board[square], the first bug in undo makes them disagree.
The interfaces#
Squares are (file, rank) tuples from 0 to 7, so e4 is (4, 3). A Move is a frozen value carrying its own flags, so undo knows what kind of move it is reversing.
# chess.py, part 1: colours, squares and moves
from __future__ import annotations
import threading
from abc import ABC, abstractmethod
from collections.abc import Iterator
from dataclasses import dataclass
from enum import Enum
class Color(Enum):
WHITE = "white"
BLACK = "black"
@property
def other(self) -> Color:
return Color.BLACK if self is Color.WHITE else Color.WHITE
Square = tuple[int, int] # (file, rank), both 0-7: "e4" is (4, 3)
def sq(name: str) -> Square:
return "abcdefgh".index(name[0]), int(name[1]) - 1
def name(s: Square) -> str:
return "abcdefgh"[s[0]] + str(s[1] + 1)
def on_board(s: Square) -> bool:
return 0 <= s[0] < 8 and 0 <= s[1] < 8
@dataclass(frozen=True)
class Move:
frm: Square
to: Square
promotion: str | None = None # "q", "r", "b" or "n"
castle: bool = False
en_passant: bool = False
def __str__(self) -> str:
return name(self.frm) + name(self.to) + (self.promotion or "")The pieces: geometry only#
Every piece answers attacks(): which squares do I hit from here? For most pieces, moves are the attacked squares not holding a friend, so the base class does that once. A Slider walks each direction until it hits a piece or the edge; a Stepper tries fixed offsets. The pawn alone moves differently from how it captures: ahead one square (two from home), diagonally to capture, including en passant. Reaching the last rank yields four moves, one per promotion piece.
# part 2: each piece knows the squares it attacks; legality is the game's job
class Piece(ABC):
symbol = "?"
def __init__(self, color: Color) -> None:
self.color = color
def __repr__(self) -> str:
return self.symbol.upper() if self.color is Color.WHITE else self.symbol
@abstractmethod
def attacks(self, board: Board, at: Square) -> Iterator[Square]: ...
def moves(self, board: Board, at: Square, ep: Square | None) -> Iterator[Move]:
for to in self.attacks(board, at): # most pieces: any attacked square not holding a friend
target = board.squares.get(to)
if target is None or target.color is not self.color:
yield Move(at, to)
class Slider(Piece):
dirs: tuple[tuple[int, int], ...] = ()
def attacks(self, board: Board, at: Square) -> Iterator[Square]:
for df, dr in self.dirs:
s = (at[0] + df, at[1] + dr)
while on_board(s):
yield s
if s in board.squares:
break # the first piece in the way is attacked; nothing beyond it is
s = (s[0] + df, s[1] + dr)
class Stepper(Piece):
steps: tuple[tuple[int, int], ...] = ()
def attacks(self, board: Board, at: Square) -> Iterator[Square]:
for df, dr in self.steps:
if on_board(s := (at[0] + df, at[1] + dr)):
yield s
class Rook(Slider):
symbol, dirs = "r", ((1, 0), (-1, 0), (0, 1), (0, -1))
class Bishop(Slider):
symbol, dirs = "b", ((1, 1), (1, -1), (-1, 1), (-1, -1))
class Queen(Slider):
symbol, dirs = "q", Rook.dirs + Bishop.dirs
class Knight(Stepper):
symbol, steps = "n", ((1, 2), (2, 1), (2, -1), (1, -2), (-1, -2), (-2, -1), (-2, 1), (-1, 2))
class King(Stepper):
symbol, steps = "k", Queen.dirs # castling needs history, so the game adds it
class Pawn(Piece):
symbol = "p"
def attacks(self, board: Board, at: Square) -> Iterator[Square]:
d = 1 if self.color is Color.WHITE else -1
for df in (-1, 1):
if on_board(s := (at[0] + df, at[1] + d)):
yield s
def moves(self, board: Board, at: Square, ep: Square | None) -> Iterator[Move]:
d, home, last = (1, 1, 7) if self.color is Color.WHITE else (-1, 6, 0)
one, two = (at[0], at[1] + d), (at[0], at[1] + 2 * d)
if on_board(one) and one not in board.squares:
yield from self._maybe_promote(at, one, last)
if at[1] == home and two not in board.squares:
yield Move(at, two)
for s in self.attacks(board, at):
target = board.squares.get(s)
if target is not None and target.color is not self.color:
yield from self._maybe_promote(at, s, last)
elif s == ep:
yield Move(at, s, en_passant=True)
@staticmethod
def _maybe_promote(at: Square, to: Square, last: int) -> Iterator[Move]:
if to[1] == last:
for p in "qrbn":
yield Move(at, to, promotion=p)
else:
yield Move(at, to)
class Board:
def __init__(self) -> None:
self.squares: dict[Square, Piece] = {}
def king(self, color: Color) -> Square:
return next(s for s, p in self.squares.items() if isinstance(p, King) and p.color is color)
def attacked(self, target: Square, by: Color) -> bool:
return any(p.color is by and target in p.attacks(self, s) for s, p in list(self.squares.items()))Board.attacked() reuses the pieces' own attacks(), so "can a knight reach e5?" and "is e5 attacked by a knight?" can never disagree.
The rules that need history#
Castling rights (K, Q, k, q) are lost for good when the king moves, or that rook moves or is captured on its corner. In one line: any move from or onto e1, h1, a1, e8, h8 or a8 removes the rights tied to that square. With the right, castling also needs empty squares between king and rook, and a king that is not in check, does not cross an attacked square and does not land on one. The rook may cross an attacked square (b1).
The en passant square: the square a pawn skipped with a two-square advance, valid for one move. The capture lands there and removes the pawn beside it.
Undo records hold what a move destroys: the piece as it was (so a promoted queen reverts to a pawn), the captured piece and where it stood, and the old rights and en passant square.
# part 3: the rules that need history, as tables and records
PIECES: dict[str, type[Piece]] = {"k": King, "q": Queen, "r": Rook, "b": Bishop, "n": Knight, "p": Pawn}
START = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq -"
CASTLES = { # right: king from, king to, rook from, rook to, squares that must be empty
"K": (sq("e1"), sq("g1"), sq("h1"), sq("f1"), (sq("f1"), sq("g1"))),
"Q": (sq("e1"), sq("c1"), sq("a1"), sq("d1"), (sq("b1"), sq("c1"), sq("d1"))),
"k": (sq("e8"), sq("g8"), sq("h8"), sq("f8"), (sq("f8"), sq("g8"))),
"q": (sq("e8"), sq("c8"), sq("a8"), sq("d8"), (sq("b8"), sq("c8"), sq("d8"))),
}
ROOK_JUMP = {(kf, kt): (rf, rt) for kf, kt, rf, rt, _ in CASTLES.values()}
LOSES_RIGHTS = { # any move from or onto these squares ends these castling rights
sq("e1"): {"K", "Q"}, sq("h1"): {"K"}, sq("a1"): {"Q"},
sq("e8"): {"k", "q"}, sq("h8"): {"k"}, sq("a8"): {"q"},
}
class Status(Enum):
ONGOING = "ongoing"
CHECK = "check"
CHECKMATE = "checkmate"
STALEMATE = "stalemate"
@dataclass(frozen=True)
class Undo: # everything a move destroys, so pop() can put it back
move: Move
piece: Piece
captured: Piece | None
captured_at: Square
rights: frozenset[str]
ep: Square | None
class IllegalMove(Exception): ...The patterns, and why#
| Pattern | Where | Why it earns its place |
|---|---|---|
| Inheritance for families | Piece → Slider / Stepper → Rook, Knight… |
Rook, bishop and queen differ only in directions, knight and king only in offsets: each is one line of data. |
| Command with an undo stack | Move + Undo, push() / pop() |
An exact inverse for every move. Legality checks, the undo button, perft and a future computer opponent all use the same two methods. |
| Table-driven rules | CASTLES, LOSES_RIGHTS |
Four castles are four rows of data, not four if branches. |
Concurrency and edge cases#
Online, moves arrive on server threads and spectators poll status(). The trap: legal_moves() looks like a read, but it isn't. It plays and takes back every candidate on the shared board, so a reader mid-way would see a knight where none moved. Every public method takes the game's lock, reads included (an RLock, since move() calls legal_moves()). One lock per game. And the server passes the connection's colour as by, so nobody moves for their opponent.
- A pinned piece: its geometry allows the move; make-and-test rejects it
- En passant that exposes the king along the rank: two pawns leave the rank at once; shortcuts miss it, make-and-test doesn't
- Castling: not out of, through or into check; the rook may cross an attacked b1
- A rook captured on its corner: that side loses the right, though its king never moved
- Promotion: no piece given means a queen; a suffix on an ordinary move is illegal; undo brings back the pawn
The code#
push and pop are the engine room, legal_moves is make-and-test, and status falls out of it: no legal moves and in check is checkmate; no legal moves and not in check is stalemate.
# part 4: the game: turn, rights, en passant, history, and legality by make-and-test
class Game:
def __init__(self, fen: str = START) -> None:
placement, turn, rights, ep = fen.split()[:4]
self.board = Board()
for row, line in enumerate(placement.split("/")):
f = 0
for ch in line:
if ch.isdigit():
f += int(ch)
continue
color = Color.WHITE if ch.isupper() else Color.BLACK
self.board.squares[(f, 7 - row)] = PIECES[ch.lower()](color)
f += 1
self.turn = Color.WHITE if turn == "w" else Color.BLACK
self.rights = frozenset(rights) - {"-"}
self.ep: Square | None = None if ep == "-" else sq(ep)
self.history: list[Undo] = []
self._lock = threading.RLock()
def push(self, m: Move) -> None:
sqs = self.board.squares
piece = sqs.pop(m.frm)
captured_at = (m.to[0], m.frm[1]) if m.en_passant else m.to
captured = sqs.pop(captured_at, None)
sqs[m.to] = PIECES[m.promotion](piece.color) if m.promotion else piece
if m.castle:
rf, rt = ROOK_JUMP[(m.frm, m.to)]
sqs[rt] = sqs.pop(rf)
self.history.append(Undo(m, piece, captured, captured_at, self.rights, self.ep))
self.rights = self.rights - LOSES_RIGHTS.get(m.frm, set()) - LOSES_RIGHTS.get(m.to, set())
double_step = isinstance(piece, Pawn) and abs(m.to[1] - m.frm[1]) == 2
self.ep = (m.frm[0], (m.frm[1] + m.to[1]) // 2) if double_step else None
self.turn = self.turn.other
def pop(self) -> Move:
u = self.history.pop()
sqs, m = self.board.squares, u.move
del sqs[m.to]
sqs[m.frm] = u.piece # a promoted queen goes back to being a pawn
if u.captured is not None:
sqs[u.captured_at] = u.captured
if m.castle:
rf, rt = ROOK_JUMP[(m.frm, m.to)]
sqs[rf] = sqs.pop(rt)
self.rights, self.ep, self.turn = u.rights, u.ep, self.turn.other
return m
def _castles(self) -> Iterator[Move]:
for right in sorted(self.rights):
k_from, k_to, r_from, r_to, empty = CASTLES[right]
if (Color.WHITE if right.isupper() else Color.BLACK) is not self.turn:
continue
if any(s in self.board.squares for s in empty):
continue
# not out of, through, or into check (the king crosses the rook's landing square)
if any(self.board.attacked(s, self.turn.other) for s in (k_from, r_to, k_to)):
continue
yield Move(k_from, k_to, castle=True)
def legal_moves(self) -> list[Move]:
with self._lock: # push/pop below change the board, so even readers hold the lock
mine = [(s, p) for s, p in self.board.squares.items() if p.color is self.turn]
pseudo = [m for s, p in mine for m in p.moves(self.board, s, self.ep)] + list(self._castles())
legal = []
for m in pseudo:
self.push(m)
if not self.board.attacked(self.board.king(self.turn.other), self.turn):
legal.append(m) # the side that just moved has not left its king in check
self.pop()
return legal
def in_check(self) -> bool:
return self.board.attacked(self.board.king(self.turn), self.turn.other)
def status(self) -> Status:
with self._lock:
if self.legal_moves():
return Status.CHECK if self.in_check() else Status.ONGOING
return Status.CHECKMATE if self.in_check() else Status.STALEMATE
def move(self, uci: str, by: Color | None = None) -> Status:
frm, to, promo = sq(uci[:2]), sq(uci[2:4]), uci[4:] or None
with self._lock:
if by is not None and by is not self.turn:
raise IllegalMove(f"it is {self.turn.value}'s turn")
for m in self.legal_moves():
if m.frm == frm and m.to == to and m.promotion == (promo or ("q" if m.promotion else None)):
self.push(m)
return self.status()
raise IllegalMove(f"{uci} is not a legal move for {self.turn.value}")
def undo(self) -> Move:
with self._lock:
if not self.history:
raise IllegalMove("nothing to undo")
return self.pop()Then the proof. Perft counts every legal move sequence to a given depth. The counts for well-known positions are published, and one bug in castling, en passant, promotion or pins changes them. The second position, known as "Kiwipete", is packed with all three special moves.
# part 5: short games that touch every rule, then perft against published counts
def perft(g: Game, depth: int) -> int:
moves = g.legal_moves()
if depth == 1:
return len(moves)
total = 0
for m in moves:
g.push(m)
total += perft(g, depth - 1)
g.pop()
return total
if __name__ == "__main__":
g = Game()
for uci in ["f2f3", "e7e5", "g2g4"]:
g.move(uci)
for bad, by in [("d8h4", Color.WHITE), ("e8e6", None)]:
try:
g.move(bad, by)
except IllegalMove as e:
print("rejected:", e)
print("d8h4:", g.move("d8h4").value) # fool's mate
print("undo", g.undo(), "->", g.status().value, "with", len(g.legal_moves()), "replies for black")
ep = Game()
for uci in ["e2e4", "a7a6", "e4e5", "d7d5"]:
ep.move(uci)
print("e5d6 en passant:", ep.move("e5d6").value, "| d5 now:", ep.board.squares.get(sq("d5")))
ep.undo()
print("undo: d5 is", ep.board.squares.get(sq("d5")), "| en passant square", name(ep.ep))
c = Game()
for uci in ["e2e4", "e7e5", "g1f3", "b8c6", "f1c4", "g8f6", "e1g1"]:
c.move(uci)
print("castled: g1 =", c.board.squares[sq("g1")], "f1 =", c.board.squares[sq("f1")], "| rights:", "".join(sorted(c.rights)))
try:
Game("4kr2/8/8/8/8/8/8/4K2R w K -").move("e1g1") # the black rook watches f1
except IllegalMove as e:
print("through check:", e)
print("a7a8:", Game("7k/P7/8/8/8/8/8/K7 w - -").move("a7a8").value) # the new queen checks along rank 8
under = Game("7k/P7/8/8/8/8/8/K7 w - -")
print("a7a8n:", under.move("a7a8n").value, under.board.squares[sq("a8")], "| undo:", under.undo(), under.board.squares[sq("a7")])
print("f2f7:", Game("7k/8/6K1/8/8/8/5Q2/8 w - -").move("f2f7").value)
print("perft start:", [perft(Game(), d) for d in (1, 2, 3)])
kiwipete = Game("r3k2r/p1ppqpb1/bn2pnp1/3PN3/1p2P3/2N2Q1p/PPPBBPPP/R3K2R w KQkq -")
print("perft kiwipete:", [perft(kiwipete, d) for d in (1, 2, 3)])rejected: it is black's turn
rejected: e8e6 is not a legal move for black
d8h4: checkmate
undo d8h4 -> ongoing with 30 replies for black
e5d6 en passant: ongoing | d5 now: None
undo: d5 is p | en passant square d6
castled: g1 = K f1 = R | rights: kq
through check: e1g1 is not a legal move for white
a7a8: check
a7a8n: ongoing N | undo: a7a8n P
f2f7: stalemate
perft start: [20, 400, 8902]
perft kiwipete: [48, 2039, 97862]Queen to h4 is fool's mate; one undo and black has 30 replies again. Undoing en passant puts the d5 pawn back and restores d6. After castling only black's rights remain, and a rook watching f1 blocks castling though the king isn't in check. The new queen checks along the eighth rank; the knight doesn't, and undo makes it a pawn again. Queen to f7 is stalemate. And perft matches the published counts: 20, 400 and 8,902 from the start; 48, 2,039 and 97,862 for Kiwipete.
Extending it: the follow-ups#
- "Add the other draws." Threefold repetition: count each position (placement, turn, rights, en passant square) in
push/pop. The fifty-move rule: a half-move clock inUndo, reset by pawn moves and captures. Insufficient material: a check on the pieces left. - "Play online." A
GameServicemaps games to ids and connections to colours. Store each game as its move list: the position is a replay of it. - "Show standard notation (Nbd2, exd6, O-O)." A formatter that uses
legal_moves()to tell when two knights can reach the same square. - "Add a computer opponent." Alpha-beta search over
pushandpop, with the evaluation as a strategy.
Common mistakes#
is_valid_moveon each piece, blind to pins, checks, castling history and en passant- A piece that stores its own position as well as the board, so the two drift apart
- Checking for check only when the king moves, so pinned pieces walk away from their king
- Castling without the attacked-square checks, or after the rook moved away and came back
- Undo that forgets castling rights or the en passant square
- Calling every position with no legal moves checkmate: without check, it's stalemate, a draw
Chess
Q1/3 White plays e1g1 (castles king-side). Which squares must black not attack?
Answer & explanation
Answer: B
Not while in check, not across an attacked square, not onto one. The queen-side rook may cross an attacked b1.
Q2/3 Black has just played d7d5. White's pawn on e5 captures en passant. Which squares change?
Answer & explanation
Answer: C
The pawn lands on the skipped square, d6, and the captured pawn leaves d5, beside it. That is why Undo stores captured_at.
Q3/3 Why does legal_moves() take the game's lock, though it leaves the position unchanged?
Answer & explanation
Answer: D
Make-and-test briefly changes the board for each candidate; a reader in between would see positions that never happened.
New lessons by email
New lessons, side quests and case files as they ship, at most once a week. No spam, unsubscribe anytime.
Discussion · …
Public: everyone reading this lesson sees comments. Be kind and keep it on the material.