Chess Game

Pieces with movement rules, Board, Move, Game state. The cleanest polymorphism problem in the OOD canon.

System Intermediate
15 min read
ood case-study chess command-pattern state-pattern

Context#

A two-player chess game tracks an 8×8 board, two sets of six piece types (king, queen, rook, bishop, knight, pawn), a turn counter, a move history, and a game status that resolves to one of Checkmate | Stalemate | Draw | Resigned or InProgress. Players alternate moves; each move must be legal (the piece can move there, the destination isn’t blocked by a friendly piece, and the player’s own king isn’t left in check). The system must enforce castling, en passant, pawn promotion, and the various draw conditions (fifty-move rule, threefold repetition, insufficient material).

The problem is the cleanest polymorphism prompt in the OOD canon. The Game class does not switch on piece type — piece.getValidMoves(board, position) is the only call it makes. Every other case study has at least one place where you reluctantly admit an instanceof; chess does not. That is what makes it the favourite prompt for senior rounds.

The interviewer’s hidden objectives, in roughly the order they will be tested:

  • Can you clarify scope — full FIDE rules vs. a tutorial-grade subset — without spinning out?
  • Can you draw the class hierarchy with Piece abstract and six concrete subclasses?
  • Can you express movement rules polymorphically, with no switch (piece.type) in Game?
  • Can you model Move as a Command so undo and history fall out naturally?
  • Can you defend trade-offs when the interviewer pushes (AI opponent, network play, three-fold repetition, PGN export)?

Requirements (functional and non-functional)#

Clarifying in the room is the most points-bearing part. The scope below is the one most interviewers expect; anything outside it should be flagged out-of-scope so you can finish.

Functional — in scope.

  • Standard 8×8 board with the conventional starting position.
  • Two players, alternating moves. White moves first.
  • All six piece types with their full movement rules, including:
    • Castling (king-side and queen-side) under FIDE preconditions.
    • En passant capture.
    • Pawn promotion to queen / rook / bishop / knight (player’s choice).
  • A move is rejected if it leaves the moving player’s king in check.
  • The game ends on checkmate, stalemate, resignation, or draw (fifty-move rule, threefold repetition, insufficient material, agreed draw).
  • Undo the last move (one level deep for the interview; deeper as a follow-up).

Functional — out of scope (called out explicitly). Chess engine / AI opponent, network multiplayer, time controls and clocks, opening-book databases, PGN parsing, chess variants (Chess960, atomic). Acknowledge them so the interviewer knows you saw them.

Non-functional.

  • A two-player human game in process; no concurrency requirements beyond “moves alternate”.
  • Latency target: move validation under 10 ms (matters when extending to AI move generation).
  • The design must be testable without a UI: positions construct programmatically, moves apply by API.
  • The board representation must be cheap to copy for hypothetical-move checks (the check-detection step plays the move on a clone).

Use case diagram#

┌────────────────┐ ┌────────────────┐
│ Player W │ │ Player B │
└────────┬───────┘ └────────┬───────┘
│ │
┌──────────────────┼─────┬────────────────────┤
▼ ▼ │ ▼
[make move] [offer draw]│ [resign]
│ │ │ │
└──────────────────┼─────┴────────────────────┘
▼
┌─────────────────────────────────────┐
│ Chess Game │
└────────┬────────────────────────────┘
▼
┌──────────────────────────────────────────┐
│ [validate move] [detect check/mate/draw]│
│ [undo move] [promote pawn] │
└──────────────────────────────────────────┘

Two symmetric primary actors; the system’s interesting use cases all live downstream of make move.

Class diagram#

┌──────────────────────────┐
│ Game │
├──────────────────────────┤
│ board : Board │
│ history : List<Move> │ ◇── Command log
│ turn : Color │
│ status : GameStatus │ ◇── State (enum-driven)
├──────────────────────────┤
│ play(move) : Result │
│ undo() │
│ legalMoves(color) : List │
└─────────────┬────────────┘
│ owns
▼
┌──────────────────────────┐
│ Board │
├──────────────────────────┤
│ squares : Square[8][8] │
├──────────────────────────┤
│ at(position) : Piece? │
│ place(piece, position) │
│ move(from, to) │
│ snapshot() : Board │
│ kingPosition(color) │
└──────────────────────────┘
┌──────────────────────────────────────────┐
│ Piece (abstract) │
├──────────────────────────────────────────┤
│ color : Color │
│ hasMoved : boolean │
├──────────────────────────────────────────┤
│ getValidMoves(Board, Position) : List<Position> │ ◀── the only polymorphic call Game makes
│ symbol() : char │
└──────────────────────────────────────────┘
▲
┌──────┬──────┬─────┴────┬──────┬──────┬──────┐
▼ ▼ ▼ ▼ ▼ ▼ ▼
King Queen Bishop Knight Rook Pawn
┌──────────────────────────┐ ┌──────────────────────────┐
│ Move │ │ Position │
├──────────────────────────┤ ├──────────────────────────┤
│ from, to : Position │ │ row, col : int │
│ piece : Piece │ │ algebraic() : String │
│ captured : Piece? │ └──────────────────────────┘
│ kind : MoveKind │
│ (Normal/Castle/EnPassant/Promotion) │
│ promotedTo : PieceType? │
├──────────────────────────┤
│ apply(board) │ ◀── Command pattern
│ undo(board) │
└──────────────────────────┘

