Design a Chess Game: Low-Level Design of Pieces and Board
9 min readBytePatterns
Design a chess game in a low-level design interview: pieces own move shape, the board owns occupancy, the game owns turns and check, and where castling belongs.
"Design a chess game" is a favourite low-level design question because the domain is familiar and the rules are precise, so every design decision has a visible consequence. The trap is a Piece class that knows everything, or a Board with one enormous if per piece type. The clean answer splits one question, "is this move legal?", across three objects that each own exactly the data they need.
The problem it solves
You are asked for classes and their responsibilities, not a chess engine. The model should:
- Validate a move from one square to another.
- Add a new piece type, say a fairy-chess variant, without editing the board.
- Handle rules that span pieces: blocking, captures, check, castling.
- Track the game: whose turn it is and the move history.
The design is judged on where each rule lives: a rule in the wrong class needs data it does not own.
The intuition
Cut the legality question into the parts that need different data:
- The piece owns geometry. A rook moves along a rank or file; a knight moves two by one; a king moves one square. Answering "is this offset my shape?" needs only the offset. The piece never sees the board, so each piece is a small class with one method, and a new piece is a new class.
- The board owns occupancy. Only the board knows who stands where, so only it can answer "is anything in the way?" and "is the target empty or an enemy?". For sliding pieces (rook, bishop, queen) it walks the squares strictly between; knights jump, so it checks only the target.
- The game owns rules across pieces and time. Whose turn it is, and the rule that a move must not leave your own king attacked, which needs the whole position after the move. Castling, en passant and pawn promotion belong here too: they depend on two pieces at once, or on history such as "has this rook ever moved?".
The flow for one request is: the game checks the turn, the board finds the piece on the from-square, the piece answers shape, the board answers path and target, and finally the game tries the move on a copy and rejects it if its own king would be in check. That last step is why a pinned piece cannot move off its line even though its shape and path are fine.
Two forms are worth naming. Pseudo-legal moves pass the piece and board checks; legal moves also keep the king safe. Most engines generate pseudo-legal moves and filter, which is exactly this layering.
Watch it run
The animation puts two objects beside a four-by-four board. The piece owns shape; the board owns who is standing where. Rook to the top of its file: the rook is asked one thing only, same rank or same file? It answers yes and stops, because it has never been told which squares are occupied; that is not its data. The board answers the other half, nothing in the way and nothing of ours on the target, and only then does the rook move: legal, True. The knight's rule is different, and it is all the knight knows: an offset of two and one. A knight jumps, so there is no path to check; the board only looks at the target, and it is empty: True again. Rewind, and try the rook one square sideways onto the square its own knight is standing on. Shape says yes; the board says no. Neither object could have refused this alone. And since neither ever needed the other's data, a new piece is a new class and nothing else: zero existing classes touched.
Chess Board Model
Step 1 of 9
Two objects, two halves of one question. The piece owns shape; the board owns who is standing where.
The same interactive animation as the lesson — step through it with the controls.
The code
Pieces with one geometry method each, a board that checks path and target, and a game that enforces turns and king safety. The last lines show a pin: the board allows the rook's sideways move, the game refuses it:
def sign(x):
return (x > 0) - (x < 0)
class Piece:
slides = False # does it travel across the squares between?
def __init__(self, colour):
self.colour = colour
def shape_ok(self, dr, dc): # geometry only: the piece never sees the board
raise NotImplementedError
class Rook(Piece):
slides = True
def shape_ok(self, dr, dc): return (dr == 0) != (dc == 0)
class Bishop(Piece):
slides = True
def shape_ok(self, dr, dc): return dr != 0 and abs(dr) == abs(dc)
class Queen(Piece):
slides = True
def shape_ok(self, dr, dc): return Rook.shape_ok(self, dr, dc) or Bishop.shape_ok(self, dr, dc)
class Knight(Piece):
def shape_ok(self, dr, dc): return sorted((abs(dr), abs(dc))) == [1, 2]
class King(Piece):
def shape_ok(self, dr, dc): return max(abs(dr), abs(dc)) == 1
class Board:
"""Owns occupancy: who stands where, and what is in the way."""
def __init__(self, at):
self.at = dict(at) # (row, col) -> Piece
def between(self, a, b):
dr, dc = sign(b[0] - a[0]), sign(b[1] - a[1])
r, c = a[0] + dr, a[1] + dc
while (r, c) != b:
yield r, c
r, c = r + dr, c + dc
def can_move(self, a, b):
piece = self.at.get(a)
if piece is None or not (0 <= b[0] < 8 and 0 <= b[1] < 8) or a == b:
return False
if not piece.shape_ok(b[0] - a[0], b[1] - a[1]): # ask the piece
return False
if piece.slides and any(sq in self.at for sq in self.between(a, b)):
return False # path blocked
target = self.at.get(b)
return target is None or target.colour != piece.colour # empty, or a capture
def attacked(self, sq, by):
return any(p.colour == by and self.can_move(a, sq) for a, p in self.at.items())
class Game:
"""Owns the rules that span pieces and time: whose turn, and never leave your king in check."""
def __init__(self, board, turn="white"):
self.board, self.turn, self.history = board, turn, []
def legal(self, a, b):
piece = self.board.at.get(a)
if piece is None or piece.colour != self.turn or not self.board.can_move(a, b):
return False
after = Board(self.board.at)
after.at[b] = after.at.pop(a)
king = next(sq for sq, p in after.at.items() if isinstance(p, King) and p.colour == self.turn)
return not after.attacked(king, "black" if self.turn == "white" else "white")
def move(self, a, b):
if not self.legal(a, b):
raise ValueError("illegal move %s -> %s" % (a, b))
self.board.at[b] = self.board.at.pop(a)
self.history.append((a, b))
self.turn = "black" if self.turn == "white" else "white"
b = Board({(0, 0): Rook("white"), (1, 0): Knight("white")})
print(b.can_move((0, 0), (0, 5)), b.can_move((1, 0), (2, 2)), b.can_move((0, 0), (1, 0)))
# True True False
print(b.can_move((0, 0), (3, 0))) # False the knight on (1, 0) blocks the file
pinned = Game(Board({(7, 4): King("white"), (6, 4): Rook("white"),
(0, 4): Rook("black"), (0, 0): King("black")}))
print(pinned.board.can_move((6, 4), (6, 0)), pinned.legal((6, 4), (6, 0))) # True False
print(pinned.legal((6, 4), (0, 4))) # True capturing the attacker is fine
pinned.move((6, 4), (0, 4))
print(pinned.turn, pinned.history) # black [((6, 4), (0, 4))]
The first line is the lesson's own example. Validation asks "is this one move allowed?"; a move generator answers the opposite question, "where can this piece go?", by walking each ray until something stops it. The two are written independently, so they make a good cross-check: on 1,500 seeded random positions, every piece's validated targets must equal its generated ones, and every generated move must be legal exactly when no enemy ray reaches the king afterwards:
import random
STEPS = {Rook: [(1, 0), (-1, 0), (0, 1), (0, -1)],
Bishop: [(1, 1), (1, -1), (-1, 1), (-1, -1)],
Knight: [(1, 2), (2, 1), (-1, 2), (-2, 1), (1, -2), (2, -1), (-1, -2), (-2, -1)],
King: [(dr, dc) for dr in (-1, 0, 1) for dc in (-1, 0, 1) if (dr, dc) != (0, 0)]}
STEPS[Queen] = STEPS[Rook] + STEPS[Bishop]
def generate(at, a):
"""Brute force the other way round: walk each ray from the piece until something stops it."""
piece, out = at[a], set()
for dr, dc in STEPS[type(piece)]:
r, c = a[0] + dr, a[1] + dc
while 0 <= r < 8 and 0 <= c < 8:
if (r, c) in at:
if at[(r, c)].colour != piece.colour:
out.add((r, c)) # a capture ends the ray
break
out.add((r, c))
if not piece.slides:
break # knights and kings take one step
r, c = r + dr, c + dc
return out
SQUARES = [(r, c) for r in range(8) for c in range(8)]
rng = random.Random(33)
ok = True
for _ in range(1_500):
cells = rng.sample(SQUARES, rng.randint(2, 10))
at = {cells[0]: King("white"), cells[1]: King("black")}
for sq in cells[2:]:
at[sq] = rng.choice([Rook, Bishop, Queen, Knight])(rng.choice(["white", "black"]))
board, game = Board(at), Game(Board(at), rng.choice(["white", "black"]))
for a, piece in at.items():
ok &= {b for b in SQUARES if board.can_move(a, b)} == generate(at, a)
if piece.colour != game.turn:
continue
for b in generate(at, a): # legal = pseudo-legal and king safe
after = dict(at)
after[b] = after.pop(a)
king = next(s for s, p in after.items() if isinstance(p, King) and p.colour == game.turn)
safe = not any(king in generate(after, s) for s, p in after.items() if p.colour != game.turn)
ok &= game.legal(a, b) == safe
print(ok) # True
The complexity
- Validating one move:
O(1)for shape, plus at most six squares between on an 8×8 board. - King safety: one copy, then
O(p)validations forppieces. Real engines cache attack maps; an interview answer need not. - Adding a piece type: one class, no changes to
BoardorGame.
Where it goes wrong
- Pieces that read the board. Then every piece duplicates path-walking and occupancy logic.
- A board with a branch per piece type. Adding a piece edits the board, the opposite of open for extension.
- Forgetting pins. Shape and path pass, but the king is exposed; check safety after the move, not before.
- Castling on the king or rook class. It needs both pieces, their move history and attacked squares, so it belongs to the game.
- Pawns as an afterthought. Direction depends on colour, captures differ from moves, and promotion replaces the piece. Model them early or say explicitly that you are leaving them out.
When it shows up in interviews
As "design chess", "design tic-tac-toe" or any board game, where the interviewer probes responsibilities, extensibility and one tricky rule. It sits with the other low-level design cases: the parking lot, the elevator, and patterns like polymorphism through interfaces, which is exactly how shape_ok lets the board treat every piece alike.
How to say it in an interview
"I'd split legality across three classes. Each Piece subclass answers only geometry: is this offset my shape, and do I slide or jump. The Board owns occupancy, so it checks the squares between for sliders and that the target is empty or an enemy. The Game owns turns, history and rules across pieces: it applies the move to a copy and rejects it if its own king is attacked, which handles pins. Castling, en passant and promotion live in Game, because they need two pieces or history. A new piece is a new subclass; Board and Game don't change."