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 typepiece.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.statusInProgress → 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.