Three patterns are doing the load-bearing work:

  • Polymorphism on Piece.getValidMoves — the single most important decision in the design. Six concrete subclasses each implement their own movement rule; Game calls one method and gets a list back.
  • Command pattern on Move — every move carries enough info to apply(board) and undo(board). The move history is a command log; undo and audit fall out for free.
  • State on Game.status — InProgress → Check → (Checkmate | InProgress) → ... → (Stalemate | Checkmate | Draw | Resigned). The transitions happen after every successful move.

What is not in the diagram and that is deliberate:

  • No Player class doing logic. A player is just a Color for the interview — humans drive the UI. If AI arrives, Player gains a chooseMove(game) method; the game does not change.
  • Pawn promotion is a MoveKind, not a subclass of Pawn. The pawn promotes to a different piece type on the board; the piece on the next move is a Queen, not a “promoted pawn”. No subclass needed.
  • No MoveValidator god class. Validation lives in two places: the piece (getValidMoves) decides geometric legality; the game (play) decides that the move doesn’t leave the king in check. Each layer has its job.

Sequence diagram (key flows)#

The play(move) flow:

Player Game Board Piece Move
│ play(m) │ │ │ │
│────────►│ │ │ │
│ │ board.at(m.from) │ │
│ │──────────────►│ │ │
│ │ piece │ │ │
│ │◄──────────────│ │ │
│ │ piece.getValidMoves(board, m.from) │
│ │──────────────────────────────►│ │
│ │ legal targets │ │
│ │◄──────────────────────────────│ │
│ │ check m.to ∈ legal │ │
│ │ m.apply(board) │
│ │──────────────────────────────────────────────►│
│ │ inCheck(turn) on clone? │
│ │ if yes → m.undo(board) ; reject │
│ │──────────────────────────────────────────────►│
│ │ update history, status, turn │
│ result │ │
│◄────────│ │

Castling, in particular, looks like this — KingsideCastleMove.apply moves both king and rook, and undo reverses both. The game doesn’t know it was castling; it asked the king for legal moves and (e1 → g1) was among them with MoveKind.CASTLE.

The undo flow:

Player Game Move(latest) Board
│ undo() │ │ │
│───────►│ │ │
│ │ history.removeLast() │
│ │──────────────►│ │
│ │ m.undo(board) │
│ │─────────────────────────────────►│
│ │ recompute status; flip turn │

Move.undo carries the captured piece (if any), so the reverse is exact. The board does not need a history of its own.

Activity diagram (for non-trivial state)#

The game’s status state machine:

┌─────────────┐
│ InProgress │◄────────────────────────────┐
└──────┬──────┘ │
│ play(move) accepted │
▼ │
┌─────────────────────┐ │
│ opponent in check? │ │
└─────┬───────────┬───┘ │
yes │ no │ │
▼ ▼ │
┌──────────┐ ┌─────────────────────┐ │
│ Check │ │ opponent has any │ │
└────┬─────┘ │ legal move? │ │
│ └─────────┬───────────┘ │
▼ no │ yes │
┌────────────────────┐ ▼ │
│ opponent has any │ (back to InProgress)───────┘
│ legal move? │
└─────┬──────────┬───┘
no │ yes│
▼ ▼
┌──────────┐ (back to InProgress, opponent in check)
│Checkmate │
└──────────┘
Also reachable from InProgress:
Stalemate (no legal move, not in check)
Draw (fifty-move / threefold / insufficient material / agreement)
Resigned (player resigns)

The fifty-move rule and threefold repetition are computed against the move history; insufficient material is computed against the board. None of these need a separate state — they each transition InProgress → Draw directly.

Java implementation#

A representative slice; the rest is mechanical.

