What L5 looks like
- Separates pseudo-legal generation (pieces) from legality (game: "own king not left in check"). Gets checkmate and stalemate from "no legal moves" plus "in check or not".
- Handles castling preconditions fully (unmoved pieces, empty path, king not in check and not crossing an attacked square), en passant's one-move window, and promotion.
- Factors movement so that adding a piece type does not touch the game logic. Can explain why pawn attack squares differ from pawn move squares.
Requirements
- Two human players share one 8x8 board in the standard starting position. White moves first, then turns alternate. A move is a source and a destination square.
- Every piece moves by its own rules: king, queen, rook, bishop, knight, pawn (single and double push, diagonal capture).
- Special moves: castling (both sides), en passant, pawn promotion (queen by default, or a chosen piece). A move is legal only if it does not leave the mover's own king in check.
- After every move the game reports its status: active, check, checkmate (mover wins) or stalemate (draw).
- Illegal input (empty square, opponent's piece, illegal move, move after the game ended) is rejected and the state is unchanged.
- Out of scope: clocks, an AI opponent, networking, PGN/FEN import, and the draw rules for threefold repetition, the 50-move rule and insufficient material (see Extensions).
Core objects
-
Color(enum): WHITE or BLACK; knows its opponent.Status(enum): ACTIVE, CHECK, CHECKMATE, STALEMATE. -
Move(value object): source, destination, and flags for castling, en passant and promotion. -
Piece(abstract): a color, ahas_movedflag, the squares it attacks (targets) and the moves it may make (moves), ignoring check. -
Slider(abstract): moves any distance along its directions until blocked (Rook,Bishop,Queen).Stepper(abstract): moves by one fixed offset (King,Knight). -
Pawn: forward pushes, diagonal captures, en passant; its attack squares differ from its move squares. -
Board: the 8x8 grid, the current en passant square, square lookup, "is this square attacked by color X", and applying a move (including the rook hop for castling, pawn removal for en passant, promotion). -
Game: whose turn it is, the move history and the status; it filters pseudo-legal moves to legal ones, adds castling, validates input and detects check, checkmate and stalemate.
Class design
{"type":"excalidraw","elements":[
{"type":"rectangle","id":"game","x":160,"y":0,"width":260,"height":140,"label":{"text":"Game\nturn, status, history\nmove(src, dst, promotion)\nlegal_moves(color)\ncastles(color)"}},
{"type":"rectangle","id":"move","x":200,"y":260,"width":180,"height":70,"label":{"text":"Move"}},
{"type":"rectangle","id":"board","x":580,"y":-15,"width":240,"height":170,"label":{"text":"Board\nen_passant\nget(pos), squares(color)\nking(color)\nattacked(pos, by)\napply(move)"}},
{"type":"rectangle","id":"piece","x":590,"y":260,"width":220,"height":140,"label":{"text":"<<abstract>>\nPiece\ncolor, has_moved\ntargets(board, at)\nmoves(board, at)"}},
{"type":"rectangle","id":"slider","x":220,"y":520,"width":180,"height":70,"label":{"text":"Slider"}},
{"type":"rectangle","id":"stepper","x":610,"y":520,"width":180,"height":70,"label":{"text":"Stepper"}},
{"type":"rectangle","id":"pawn","x":1000,"y":520,"width":180,"height":70,"label":{"text":"Pawn"}},
{"type":"rectangle","id":"rook","x":0,"y":760,"width":180,"height":70,"label":{"text":"Rook"}},
{"type":"rectangle","id":"bishop","x":220,"y":760,"width":180,"height":70,"label":{"text":"Bishop"}},
{"type":"rectangle","id":"queen","x":440,"y":760,"width":180,"height":70,"label":{"text":"Queen"}},
{"type":"rectangle","id":"king","x":660,"y":760,"width":180,"height":70,"label":{"text":"King"}},
{"type":"rectangle","id":"knight","x":880,"y":760,"width":180,"height":70,"label":{"text":"Knight"}},
{"type":"arrow","id":"game-board","x":420,"y":70,"width":160,"height":0,"start":{"id":"game"},"end":{"id":"board"},"label":{"text":"has"}},
{"type":"arrow","id":"game-move","x":290,"y":140,"width":1,"height":120,"start":{"id":"game"},"end":{"id":"move"},"label":{"text":"has"}},
{"type":"arrow","id":"board-piece","x":700,"y":155,"width":1,"height":105,"start":{"id":"board"},"end":{"id":"piece"},"label":{"text":"holds"}},
{"type":"arrow","id":"slider-piece","x":310,"y":520,"width":330,"height":-120,"start":{"id":"slider"},"end":{"id":"piece"},"label":{"text":"extends"}},
{"type":"arrow","id":"stepper-piece","x":700,"y":520,"width":1,"height":-120,"start":{"id":"stepper"},"end":{"id":"piece"},"label":{"text":"extends"}},
{"type":"arrow","id":"pawn-piece","x":1090,"y":520,"width":-330,"height":-120,"start":{"id":"pawn"},"end":{"id":"piece"},"label":{"text":"extends"}},
{"type":"arrow","id":"rook-slider","x":90,"y":760,"width":180,"height":-170,"start":{"id":"rook"},"end":{"id":"slider"},"label":{"text":"extends"}},
{"type":"arrow","id":"bishop-slider","x":310,"y":760,"width":1,"height":-170,"start":{"id":"bishop"},"end":{"id":"slider"},"label":{"text":"extends"}},
{"type":"arrow","id":"queen-slider","x":530,"y":760,"width":-180,"height":-170,"start":{"id":"queen"},"end":{"id":"slider"},"label":{"text":"extends"}},
{"type":"arrow","id":"king-stepper","x":750,"y":760,"width":-70,"height":-170,"start":{"id":"king"},"end":{"id":"stepper"},"label":{"text":"extends"}},
{"type":"arrow","id":"knight-stepper","x":970,"y":760,"width":-230,"height":-170,"start":{"id":"knight"},"end":{"id":"stepper"},"label":{"text":"extends"}}
]}
Two ideas carry the design. First, pieces only know geometry: they produce pseudo-legal moves without thinking about check. Game owns the one rule that needs the whole position: "your king may not be in check after your move". Second, targets (squares a piece attacks) is separate from moves (squares it may go to). They are the same for every piece except the pawn, and Board.attacked uses only targets. That keeps attack detection simple and avoids recursion through castling.
Key flows
1. Making a move: Game.move(src, dst, promotion)
- Reject the move if the game is over,
srcis empty, or the piece is not the side to move. ThenGame.legal_moves(turn)asks every own piece formoves(board, at), addscastles(turn), and drops every candidate whereleaves_in_checkis true. That check applies the move to a copy of theBoardand asksattacked(king, opponent). - Find the candidate whose
src/dstmatch. If none matches, raise "illegal move". Attach the requested promotion piece if there is one. -
Board.apply(move)moves the piece. It also removes the captured pawn for en passant, hops the rook for castling, promotes a pawn on the last rank, setshas_moved, and records the new en passant square (only after a double pawn push).Gamethen appends tohistory, flipsturnand computes the status for the side now to move.
2. Status after a move
| In check? | Has a legal move? | Status |
|---|---|---|
| no | yes | ACTIVE |
| yes | yes | CHECK |
| yes | no | CHECKMATE (the side that just moved wins) |
| no | no | STALEMATE (draw) |
3. Castling: Game.castles(color)
- The king must be on its home square, unmoved, and not in check now. For each side, the rook must be on its corner, unmoved, of the same color, with every square between them empty.
- The squares the king crosses and lands on (f and g, or d and c) must not be attacked by the opponent.
- Emit
Move(e1, g1, castle=True)orMove(e1, c1, castle=True).Board.applymoves the rook to f1 or d1.
4. En passant. After a double push, Board.en_passant holds the square the pawn skipped. On the next move only, an enemy pawn whose diagonal targets include that square may capture onto it. apply removes the pawn beside it. Any other move clears the square.
Code
from __future__ import annotations
import copy
from abc import ABC, abstractmethod
from dataclasses import dataclass, replace
from enum import Enum
from typing import Optional
Pos = tuple[int, int] # (row, col); row 0 is White's back rank, col 0 is file a
class Color(Enum):
WHITE = "white"; BLACK = "black"
def other(self) -> Color:
return Color.BLACK if self is Color.WHITE else Color.WHITE
class Status(Enum):
ACTIVE = "active"; CHECK = "check"; CHECKMATE = "checkmate"; STALEMATE = "stalemate"
@dataclass(frozen=True)
class Move:
src: Pos
dst: Pos
castle: bool = False
en_passant: bool = False
promotion: Optional[type] = None
class Piece(ABC):
def __init__(self, color: Color) -> None:
self.color, self.has_moved = color, False
@abstractmethod
def targets(self, board: Board, at: Pos) -> list[Pos]: ... # attacked squares
def moves(self, board: Board, at: Pos) -> list[Move]:
return [Move(at, t) for t in self.targets(board, at)]
def _free_or_enemy(self, board: Board, t: Pos) -> bool:
return board.get(t) is None or board.get(t).color is not self.color
ORTHO = ((1, 0), (-1, 0), (0, 1), (0, -1))
DIAG = ((1, 1), (1, -1), (-1, 1), (-1, -1))
class Slider(Piece):
dirs: tuple[Pos, ...] = ()
def targets(self, board: Board, at: Pos) -> list[Pos]:
out: list[Pos] = []
for dr, dc in self.dirs:
t = (at[0] + dr, at[1] + dc)
while board.inside(t):
if self._free_or_enemy(board, t): out.append(t)
if board.get(t) is not None: break
t = (t[0] + dr, t[1] + dc)
return out
class Stepper(Piece):
steps: tuple[Pos, ...] = ()
def targets(self, board: Board, at: Pos) -> list[Pos]:
cands = [(at[0] + dr, at[1] + dc) for dr, dc in self.steps]
return [t for t in cands if board.inside(t) and self._free_or_enemy(board, t)]
class Rook(Slider): dirs = ORTHO
class Bishop(Slider): dirs = DIAG
class Queen(Slider): dirs = ORTHO + DIAG
class King(Stepper): steps = ORTHO + DIAG
class Knight(Stepper): steps = ((1, 2), (2, 1), (-1, 2), (-2, 1), (1, -2), (2, -1), (-1, -2), (-2, -1))
class Pawn(Piece):
d = property(lambda self: 1 if self.color is Color.WHITE else -1) # forward direction
def targets(self, board: Board, at: Pos) -> list[Pos]:
return [t for t in ((at[0] + self.d, at[1] - 1), (at[0] + self.d, at[1] + 1)) if board.inside(t)]
def moves(self, board: Board, at: Pos) -> list[Move]:
out = [Move(at, t) for t in self.targets(board, at)
if board.get(t) is not None and board.get(t).color is not self.color]
if board.en_passant in self.targets(board, at):
out.append(Move(at, board.en_passant, en_passant=True))
one, two = (at[0] + self.d, at[1]), (at[0] + 2 * self.d, at[1])
if board.inside(one) and board.get(one) is None:
out.append(Move(at, one))
if not self.has_moved and board.get(two) is None: out.append(Move(at, two))
return out
class Board:
def __init__(self) -> None:
self.grid: list[list[Optional[Piece]]] = [[None] * 8 for _ in range(8)]
self.en_passant: Optional[Pos] = None
@staticmethod
def inside(p: Pos) -> bool: return 0 <= p[0] < 8 and 0 <= p[1] < 8
def get(self, p: Pos) -> Optional[Piece]: return self.grid[p[0]][p[1]]
def put(self, p: Pos, piece: Optional[Piece]) -> None: self.grid[p[0]][p[1]] = piece
def squares(self, color: Color) -> list[tuple[Pos, Piece]]:
return [((r, c), pc) for r, row in enumerate(self.grid) for c, pc in enumerate(row) if pc and pc.color is color]
def king(self, color: Color) -> Pos: return next(pos for pos, pc in self.squares(color) if isinstance(pc, King))
def attacked(self, sq: Pos, by: Color) -> bool: return any(sq in pc.targets(self, pos) for pos, pc in self.squares(by))
def apply(self, m: Move) -> Optional[Piece]:
piece, captured = self.get(m.src), self.get(m.dst)
if m.en_passant:
captured = self.get((m.src[0], m.dst[1])); self.put((m.src[0], m.dst[1]), None)
if m.castle:
rook_from, rook_to = ((m.src[0], 7), (m.src[0], 5)) if m.dst[1] == 6 else ((m.src[0], 0), (m.src[0], 3))
self.put(rook_to, self.get(rook_from)); self.put(rook_from, None); self.get(rook_to).has_moved = True
self.put(m.dst, piece); self.put(m.src, None); piece.has_moved = True
if isinstance(piece, Pawn) and m.dst[0] in (0, 7):
self.put(m.dst, (m.promotion or Queen)(piece.color))
double = isinstance(piece, Pawn) and abs(m.dst[0] - m.src[0]) == 2
self.en_passant = ((m.src[0] + m.dst[0]) // 2, m.src[1]) if double else None
return captured
@classmethod
def standard(cls) -> Board:
b = cls()
for c, kind in enumerate([Rook, Knight, Bishop, Queen, King, Bishop, Knight, Rook]):
b.put((0, c), kind(Color.WHITE)); b.put((7, c), kind(Color.BLACK))
b.put((1, c), Pawn(Color.WHITE)); b.put((6, c), Pawn(Color.BLACK))
return b
class Game:
def __init__(self, board: Optional[Board] = None) -> None:
self.board, self.turn, self.status = board or Board.standard(), Color.WHITE, Status.ACTIVE
self.history: list[Move] = []
def leaves_in_check(self, m: Move, color: Color) -> bool:
trial = copy.deepcopy(self.board); trial.apply(m)
return trial.attacked(trial.king(color), color.other())
def castles(self, color: Color) -> list[Move]:
row, b, foe = (0 if color is Color.WHITE else 7), self.board, color.other()
king = b.get((row, 4))
if not isinstance(king, King) or king.color is not color or king.has_moved or b.attacked((row, 4), foe):
return []
out = []
for rook_col, between, king_path in ((7, (5, 6), (5, 6)), (0, (1, 2, 3), (3, 2))):
rook = b.get((row, rook_col))
if (isinstance(rook, Rook) and rook.color is color and not rook.has_moved
and all(b.get((row, c)) is None for c in between)
and not any(b.attacked((row, c), foe) for c in king_path)):
out.append(Move((row, 4), (row, king_path[-1]), castle=True))
return out
def legal_moves(self, color: Color) -> list[Move]:
cands = [m for pos, pc in self.board.squares(color) for m in pc.moves(self.board, pos)]
return [m for m in cands + self.castles(color) if not self.leaves_in_check(m, color)]
def move(self, src: Pos, dst: Pos, promotion: Optional[type] = None) -> Status:
if self.status in (Status.CHECKMATE, Status.STALEMATE): raise ValueError("game is over")
piece = self.board.get(src)
if piece is None or piece.color is not self.turn: raise ValueError("no piece of yours there")
if promotion not in (None, Queen, Rook, Bishop, Knight): raise ValueError("bad promotion")
match = next((m for m in self.legal_moves(self.turn) if (m.src, m.dst) == (src, dst)), None)
if match is None: raise ValueError("illegal move")
match = replace(match, promotion=promotion)
self.board.apply(match); self.history.append(match)
self.turn = self.turn.other()
in_check = self.board.attacked(self.board.king(self.turn), self.turn.other())
can_move = bool(self.legal_moves(self.turn))
self.status = (Status.CHECK if in_check else Status.ACTIVE) if can_move else (
Status.CHECKMATE if in_check else Status.STALEMATE)
return self.status
def sq(name: str) -> Pos: return int(name[1]) - 1, ord(name[0]) - ord("a") # "e2" -> (1, 4)
g = Game() # Fool's mate: 1. f3 e5 2. g4 Qh4#
for a, b in [("f2", "f3"), ("e7", "e5"), ("g2", "g4"), ("d8", "h4")]:
print(a, b, g.move(sq(a), sq(b)).value) # ... d8 h4 checkmate
Design patterns
-
Template method / inheritance for movement.
SliderandStepperimplementtargetsonce; concrete pieces only declaredirsorsteps. Adding a fairy piece (for example, an archbishop that combines bishop and knight) needs a few lines and no changes toGame. -
Polymorphism instead of type switches.
GameandBoardnever branch on the piece type to generate moves. They callpiece.moves(). The only type checks are rules that really belong to one kind of piece: castling needs a King and Rook, and promotion and en passant need a Pawn. -
Value object.
Moveis immutable and hashable. It can go into history and be compared, and promotion is attached withdataclasses.replace. -
Factory method.
Board.standard()builds the starting position. Tests and puzzles inject custom boards throughGame(board). - Prototype (copy-and-try). Legality is tested on a deep copy of the board. This is simple and obviously correct. Extensions shows the make/unmake alternative.
Extensions
-
Undo / takeback and performance. Make
Board.applyreturn an undo record: the captured piece, the previoushas_movedflags and the previous en passant square. AddBoard.undo(record). Thenleaves_in_checkcan do make/unmake instead of a deep copy, and the history becomes a stack of(Move, UndoRecord). A move engine would also cache king positions and use bitboards. -
Draw rules and resignation. Add
DRAWandRESIGNEDtoStatus. Keep a halfmove clock (reset on a pawn move or a capture) for the 50-move rule. Keep a counter of position hashes (Zobrist hashes of the board, side to move, castling rights and en passant square) for threefold repetition. Check insufficient material (K vs K, K+B vs K, K+N vs K) in the same place where status is computed. -
Online play and clocks. Wrap
Gamein aGameSessionwith twoPlayers, aClockper side, and athreading.Lock(or an actor or queue per game), so two concurrent requests cannot both pass validation. Publish an event after each move (observer) so spectators, a move log and a notation writer can subscribe. -
AI opponent and notation. A
Playerinterface withHumanPlayerandEnginePlayer(strategy) can choose fromlegal_moves. ANotationWriterturns aMoveand the board before it into SAN ("Nxe5+", "O-O"). AFenParsercan serve as a second board factory.
Interviewer expectations
Mid-level (L4)
- Clean class split: Board, Piece hierarchy, Move, Game; enums for color and status.
- Correct per-piece movement, including blocking for sliding pieces and pawn direction.
- Turn alternation and input validation; knows that check, checkmate and stalemate must be detected after each move.
Senior (L5)
- Separates pseudo-legal generation (pieces) from legality (game: "own king not left in check"). Gets checkmate and stalemate from "no legal moves" plus "in check or not".
- Handles castling preconditions fully (unmoved pieces, empty path, king not in check and not crossing an attacked square), en passant's one-move window, and promotion.
- Factors movement so that adding a piece type does not touch the game logic. Can explain why pawn attack squares differ from pawn move squares.
Staff (L6+)
- Discusses copy-and-try versus make/unmake and the cost of legal move generation. Knows when bitboards or incremental attack maps matter.
- Designs for draw rules (repetition hashing, 50-move clock), undo, serialization (FEN/PGN), and a concurrent online service with clocks and an event stream.
- Keeps the rule engine pure and deterministic so it can be shared by the server, the client-side preview and an AI player, and tested with puzzle positions.
Make it yours
Write each chapter yourself, check it against this answer, then let the coach grill you.
