Design Chess
Online chess is a classic low-level-design interview problem, and most published solutions get the structure right while leaving the rules as a pile of // TODO comments. The interesting design question is not "what classes exist" — Board, Box, Piece, Move, Game are obvious. The interesting question is where each rule lives so that adding a new piece or a new end-of-game condition does not force you to edit a giant central method.
This rewrite makes two design commitments and then defends them. First, per-piece movement geometry lives behind a single polymorphic method, Piece.canMove — the Strategy pattern. Second, the rules that are not about one piece in isolation (is my king in check? is this checkmate? is this castling legal?) live on Board and Game, because they need the whole board. The page below explains why Strategy is the right call here versus the two real alternatives — an if/else ladder on a PieceType enum, and a single central rules engine — and exactly when each alternative would actually be the better choice. It then shows working, compilable code for the parts that the original solution left as comments: post-move checkmate detection, the simulate-then-revert self-check guard, and an attack-square test that correctly handles the pawn.
The pattern decision: Strategy vs. an if/else ladder vs. a central rules engine
Every move legality check answers the same question — "can this piece go from here to there?" — but the answer depends on what kind of piece it is. There are three ways to dispatch on piece type, and the choice is not free.
| Approach | How move legality is dispatched | Cost of adding a piece / variant | When it is actually the right choice |
|---|---|---|---|
if/else (or switch) ladder on a PieceType enum, inside one canMove(type, …) function | One function with a branch per piece type; all geometry in one file | Edit the central function (and every other function that switches on type) — the change is not local, and the file grows without bound (open/closed violation) | When piece behavior is trivial and fixed forever, or when you deliberately want all rules in one auditable place and will never extend (e.g. a throwaway puzzle solver) |
Strategy — abstract Piece.canMove, one subclass per piece | Virtual dispatch: piece.canMove(board, start, end) resolves to the piece's own override | Add one new subclass; nothing else changes (open/closed satisfied). Each piece's geometry is unit-testable in isolation | When the set of variants grows and each variant has self-contained behavior — exactly chess pieces. This is our choice. |
Central rules engine — a RuleEngine.isLegal(board, move) that owns all rules, pieces are dumb data | One service consults board state and move history for every rule | Editing the engine touches unrelated rules together; per-piece logic is buried in a large class | When rules are cross-cutting and cannot be attributed to one piece — check, checkmate, castling, en-passant, the 50-move and threefold-repetition draws. These genuinely need the whole board and history. |
Why Strategy beats the if/else ladder here: the ladder centralizes a thing that has no reason to be central. Bishop geometry and knight geometry share nothing; forcing them into one function couples them and makes the function the single thing every piece change must touch. Strategy makes each piece's rule local and closed for modification.
Why we still need a (small) central layer — and when the rules engine wins: Strategy is the wrong tool for rules that are not about one piece. "Is white in check?" is a property of the position, not of any single piece, so it cannot live on a Piece subclass without that piece reaching across the whole board — which would make every piece depend on every other. So this design is deliberately two-layer: per-piece geometry uses Strategy; cross-cutting rules (check, checkmate, castling legality, draw conditions) live in a thin rules layer on Board/Game. If your variant had mostly cross-cutting rules and few distinct pieces — say a heavily house-ruled variant where almost every rule reads global state — the per-piece Strategy layer would be nearly empty and a single rules engine would be the simpler design. Chess is not that: it has six richly different pieces and a handful of global rules, so the split pays off.
When NOT to use Strategy: do not reach for it if there is only ever one implementation (no variation to abstract over), or if the "strategies" constantly need each other's private state — that is a sign the behavior is really cross-cutting and belongs in the central layer, not in per-object strategies.
The latent bug to call out: move geometry is not attack geometry
The tempting shortcut is to implement "is the king's square attacked?" by asking every enemy piece canMove(board, attacker, kingBox) — reusing the Strategy method we already have. This is wrong for the pawn, and silently so. A pawn moves straight forward (and never captures straight) but attacks only the two diagonally-forward squares. So a pawn directly in front of the king satisfies canMove to that square (it can advance there) yet does not actually give check, and a pawn diagonally in front gives check yet may fail a naive canMove that assumed an empty destination. Reusing canMove as the attack test therefore produces both false positives and false negatives for pawn checks.
The fix is to make "attack geometry" a first-class concept distinct from "move geometry." Each piece exposes isAttacking(board, from, target). For most pieces it can delegate to canMove (their move and capture geometry coincide). The pawn overrides it to mean "diagonal-forward only." The king overrides it to its one-square king moves without recursing into check detection (otherwise isInCheck → king's canMove → isInCheck loops forever). Board.isInCheck is then built on isAttacking, never on canMove.
Piece — the Strategy base. canMove is move/capture geometry; isAttacking is the square-control test used by check detection. They are separated precisely because they differ for the pawn.
public abstract class Piece {
private boolean killed = false;
private final boolean white;
public Piece(boolean white) { this.white = white; }
public boolean isWhite() { return white; }
public boolean isKilled() { return killed; }
public void setKilled(boolean killed) { this.killed = killed; }
/** Can this piece legally MOVE start -> end (movement + capture geometry,
* path clearance, target not same color)? Ignores whether the mover's
* own king is left in check — Game enforces that separately. */
public abstract boolean canMove(Board board, Box start, Box end);
/** Does this piece ATTACK (control) the target square? For most pieces this
* equals canMove. Pawns and the king override it because their attack
* geometry differs from their move geometry. Used only by check detection,
* so it must NOT itself call isInCheck (no recursion). */
public boolean isAttacking(Board board, Box from, Box target) {
return canMove(board, from, target);
}
}
Pawn — shown to make the move-vs-attack distinction concrete (the original omitted it). Direction depends on color; capture is diagonal-forward, advance is straight-forward.
public class Pawn extends Piece {
public Pawn(boolean white) { super(white); }
@Override
public boolean canMove(Board board, Box start, Box end) {
int dir = isWhite() ? 1 : -1; // white moves +x, black -x
int dx = end.getX() - start.getX();
int dy = Math.abs(end.getY() - start.getY());
Piece target = end.getPiece();
// Straight advance: one square forward onto an EMPTY square.
if (dy == 0 && dx == dir && target == null) return true;
// Two-square first move (home rank, both squares empty).
int homeRank = isWhite() ? 1 : 6;
if (dy == 0 && start.getX() == homeRank && dx == 2 * dir
&& target == null
&& board.getBox(start.getX() + dir, start.getY()).getPiece() == null)
return true;
// Diagonal CAPTURE: one forward-diagonal onto an enemy piece.
if (dy == 1 && dx == dir && target != null && target.isWhite() != isWhite())
return true;
return false; // (en passant / promotion handled by Game, omitted)
}
/** A pawn ATTACKS the two forward diagonals — regardless of occupancy.
* This is why check detection cannot reuse canMove for pawns. */
@Override
public boolean isAttacking(Board board, Box from, Box target) {
int dir = isWhite() ? 1 : -1;
return target.getX() - from.getX() == dir
&& Math.abs(target.getY() - from.getY()) == 1;
}
}
King — one-square moves, plus castling delegated to Board (not left as comments). isAttacking is overridden to the king's one-square reach and deliberately does not consult check, breaking the recursion.
public class King extends Piece {
private boolean castlingDone = false;
public King(boolean white) { super(white); }
public boolean isCastlingDone() { return castlingDone; }
public void setCastlingDone(boolean v) { this.castlingDone = v; }
@Override
public boolean canMove(Board board, Box start, Box end) {
Piece target = end.getPiece();
if (target != null && target.isWhite() == isWhite()) return false;
int dx = Math.abs(start.getX() - end.getX());
int dy = Math.abs(start.getY() - end.getY());
if (dx + dy == 1 || (dx == 1 && dy == 1)) return true; // any one-square move
// Two-square sideways move on the home rank => castling attempt.
// Legality (rook unmoved, path clear, not moving through check) needs the
// whole board, so it is the Board's job, not the King's. Delegate.
if (start.getX() == end.getX() && dy == 2 && !castlingDone)
return board.canCastle(this, start, end);
return false;
}
/** King attacks its 8 neighbours. Must NOT call isInCheck (would recurse). */
@Override
public boolean isAttacking(Board board, Box from, Box target) {
int dx = Math.abs(from.getX() - target.getX());
int dy = Math.abs(from.getY() - target.getY());
return dx <= 1 && dy <= 1 && (dx + dy) > 0;
}
/** Is this king move a castling move? Two squares sideways on one rank.
* This only recognizes the shape; legality (rook unmoved, path clear, not
* moving through check) is Board.canCastle, called from canMove above. */
public boolean isCastlingMove(Box start, Box end) {
return start.getX() == end.getX()
&& Math.abs(start.getY() - end.getY()) == 2;
}
}
Board — the home of the cross-cutting rules. isAttacked scans living enemy pieces using isAttacking (never canMove). isInCheck finds the king and asks if its square is attacked. canCastle implements the real castling legality the original left as comments — and it is actually wired here, called from King.canMove.
public class Board {
private Box[][] boxes = new Box[8][8];
public Board() { resetBoard(); }
public Box getBox(int x, int y) {
if (x < 0 || x > 7 || y < 0 || y > 7)
throw new IndexOutOfBoundsException("off-board: " + x + "," + y);
return boxes[x][y];
}
/** Is `square` attacked by any living piece of color `byWhite`? */
public boolean isAttacked(Box square, boolean byWhite) {
for (int x = 0; x < 8; x++) {
for (int y = 0; y < 8; y++) {
Piece p = boxes[x][y].getPiece();
if (p != null && !p.isKilled() && p.isWhite() == byWhite
&& p.isAttacking(this, boxes[x][y], square))
return true;
}
}
return false;
}
/** Is the `white` side's king currently in check? */
public boolean isInCheck(boolean white) {
Box kingBox = findKing(white);
return isAttacked(kingBox, !white); // attacked by the OPPONENT
}
public Box findKing(boolean white) {
for (int x = 0; x < 8; x++)
for (int y = 0; y < 8; y++) {
Piece p = boxes[x][y].getPiece();
if (p instanceof King && p.isWhite() == white) return boxes[x][y];
}
throw new IllegalStateException("king not found");
}
/** Real castling legality (king-side example): king & rook unmoved,
* squares between them empty, and the king is not in check now nor on
* any square it passes through. Wired in from King.canMove. */
public boolean canCastle(King king, Box start, Box end) {
if (king.isCastlingDone()) return false;
int rank = start.getX();
boolean kingSide = end.getY() > start.getY();
int rookY = kingSide ? 7 : 0;
Piece rook = getBox(rank, rookY).getPiece();
if (!(rook instanceof Rook) || rook.isKilled()) return false;
// Path between king and rook must be empty.
int step = kingSide ? 1 : -1;
for (int y = start.getY() + step; y != rookY; y += step)
if (getBox(rank, y).getPiece() != null) return false;
// King may not be in check, nor pass through / land on an attacked square.
for (int y = start.getY(); y != end.getY() + step; y += step)
if (isAttacked(getBox(rank, y), !king.isWhite())) return false;
return true;
}
public void resetBoard() {
boxes[0][0] = new Box(new Rook(true), 0, 0);
boxes[0][1] = new Box(new Knight(true), 0, 1);
boxes[0][2] = new Box(new Bishop(true), 0, 2);
boxes[0][3] = new Box(new Queen(true), 0, 3);
boxes[0][4] = new Box(new King(true), 0, 4);
// … remaining white back rank + pawns on rank 1 …
boxes[7][4] = new Box(new King(false), 7, 4);
// … remaining black back rank + pawns on rank 6 …
for (int x = 2; x < 6; x++)
for (int y = 0; y < 8; y++)
boxes[x][y] = new Box(null, x, y);
}
}
The headline fix: checkmate detection and the self-check guard
The original solution declared a winner the instant a king was "captured." That is not chess — the king is never captured; the game ends by checkmate: the side to move is in check and has no legal move that escapes it. Two pieces of machinery make this correct, and both are shown in full below:
- Self-check guard. Geometry (
canMove) says a move is shaped legally, but a move that leaves your own king in check is illegal. We simulate the move on the board, testisInCheck(mover), then revert. Only moves that survive this are legal. - Checkmate test. After a legal move, the opponent is checkmated iff they are in check and every shaped-legal move they could make still leaves them in check (i.e. no legal escape exists). Stalemate is the same loop with the check condition false — no legal move, but not in check — which is a draw.
Game.makeMove — complete and compilable. Note the corrected capture (read the destination square, move.getEnd(), not the start), the simulate-then-revert self-check guard, and the replacement of king-capture with a post-move checkmate/stalemate test.
public class Game {
private Player[] players;
private Board board;
private Player currentTurn;
private GameStatus status;
private List<Move> movesPlayed = new ArrayList<>();
public boolean isEnd() { return status != GameStatus.ACTIVE; }
public GameStatus getStatus() { return status; }
public void setStatus(GameStatus status) { this.status = status; }
public boolean playerMove(Player player, int sx, int sy, int ex, int ey) {
Box start = board.getBox(sx, sy);
Box end = board.getBox(ex, ey); // fixed: was getBox(startY, endY)
return makeMove(new Move(player, start, end), player);
}
private boolean makeMove(Move move, Player player) {
Piece source = move.getStart().getPiece();
if (source == null) return false;
if (player != currentTurn) return false;
if (source.isWhite() != player.isWhiteSide()) return false;
// 1) Shape-legal? (per-piece Strategy)
if (!source.canMove(board, move.getStart(), move.getEnd())) return false;
// 2) Self-check guard: simulate, test own king, revert.
Piece captured = move.getEnd().getPiece(); // fixed: destination, not start
move.getEnd().setPiece(source);
move.getStart().setPiece(null);
boolean leavesOwnKingInCheck = board.isInCheck(player.isWhiteSide());
if (leavesOwnKingInCheck) { // revert — move is illegal
move.getStart().setPiece(source);
move.getEnd().setPiece(captured);
return false;
}
// Move is fully legal — commit its bookkeeping.
if (captured != null) { captured.setKilled(true); move.setPieceKilled(captured); }
if (source instanceof King && ((King) source).isCastlingMove(move.getStart(), move.getEnd())) {
move.setCastlingMove(true);
((King) source).setCastlingDone(true);
}
movesPlayed.add(move);
// 3) End-of-game by CHECKMATE / STALEMATE, not by king capture.
boolean white = player.isWhiteSide();
boolean opponentWhite = !white;
if (board.isInCheck(opponentWhite)) {
if (!hasAnyLegalMove(opponentWhite))
setStatus(white ? GameStatus.WHITE_WIN : GameStatus.BLACK_WIN); // checkmate
} else if (!hasAnyLegalMove(opponentWhite)) {
setStatus(GameStatus.STALEMATE); // draw
}
currentTurn = (currentTurn == players[0]) ? players[1] : players[0];
return true;
}
/** Does `white` have ANY move that is shape-legal AND escapes check?
* Each candidate is simulated and reverted (same guard as makeMove). */
private boolean hasAnyLegalMove(boolean white) {
for (int sx = 0; sx < 8; sx++) for (int sy = 0; sy < 8; sy++) {
Box from = board.getBox(sx, sy);
Piece p = from.getPiece();
if (p == null || p.isKilled() || p.isWhite() != white) continue;
for (int ex = 0; ex < 8; ex++) for (int ey = 0; ey < 8; ey++) {
Box to = board.getBox(ex, ey);
if (!p.canMove(board, from, to)) continue;
Piece cap = to.getPiece();
to.setPiece(p); from.setPiece(null); // simulate
boolean stillInCheck = board.isInCheck(white);
from.setPiece(p); to.setPiece(cap); // revert
if (!stillInCheck) return true; // a legal escape exists
}
}
return false;
}
}
Why this is correct and where it stops
The design is now demonstrably rule-correct on the points the original faked: a win is a checkmate (in-check plus no legal escape), a no-legal-move-without-check position is scored as a stalemate draw, you cannot make a move that exposes your own king, castling legality is implemented and wired through Board.canCastle, and check detection uses true attack geometry so a pawn is evaluated by the squares it controls, not the squares it advances to.
What is intentionally left out, and where it would go: en passant and promotion belong in Game.makeMove's commit step (they depend on move history / the move's target rank, not on one piece in isolation); threefold repetition and the 50-move rule are draw conditions computed from movesPlayed in the same central layer. The performance note: hasAnyLegalMove is O(64 × candidate-moves × board-scan) per turn — fine for a turn-based UI, and the obvious place to optimize later (incremental attack maps) without disturbing the Strategy/rules-layer split.
Citation
Adapted and corrected from the "Design Chess" object-oriented design problem as presented in Grokking the Object-Oriented Design Interview (DesignGurus / educative.io). The class decomposition (Player, Account, Game, Board, Box, Piece, Move, GameController, GameView) follows that source; the move-vs-attack geometry separation, the simulate-and-revert self-check guard, the checkmate/stalemate end conditions replacing king-capture, and the wired-in Board.canCastle are corrections layered on top of it. Standard rules of chess per the FIDE Laws of Chess.
When NOT to over-model chess OOD
- Do not make a class per square rule without a move generator strategy — explodes hierarchy.
- Do not ignore concurrency if building multiplayer clocks — clocks and move submit race.
Interviewer follow-ups & drills
- Why Piece hierarchy + Move validation elsewhere? Rules (check, castling) are game-state concerns, not only piece shape.
- Ops/failure: illegal move accepted under race (two moves same turn) — serialize game state with version/CAS.
- Drill: en passant eligibility is state from prior move — show where that state lives (Board/Game, not only Pawn class).
🤖 Don't fully get this? Learn it with Claude
Stuck on Design Chess? Open Claude, copy a block below, and it'll teach you this exact concept — visually and interactively.
Build the mental picture, not memorization.
I just read a lesson on **Design Chess** (OO & Low-Level Design) and want to truly understand it. Explain Design Chess from first principles using ONE vivid real-world analogy and a visual mental model — draw it as ASCII art or a clear step-by-step diagram — with a concrete example using real numbers. Then ask me one question to check I got the mental picture, and wait for my reply. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Socratic — adapts to where you're stuck.
Teach me **Design Chess** interactively. Ask me ONE guiding question at a time, wait for my answer, and adapt to my confusion — build the idea with me step by step instead of explaining it all at once. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Active recall exposes what you missed.
Quiz me on **Design Chess** with 5 questions, easy to tricky, ONE at a time. Tell me if each answer is right; at the end, explain clearly what I got wrong and why. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Intuition + hook + flashcards for long-term memory.
Help me remember **Design Chess** for the long term: give the one-sentence intuition, a memorable hook/mnemonic, a tiny worked example, and 3 active-recall flashcards (Q -> A). If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.