public enum Color { WHITE, BLACK;
public Color other() { return this == WHITE ? BLACK : WHITE; }
}
public final class Position {
public final int row, col;
public Position(int row, int col) { this.row = row; this.col = col; }
public boolean inBounds() { return row >= 0 && row < 8 && col >= 0 && col < 8; }
public Position offset(int dr, int dc) { return new Position(row + dr, col + dc); }
}
public abstract class Piece {
protected final Color color;
protected boolean hasMoved;
protected Piece(Color color) { this.color = color; }
public Color color() { return color; }
public boolean hasMoved() { return hasMoved; }
public void markMoved() { this.hasMoved = true; }
/** Geometrically legal targets; does NOT consider self-check. */
public abstract List<Position> getValidMoves(Board board, Position from);
public abstract char symbol();
}
public final class Knight extends Piece {
private static final int[][] DELTAS =
{{-2,-1},{-2,1},{-1,-2},{-1,2},{1,-2},{1,2},{2,-1},{2,1}};
public Knight(Color c) { super(c); }
public List<Position> getValidMoves(Board board, Position from) {
List<Position> out = new ArrayList<>(8);
for (int[] d : DELTAS) {
Position to = from.offset(d[0], d[1]);
if (!to.inBounds()) continue;
Piece occupant = board.at(to);
if (occupant == null || occupant.color() != color) out.add(to);
}
return out;
}
public char symbol() { return color == Color.WHITE ? 'N' : 'n'; }
}
public final class Rook extends Piece {
private static final int[][] DIRS = {{-1,0},{1,0},{0,-1},{0,1}};
public Rook(Color c) { super(c); }
public List<Position> getValidMoves(Board board, Position from) {
return slidingMoves(board, from, color, DIRS);
}
public char symbol() { return color == Color.WHITE ? 'R' : 'r'; }
}
/** Helper shared by Rook, Bishop, Queen — each just supplies its direction set. */
static List<Position> slidingMoves(Board board, Position from, Color mine, int[][] dirs) {
List<Position> out = new ArrayList<>();
for (int[] d : dirs) {
Position p = from.offset(d[0], d[1]);
while (p.inBounds()) {
Piece occ = board.at(p);
if (occ == null) { out.add(p); }
else { if (occ.color() != mine) out.add(p); break; }
p = p.offset(d[0], d[1]);
}
}
return out;
}
public final class Move {
public enum Kind { NORMAL, CASTLE_KINGSIDE, CASTLE_QUEENSIDE, EN_PASSANT, PROMOTION }
private final Position from, to;
private final Piece piece;
private final Piece captured; // nullable; en-passant captures the pawn behind 'to'
private final Kind kind;
private final Class<? extends Piece> promoteTo;
public Move(Position from, Position to, Piece piece, Piece captured,
Kind kind, Class<? extends Piece> promoteTo) {
this.from = from; this.to = to; this.piece = piece; this.captured = captured;
this.kind = kind; this.promoteTo = promoteTo;
}
public void apply(Board board) {
switch (kind) {
case NORMAL, EN_PASSANT -> {
if (kind == Kind.EN_PASSANT) board.remove(epCapturedSquare());
board.move(from, to);
piece.markMoved();
}
case CASTLE_KINGSIDE -> {
board.move(from, to); // king e1→g1
board.move(new Position(from.row, 7),
new Position(from.row, 5)); // rook h1→f1
piece.markMoved();
}
case CASTLE_QUEENSIDE -> {
board.move(from, to);
board.move(new Position(from.row, 0),
new Position(from.row, 3));
piece.markMoved();
}
case PROMOTION -> {
board.move(from, to);
board.place(instantiate(promoteTo, piece.color()), to);
}
}
}
public void undo(Board board) {
// Symmetric: move piece back; restore captured; un-promote if needed.
// (Omitted for brevity; one branch per Kind.)
}
}
public final class Game {
private final Board board = Board.starting();
private final List<Move> history = new ArrayList<>();
private Color turn = Color.WHITE;
private GameStatus status = GameStatus.IN_PROGRESS;
public Result play(Move m) {
if (status != GameStatus.IN_PROGRESS) return Result.GAME_OVER;
Piece p = board.at(m.from());
if (p == null || p.color() != turn) return Result.ILLEGAL;
List<Position> targets = p.getValidMoves(board, m.from());
if (!targets.contains(m.to())) return Result.ILLEGAL;
m.apply(board);
if (board.isInCheck(turn)) { // self-check check
m.undo(board);
return Result.LEAVES_KING_IN_CHECK;
}
history.add(m);
turn = turn.other();
status = computeStatus(); // Check / Checkmate / Stalemate / Draw / InProgress
return Result.OK;
}
public void undo() {
if (history.isEmpty()) return;
Move last = history.remove(history.size() - 1);
last.undo(board);
turn = turn.other();
status = computeStatus();
}
}

Notes the interviewer will look for:

  • getValidMoves is the only polymorphic call. Every piece-specific decision happens behind that one method. If you see if (piece instanceof Pawn) in Game, the design has failed even if the rest is correct.
  • Self-check check uses a real apply/undo, not a clone. The Command pattern earns its keep here — applying and undoing is cheaper than copying the board every time, and the move object already has all the info.
  • slidingMoves is shared across Rook / Bishop / Queen by passing the direction set. That’s reuse without inheritance — the queen does not extend rook.
  • Castling preconditions live on the King’s getValidMoves, not on the move. The king knows: it hasn’t moved, the rook hasn’t moved, squares between are empty, the king isn’t in check, doesn’t pass through check, doesn’t land in check.

Trade-offs and extensions#

Decisions explicitly made and what they cost:

DecisionWhyCost if requirements change
Piece abstract base + six subclassesMovement is genuinely per-type; polymorphism is the right answer.None — this is the textbook choice.
Command pattern on MoveUndo, history, and self-check check all reuse the same abstraction.None — necessary for any non-toy chess implementation.
Apply/undo on a real board instead of cloningBoards are cheap to mutate; clones are expensive at any non-trivial depth.If concurrent search (parallel alpha-beta) arrives, the board must be either thread-local or copy-on-write.
Position as a value objectEquality, hashing, and printing work the way you’d expect.None.
MoveKind enum on Move instead of subclassesMove is data, not behaviour — the behaviour lives on apply. A CastleMove subclass would be over-modelled.A move-grading AI may want subclass-like extensibility; the enum is still fine, with grading logic in a separate visitor.
Promotion as a MoveKind, not a Pawn subclassThe pawn becomes a queen on the next move — there is no “promoted pawn” entity.None.
isInCheck walks the opponent’s piecesO(piece_count × move_count) per check — small constant in chess.If you add an AI that calls this per ply, switch to attack maps (bitboards).

Likely follow-up extensions and the shape of the answer:

  • AI opponent. Player gains a chooseMove(Game) method; HumanPlayer reads from the UI, MinimaxPlayer runs search. The game does not change.
  • Network play. Move is already serialisable (positions, kind, promotion target). Add a MoveTransport interface; the local game just receives moves over the wire instead of from the keyboard.
  • Threefold repetition. Hash the position after every move; store the count keyed by Zobrist hash; transition to Draw when any hash hits 3.
  • PGN export. A formatter that walks history and prints algebraic notation. The move object already carries enough — disambiguation needs to know which other pieces could have reached the same square (a getValidMoves query suffices).
  • Time controls. A Clock per side; on every turn switch, pause one and start the other. Time-out is another transition into Resigned (or its own TimeForfeit status).
  • Chess960 / variants. The starting position is no longer the FIDE one; the king’s castling preconditions depend on starting rook files. Subclass Board or factor a StartingPosition strategy.

Mock interview follow-ups#

Questions interviewers reach for and the briefest correct answer:

  • “Where does the instanceof piece type switch live?” — Nowhere. That’s the design’s whole point. Each piece’s getValidMoves is its own implementation; Game calls one method.
  • “How do you check that a move doesn’t leave the king in check?” — Apply the move, ask isInCheck(turn), undo if true. The Command pattern makes this two lines.
  • “How do you implement castling?” — Three things have to be true (king and rook haven’t moved, squares between are empty, king isn’t in or passing through or landing in check). The king’s getValidMoves returns g1/c1 with MoveKind.CASTLE_* when they hold; the move’s apply shuffles both pieces.
  • “What about en passant?” — A pawn that just moved two squares is capturable on the next move only. Stash the last move on the game (already there in history); the pawn’s getValidMoves consults it. The move’s kind is EN_PASSANT and apply removes the captured pawn from the square behind the destination.
  • “How do you detect checkmate vs. stalemate?” — After the move, ask: is the side-to-move in check, and do they have any legal move? Both → checkmate. Only the second is false → stalemate. Iterating “any legal move” means walking every piece’s getValidMoves and filtering by self-check.
  • “How do you handle pawn promotion if the player wants a knight, not a queen?” — The move carries promoteTo. The UI prompts; the move is constructed with the chosen target. The piece swap happens in apply.
  • “How do you support undo more than one move deep?” — history is already a list; undo() already operates on the last entry. Loop. The cost is replaying status detection, which is cheap.
Search ESC

Keyboard shortcuts

Shortcuts are disabled while typing in inputs.