Skip to content

Low Level DesignWorked problems

Design chess

Design a chess game step by step for a senior low level design interview. Grow from one switch that knows every piece to move rules as strategies, legality checked by trying each move on a copy of the board, checkmate and stalemate, castling, en passant and promotion from a small piece of game history, and an online game where retried and out-of-date moves are refused, with code in Java, Python, C#, Go and TypeScript.

Senior30 min core · 44 min full depth

“Design a chess game: two players, a board, and the rules.” Then: “how do you know the game is over?”

Chess is played by two players on a board of 8 by 8 squares. Each side has sixteen pieces, and each kind of piece moves in its own way. You win by checkmate: the other king is attacked (in check) and has no way to escape.

Everyone knows how the pieces move, so this question isn’t really about movement. It’s about the rules that cut across all the pieces. A move is only legal if it doesn’t leave your own king in check, and that one rule decides most of the hard cases. Three moves depend on what happened earlier in the game, not on the board alone. And in an online game, the same move can arrive at the server twice.

If you only have a minute, this is the design. The rest of the page explains how we get here.

  1. Each kind of piece has a move rule (the Strategy pattern) that lists where it can go, ignoring check. Three rules cover six pieces: one for pieces that step, one for pieces that slide, and one for the pawn.
  2. A move is legal if the mover’s king isn’t attacked after it. We try the move on a copy of the board and ask. That one test handles moving into check, pinned pieces, and answering check.
  3. No legal moves ends the game. It’s checkmate if the king is attacked, and stalemate (a draw) if it isn’t.
  4. Castling and en passant need a little history: which rooks can still castle, and which square a pawn just skipped. The board alone can’t tell you.
  5. Online, every move carries the move number it was made at. The server plays it only if that number is still current. So a retried move is never played twice.

We’ll build this in four stages, then put the game online.

Before drawing anything, we ask a few questions. Each answer changes a class. Anything that doesn’t, we assume and move on.

Interviewerminute 0

Design chess.

What they are testing

Whether you scope it before drawing a board. “Two people, standard rules, enforce legal moves, detect the end” is a design problem. Asking about a chess engine, ratings or the screen first is a red flag. Each is a separate system, and none of it decides this interview.

Here are the questions that matter:

  • Two people, or against the computer? Two people. A computer opponent needs fast search, which is a different focus.
  • Enforce the rules, or just record moves? Enforce every rule.
  • What counts as “the end”? Checkmate and stalemate. Other draw rules come up at the end.
  • Same device, or online? Online, each player on their own device.
  • Standard chess only? Yes, but variants should be cheap to add.

These answers give us one promise that matters more than the others: no move leaves the mover’s own king in check. It’s the hard one, because it depends on the whole board, not on the piece moving. Two smaller promises come with it. A position with no legal moves always ends the game, the right way. And a move sent twice, or sent against a position that has since changed, is never played.

We leave out clocks, resigning, offering a draw, and saving games.

Go deeperThree more questions, and what each changes1 min
Question We assume If the answer were different
Clocks? Out of scope A clock per side, paused and started on each move
Resign and offer a draw? Out of scope Two more ways to end, outside the move rules
Save and replay games? Out of scope Keep the move list; any position replays from the start

We’ll build the design in four stages. Each stage adds only the classes that a new requirement needs. In each diagram, the new parts are outlined. The colors show what each class does (the key is under every diagram). Squares have their usual names, like e4. In code, a square is a file (the column, a to h, stored as 0 to 7) and a rank (the row, 1 to 8, stored as 0 to 7).

Stage 1: A board, and a switch that knows every piece

Section titled “Stage 1: A board, and a switch that knows every piece”
Interviewerminute 4

Start simple. Set up the board and let players move pieces the way they move.

What they are testing

Whether you can get a correct board on the page quickly, and then name what the simple version gets wrong. The red flag is spending ten minutes here on how each piece moves.

Start with the smallest thing that works. A Board is an 8 by 8 grid of pieces. A Piece is a kind and a color, nothing more. It’s a value, like a number: two white rooks are equal, and a piece never changes. A Square is a value too. The Game holds the board and whose turn it is.

To move, the game checks three things. There’s a piece on the starting square. It belongs to the player whose turn it is. And the piece can make that move. Then it moves it. How a piece can move is one big switch on its kind. The king steps one square. The knight jumps in an L. The rook, bishop and queen slide until something is in the way. The pawn moves forward, and captures diagonally.

Stage 1: a board and a game

Game

-board: Board

-turn: Color

+move(from, to) : Result

-canMove(piece, from, to) : bool

Board

-cells: Piece[8][8]

+at(Square) : Piece

+put(Square, Piece)

+standard() : Board

«value»

Piece

+kind: Kind

+color: Color

«value»

Square

+file

+rank

+of(name) : Square

«enum»

Kind

KING QUEEN ROOK BISHOP KNIGHT PAWN

Game

-board: Board

-turn: Color

+move(from, to) : Result

-canMove(piece, from, to) : bool

Board

-cells: Piece[8][8]

+at(Square) : Piece

+put(Square, Piece)

+standard() : Board

«value»

Piece

+kind: Kind

+color: Color

«value»

Square

+file

+rank

+of(name) : Square

«enum»

Kind

KING QUEEN ROOK BISHOP KNIGHT PAWN

Scroll or pinch to zoom, drag to pan
  • Color key
  • Facade
  • Entity
  • Value object
  • Enum
  • Interface
  • Implementation
  • New in this step
Game.javagame.pyGame.csgame.gogame.ts
// The two sides. other() says whose turn comes next.
enum Color {
WHITE, BLACK;
Color other() { return this == WHITE ? BLACK : WHITE; }
}
// The six kinds of piece. How each one moves lives in the rules, not here.
enum Kind { KING, QUEEN, ROOK, BISHOP, KNIGHT, PAWN }
// A piece is only its kind and color. It does not store its square: the board does, so moving
// a piece changes one place, not two.
record Piece(Kind kind, Color color) {}
// A square by file (a to h as 0 to 7) and rank (1 to 8 as 0 to 7).
record Square(int file, int rank) {
static Square of(String name) { return new Square(name.charAt(0) - 'a', name.charAt(1) - '1'); }
boolean onBoard() { return file >= 0 && file < 8 && rank >= 0 && rank < 8; }
Square plus(int df, int dr) { return new Square(file + df, rank + dr); }
@Override
public String toString() { return "" + (char) ('a' + file) + (rank + 1); }
}
class Board {
private final Piece[][] cells = new Piece[8][8];
Piece at(Square s) { return cells[s.rank()][s.file()]; }
void put(Square s, Piece p) { cells[s.rank()][s.file()] = p; }
static Board standard() {
Board b = new Board();
Kind[] backRank = {Kind.ROOK, Kind.KNIGHT, Kind.BISHOP, Kind.QUEEN, Kind.KING, Kind.BISHOP, Kind.KNIGHT, Kind.ROOK};
for (int f = 0; f < 8; f++) {
b.put(new Square(f, 0), new Piece(backRank[f], Color.WHITE));
b.put(new Square(f, 1), new Piece(Kind.PAWN, Color.WHITE));
b.put(new Square(f, 6), new Piece(Kind.PAWN, Color.BLACK));
b.put(new Square(f, 7), new Piece(backRank[f], Color.BLACK));
}
return b;
}
}
enum Result { OK, NO_PIECE, NOT_YOUR_TURN, ILLEGAL_MOVE }
class Game {
final Board board = Board.standard();
private Color turn = Color.WHITE;
Result move(String from, String to) {
Square f = Square.of(from);
Square t = Square.of(to);
Piece p = board.at(f);
if (p == null) return Result.NO_PIECE;
if (p.color() != turn) return Result.NOT_YOUR_TURN;
if (!canMove(p, f, t)) return Result.ILLEGAL_MOVE;
board.put(t, p);
board.put(f, null);
turn = turn.other();
return Result.OK;
}
// flaw 1: one switch knows how every piece moves, and every rule still to come (castling, en
// passant, promotion) makes it longer.
// flaw 2: nothing stops a move that leaves your own king in check.
// flaw 3: the game never ends: there is no check, checkmate or stalemate.
private boolean canMove(Piece p, Square f, Square t) {
Piece target = board.at(t);
if (target != null && target.color() == p.color()) return false;
int df = t.file() - f.file();
int dr = t.rank() - f.rank();
int adf = Math.abs(df);
int adr = Math.abs(dr);
return switch (p.kind()) {
case KING -> Math.max(adf, adr) == 1;
case KNIGHT -> adf * adr == 2;
case ROOK -> (df == 0 || dr == 0) && pathClear(f, t);
case BISHOP -> adf == adr && pathClear(f, t);
case QUEEN -> (df == 0 || dr == 0 || adf == adr) && pathClear(f, t);
case PAWN -> {
int forward = p.color() == Color.WHITE ? 1 : -1;
int start = p.color() == Color.WHITE ? 1 : 6;
if (df == 0 && target == null) {
yield dr == forward || (dr == 2 * forward && f.rank() == start && board.at(f.plus(0, forward)) == null);
}
yield adf == 1 && dr == forward && target != null;
}
};
}
// Every square strictly between f and t is empty.
private boolean pathClear(Square f, Square t) {
int sf = Integer.signum(t.file() - f.file());
int sr = Integer.signum(t.rank() - f.rank());
for (Square s = f.plus(sf, sr); !s.equals(t); s = s.plus(sf, sr)) {
if (board.at(s) != null) return false;
}
return true;
}
}

It works, but it has three problems, and we leave them in on purpose. The switch knows how every piece moves, and every rule still to come (castling, en passant, promotion) makes it longer. Nothing stops a player from moving into check, or leaving their king in check. And the game never ends.

Interviewerminute 7

The switch is going to grow. How would you structure movement, so that adding a piece, or a variant with new pieces, doesn’t touch the game?

What they are testing

Whether you reach for the Strategy pattern and notice what the pieces share. A candidate who writes six classes with six copies of the sliding loop has missed that the queen is the rook and the bishop together.

Movement becomes a MoveRule. Given a board and a square, it returns the squares the piece there can reach, ignoring check. The game looks up the rule for a piece’s kind, and never names a piece itself. A family of interchangeable rules behind one interface is the Strategy pattern.

Six pieces need only three rules:

  • Step(directions, slides) walks in each of its directions. A stepper (king, knight) takes one step. A slider (rook, bishop, queen) keeps going until it reaches a piece. It can capture an enemy piece in the way, and stops before one of its own. The king and queen use all eight directions. The rook uses the four straight ones, and the bishop the four diagonal ones. The knight uses its eight L-shaped jumps.
  • PawnRule moves one square forward, or two from its starting row, only onto empty squares. It captures one square diagonally forward.

A rule also answers a second question: which squares does this piece attack? For every piece except the pawn, that’s the same as where it can move. A pawn attacks the two diagonal squares in front of it, even when they’re empty and it can’t move there. Stage 3 needs exactly this.

Stage 2: move rules as strategies

Game

+move(from, to) : Result

Rules

+BY_KIND: Map<Kind, MoveRule>

«interface»

MoveRule

+targets(Board, Square) : List<Square>

+attacks(Board, Square) : List<Square>

«value»

Step

+directions

+slides

PawnRule

Board

+at(Square) : Piece

+put(Square, Piece)

Game

+move(from, to) : Result

Rules

+BY_KIND: Map<Kind, MoveRule>

«interface»

MoveRule

+targets(Board, Square) : List<Square>

+attacks(Board, Square) : List<Square>

«value»

Step

+directions

+slides

PawnRule

Board

+at(Square) : Piece

+put(Square, Piece)

Scroll or pinch to zoom, drag to pan
  • Color key
  • Facade
  • Entity
  • Value object
  • Enum
  • Interface
  • Implementation
  • New in this step
PawnRule.javapawn_rule.pyPawnRule.cse.gopawn-rule.ts
// How one kind of piece moves: where the piece on from can go, ignoring check. Adding a piece,
// or a fairy-chess variant, is a new rule rather than a longer switch.
interface MoveRule {
List<Square> targets(Board board, Square from);
// The squares this piece threatens. The same as its targets for every piece but the pawn,
// which moves straight and captures diagonally.
default List<Square> attacks(Board board, Square from) { return targets(board, from); }
}
// Steps in each direction, once (king, knight) or repeatedly until something is in the way
// (rook, bishop, queen). An enemy piece in the way can be captured; an own piece can't.
record Step(int[][] directions, boolean slides) implements MoveRule {
public List<Square> targets(Board board, Square from) {
Color own = board.at(from).color();
List<Square> out = new ArrayList<>();
for (int[] d : directions) {
for (Square s = from.plus(d[0], d[1]); s.onBoard(); s = s.plus(d[0], d[1])) {
Piece p = board.at(s);
if (p == null || p.color() != own) out.add(s);
if (p != null || !slides) break;
}
}
return out;
}
}
// The pawn is the odd one out. It moves forward but captures diagonally, may move two squares
// from its start, and only goes one way. So it gets its own rule.
class PawnRule implements MoveRule {
public List<Square> targets(Board board, Square from) {
Color own = board.at(from).color();
int forward = own == Color.WHITE ? 1 : -1;
List<Square> out = new ArrayList<>();
Square one = from.plus(0, forward);
if (one.onBoard() && board.at(one) == null) {
out.add(one);
Square two = one.plus(0, forward);
boolean onStart = from.rank() == (own == Color.WHITE ? 1 : 6);
if (onStart && board.at(two) == null) out.add(two);
}
for (Square s : attacks(board, from)) {
Piece p = board.at(s);
if (p != null && p.color() != own) out.add(s);
}
return out;
}
public List<Square> attacks(Board board, Square from) {
int forward = board.at(from).color() == Color.WHITE ? 1 : -1;
return Stream.of(from.plus(-1, forward), from.plus(1, forward)).filter(Square::onBoard).toList();
}
}
// The one place that says how each kind of piece moves. Directions are {file, rank} steps.
final class Rules {
static final int[][] STRAIGHT = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}};
static final int[][] DIAGONAL = {{1, 1}, {1, -1}, {-1, 1}, {-1, -1}};
static final int[][] ALL = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}, {1, 1}, {1, -1}, {-1, 1}, {-1, -1}};
static final int[][] KNIGHT = {{1, 2}, {2, 1}, {2, -1}, {1, -2}, {-1, -2}, {-2, -1}, {-2, 1}, {-1, 2}};
// Six kinds of piece, four shapes of movement: the queen is the rook and bishop together, and
// the king and knight are steppers with different directions.
static final Map<Kind, MoveRule> BY_KIND = Map.of(
Kind.KING, new Step(ALL, false),
Kind.QUEEN, new Step(ALL, true),
Kind.ROOK, new Step(STRAIGHT, true),
Kind.BISHOP, new Step(DIAGONAL, true),
Kind.KNIGHT, new Step(KNIGHT, false),
Kind.PAWN, new PawnRule());
}
class Game {
final Board board = Board.standard();
private Color turn = Color.WHITE;
// flaw: a move that leaves your own king in check is still allowed, and nothing ends the game.
Result move(String from, String to) {
Square f = Square.of(from);
Square t = Square.of(to);
Piece p = board.at(f);
if (p == null) return Result.NO_PIECE;
if (p.color() != turn) return Result.NOT_YOUR_TURN;
if (!Rules.BY_KIND.get(p.kind()).targets(board, f).contains(t)) return Result.ILLEGAL_MOVE;
board.put(t, p);
board.put(f, null);
turn = turn.other();
return Result.OK;
}
}
Go deeperA rule per kind, or a class per piece1 min
A class per piece (Rook extends Piece) A rule per kind (our choice)
What a piece is An object with behaviour and identity A value: kind and color
Shared sliding code Copied, or pushed into a base class One Step, used by three kinds
Promotion Replace the object with a new class Put a different value on the square
Copying the board Copy objects, or share them carefully Copy 64 references; values never change
A new piece in a variant A new subclass A new rule in the table

A class per piece is a fine answer, if it stays about movement. What it mustn’t do is decide legality, which needs the whole board.

Interviewerminute 12

A player moves a bishop that was blocking an attack on their own king. Your code allows it. Fix that, and tell me when the game is over.

What they are testing

Whether you find the single rule behind pins, check and the end of the game. The red flag is a separate special case for each: “if pinned”, “if in check, only these moves”. The strong answer is one test, applied to every move.

Here is the rule: a move is legal if, after it, the mover’s own king is not attacked. Three situations break it, and one test covers all three:

  • Moving into check. The king steps onto an attacked square.
  • Moving a pinned piece. A piece is pinned when it stands between its own king and an attacker. Moving it away opens the line to the king.
  • Ignoring a check. The king is already attacked, and the move doesn’t fix that, so the king is still attacked afterwards.

The game checks the rule the direct way. For each move a rule allows, it copies the board, and makes the move on the copy. It finds the mover’s king, and asks whether any enemy piece attacks that square. “Attacks” uses each rule’s attacks method. That’s why stage 2 added it.

Isn’t copying the board slow? No. A board is 64 references, and pieces are values that never change, so the copy and the original can share them.

Sequence: a pinned bishop tries to move
Copy of the boardGamePlayerCopy of the boardGamePlayerthe real board was never touchedmove e2 to d3rule for bishop lists d3copy, then play e2 to d3is the white king on e1 attacked?yes, by the black rook on e8ILLEGAL_MOVECopy of the boardGamePlayerCopy of the boardGamePlayerthe real board was never touchedmove e2 to d3rule for bishop lists d3copy, then play e2 to d3is the white king on e1 attacked?yes, by the black rook on e8ILLEGAL_MOVE
Scroll or pinch to zoom, drag to pan
  • Color key
  • Facade

The end of the game comes from the same idea. After every move, the game lists the legal moves of the player who moves next. If there are none, the game is over. If that player’s king is attacked, it’s checkmate, and the other player wins. If it isn’t, it’s stalemate, a draw. The game also checks this when it’s created, so a puzzle position that is already checkmate starts finished.

Stage 3: the game's states

a legal move, and the next player has a legal move

no legal move, king attacked

no legal move, king not attacked

Playing

Checkmate

Stalemate

a legal move, and the next player has a legal move

no legal move, king attacked

no legal move, king not attacked

Playing

Checkmate

Stalemate

Scroll or pinch to zoom, drag to pan
  • Color key
  • State
  • Fails or stops
Stage 3: legality and the end of the game

Game

-board: Board

-turn: Color

-state: State

+move(from, to) : Result

+legalMoves() : List<Move>

+inCheck() : bool

+winner() : Color

-safeAfter(Move) : bool

-attacked(Board, Square, Color) : bool

Board

+at(Square) : Piece

+put(Square, Piece)

+copy() : Board

+kingOf(Color) : Square

+squares() : List<Square>

«value»

Move

+from: Square

+to: Square

«enum»

State

PLAYING CHECKMATE STALEMATE

«interface»

MoveRule

+targets(Board, Square)

+attacks(Board, Square)

Game

-board: Board

-turn: Color

-state: State

+move(from, to) : Result

+legalMoves() : List<Move>

+inCheck() : bool

+winner() : Color

-safeAfter(Move) : bool

-attacked(Board, Square, Color) : bool

Board

+at(Square) : Piece

+put(Square, Piece)

+copy() : Board

+kingOf(Color) : Square

+squares() : List<Square>

«value»

Move

+from: Square

+to: Square

«enum»

State

PLAYING CHECKMATE STALEMATE

«interface»

MoveRule

+targets(Board, Square)

+attacks(Board, Square)

Scroll or pinch to zoom, drag to pan
  • Color key
  • Facade
  • Entity
  • Value object
  • Enum
  • Interface
  • Implementation
  • New in this step
Game.javagame.pyGame.csgame.gogame.ts
// The 64 squares and what stands on each. It knows nothing about the rules, which makes it
// cheap to copy when the game wants to test a move.
class Board {
private final Piece[][] cells = new Piece[8][8]; // [rank][file]; null is an empty square
Piece at(Square s) { return cells[s.rank()][s.file()]; }
void put(Square s, Piece p) { cells[s.rank()][s.file()] = p; }
// Trying a move on a copy is how the game asks "would my king be safe?". 64 references
// copy in nanoseconds.
Board copy() {
Board b = new Board();
for (int r = 0; r < 8; r++) b.cells[r] = cells[r].clone();
return b;
}
// All 64 squares, so callers loop over squares, not array indexes.
List<Square> squares() {
List<Square> out = new ArrayList<>();
for (int r = 0; r < 8; r++) for (int f = 0; f < 8; f++) out.add(new Square(f, r));
return out;
}
// Every check test starts from the king's square.
Square kingOf(Color color) {
for (Square s : squares()) {
Piece p = at(s);
if (p != null && p.kind() == Kind.KING && p.color() == color) return s;
}
throw new IllegalStateException("no " + color + " king");
}
// The starting position: white on ranks 1 and 2, black on ranks 7 and 8.
static Board standard() {
Board b = new Board();
Kind[] backRank = {Kind.ROOK, Kind.KNIGHT, Kind.BISHOP, Kind.QUEEN, Kind.KING, Kind.BISHOP, Kind.KNIGHT, Kind.ROOK};
for (int f = 0; f < 8; f++) {
b.put(new Square(f, 0), new Piece(backRank[f], Color.WHITE));
b.put(new Square(f, 1), new Piece(Kind.PAWN, Color.WHITE));
b.put(new Square(f, 6), new Piece(Kind.PAWN, Color.BLACK));
b.put(new Square(f, 7), new Piece(backRank[f], Color.BLACK));
}
return b;
}
}
enum Result { OK, NO_PIECE, NOT_YOUR_TURN, ILLEGAL_MOVE, GAME_OVER }
// Whether the game is still on. Checkmate and stalemate are both final.
enum State { PLAYING, CHECKMATE, STALEMATE }
record Move(Square from, Square to) {}
class Game {
private final Board board;
private Color turn;
private State state = State.PLAYING;
Game() { this(Board.standard(), Color.WHITE); }
// Any position, for puzzles and tests. It may already be over.
Game(Board board, Color turn) {
this.board = board;
this.turn = turn;
updateState();
}
Result move(String from, String to) {
if (state != State.PLAYING) return Result.GAME_OVER;
Square f = Square.of(from);
Piece p = board.at(f);
if (p == null) return Result.NO_PIECE;
if (p.color() != turn) return Result.NOT_YOUR_TURN;
Move m = new Move(f, Square.of(to));
if (!legalMoves().contains(m)) return Result.ILLEGAL_MOVE;
apply(board, m);
turn = turn.other();
updateState();
return Result.OK;
}
// A move is legal if the piece can make it and the mover's own king isn't attacked after it.
// That one test covers moving into check, moving a pinned piece, and failing to answer check.
List<Move> legalMoves() {
List<Move> out = new ArrayList<>();
for (Square f : board.squares()) {
Piece p = board.at(f);
if (p == null || p.color() != turn) continue;
for (Square t : Rules.BY_KIND.get(p.kind()).targets(board, f)) {
Move m = new Move(f, t);
if (safeAfter(m)) out.add(m);
}
}
return out;
}
boolean inCheck() { return attacked(board, board.kingOf(turn), turn.other()); }
State state() { return state; }
Color turn() { return turn; }
Color winner() { return state == State.CHECKMATE ? turn.other() : null; }
Piece at(String square) { return board.at(Square.of(square)); }
private boolean safeAfter(Move m) {
Board after = board.copy();
apply(after, m);
return !attacked(after, after.kingOf(turn), turn.other());
}
// No legal move is the end: checkmate if the king is attacked, stalemate (a draw) if not.
private void updateState() {
if (legalMoves().isEmpty()) state = inCheck() ? State.CHECKMATE : State.STALEMATE;
}
static boolean attacked(Board b, Square target, Color by) {
for (Square s : b.squares()) {
Piece p = b.at(s);
if (p != null && p.color() == by && Rules.BY_KIND.get(p.kind()).attacks(b, s).contains(target)) return true;
}
return false;
}
static void apply(Board b, Move m) {
b.put(m.to(), b.at(m.from()));
b.put(m.from(), null);
}
}

Stage 4: Moves that need the game’s history

Section titled “Stage 4: Moves that need the game’s history”
Interviewerminute 19

Add castling, en passant and promotion. What does the game need to remember that the board doesn’t show?

What they are testing

Whether you find the smallest state that answers each rule. The red flag is a hasMoved flag on every piece. It breaks as soon as a test sets up a position. Worse is reading the whole move history every time.

Three special moves can’t be decided from the board alone.

  • Castling. The king moves two squares toward a rook, and the rook jumps to the square the king crossed. It’s allowed only if neither piece has moved, every square between them is empty, the king isn’t in check, and the king doesn’t cross or land on an attacked square. “Neither has moved” is history. So the game keeps a set of rooks that can still castle, by square. A king move removes both rooks of its side. A rook moving, or being captured on its square, removes that rook. That set is everything castling needs to remember.
  • En passant. Say a pawn moves two squares. An enemy pawn beside where it landed may capture it as if it had moved only one. But only on the very next move. The game keeps the square the pawn skipped, and clears it after every other move.
  • Promotion. A pawn reaching the last row becomes a queen, rook, bishop or knight, as the player chooses. This needs no history, but the move has to carry the choice, so Move gains a promotion field.
Go deeperHow the board recognises a special move1 min

The board can recognise each special move without being told. A pawn moving diagonally to an empty square is en passant, so the captured pawn beside it is removed too. A king moving two columns is castling, so the rook moves too. apply makes those changes on the real board and on the copies used to test legality alike. So a castling move that would leave the king in check is refused like any other move.

Stage 4: how apply recognises a move

yes

no

yes

no

yes

no

apply(board, move)

a pawn moving diagonally
to an empty square?

en passant: remove the pawn
beside the target

a king moving two files?

castling: move the rook
to the square the king crossed

a promotion kind on the move?

put the new piece on the target

put the moving piece on the target

yes

no

yes

no

yes

no

apply(board, move)

a pawn moving diagonally
to an empty square?

en passant: remove the pawn
beside the target

a king moving two files?

castling: move the rook
to the square the king crossed

a promotion kind on the move?

put the new piece on the target

put the moving piece on the target

Scroll or pinch to zoom, drag to pan

Moves are now made in two steps. candidates lists every move the rules allow, then adds en passant, castling, and one move per promotion choice. legalMoves keeps those that pass the stage 3 test. The game also counts plies: moves by either side, so after white and black have each moved once, the ply is 2. The online stage uses this count.

Stage 4: the state castling and en passant need

Game

-board: Board

-turn: Color

-state: State

-ply

-castlingRooks: Set<Square>

-enPassant: Square

+move(from, to, promotion) : Result

+legalMoves() : List<Move>

-candidates() : List<Move>

-addCastling(List<Move>)

-play(Move, Piece)

-apply(Board, Move)

«value»

Move

+from: Square

+to: Square

+promotion: Kind

Board

+copy() : Board

Game

-board: Board

-turn: Color

-state: State

-ply

-castlingRooks: Set<Square>

-enPassant: Square

+move(from, to, promotion) : Result

+legalMoves() : List<Move>

-candidates() : List<Move>

-addCastling(List<Move>)

-play(Move, Piece)

-apply(Board, Move)

«value»

Move

+from: Square

+to: Square

+promotion: Kind

Board

+copy() : Board

Scroll or pinch to zoom, drag to pan
  • Color key
  • Facade
  • Entity
  • Value object
  • Enum
  • Interface
  • Implementation
  • New in this step
Game.javagame.pyGame.csgame.gogame.ts
// promotion is the piece a pawn becomes on the last rank, and null for every other move.
record Move(Square from, Square to, Kind promotion) {}
// One game of chess. It owns the board, plus what the board alone can't tell you: whose turn it
// is, which rooks may still castle, and where en passant is possible.
class Game {
// What a pawn may become: never a king, and it can't stay a pawn.
private static final List<Kind> PROMOTIONS = List.of(Kind.QUEEN, Kind.ROOK, Kind.BISHOP, Kind.KNIGHT);
private final Board board;
private Color turn;
private State state = State.PLAYING;
private int ply; // moves made so far, by both sides
// The rooks that can still castle, by square. A king's move removes both of its side's; a
// rook moving or being captured removes its own. That is everything castling remembers.
private final Set<Square> castlingRooks = new HashSet<>();
// The square a pawn skipped over on the last move, if it moved two: an enemy pawn may capture
// it there, on this move only.
private Square enPassant;
Game() { this(Board.standard(), Color.WHITE); }
// Any position, for puzzles and tests. A king and rook on their starting squares may castle.
Game(Board board, Color turn) {
this.board = board;
this.turn = turn;
for (Color c : Color.values()) {
int rank = c == Color.WHITE ? 0 : 7;
if (!new Piece(Kind.KING, c).equals(board.at(new Square(4, rank)))) continue;
for (int file : new int[] {0, 7}) {
Square corner = new Square(file, rank);
if (new Piece(Kind.ROOK, c).equals(board.at(corner))) castlingRooks.add(corner);
}
}
updateState();
}
Result move(String from, String to) { return move(from, to, null); }
// A pawn reaching the last rank becomes a queen unless another piece is asked for.
Result move(String from, String to, Kind promotion) {
if (state != State.PLAYING) return Result.GAME_OVER;
Square f = Square.of(from);
Square t = Square.of(to);
Piece p = board.at(f);
if (p == null) return Result.NO_PIECE;
if (p.color() != turn) return Result.NOT_YOUR_TURN;
Kind wanted = promotion == null ? Kind.QUEEN : promotion;
Move m = legalMoves().stream()
.filter(c -> c.from().equals(f) && c.to().equals(t) && (c.promotion() == null || c.promotion() == wanted))
.findFirst().orElse(null);
if (m == null) return Result.ILLEGAL_MOVE;
play(m, p);
return Result.OK;
}
// A move is legal if the pieces' rules allow it and the mover's own king isn't attacked
// after it.
List<Move> legalMoves() {
List<Move> out = new ArrayList<>();
for (Move m : candidates()) if (safeAfter(m)) out.add(m);
return out;
}
boolean inCheck() { return attacked(board, board.kingOf(turn), turn.other()); }
State state() { return state; }
Color turn() { return turn; }
int ply() { return ply; }
// The side that gave checkmate. null while playing, and after a stalemate.
Color winner() { return state == State.CHECKMATE ? turn.other() : null; }
Piece at(String square) { return board.at(Square.of(square)); }
// Plays a move already known to be legal, then updates what castling and en passant
// remember.
private void play(Move m, Piece p) {
apply(board, m);
if (p.kind() == Kind.KING) castlingRooks.removeIf(r -> r.rank() == m.from().rank());
castlingRooks.remove(m.from());
castlingRooks.remove(m.to());
boolean doublePush = p.kind() == Kind.PAWN && Math.abs(m.to().rank() - m.from().rank()) == 2;
enPassant = doublePush ? new Square(m.from().file(), (m.from().rank() + m.to().rank()) / 2) : null;
turn = turn.other();
ply++;
updateState();
}
// Every move the pieces' rules allow, plus the three that depend on the game's history rather
// than the board: en passant, castling, and promotion's choice of piece.
private List<Move> candidates() {
List<Move> out = new ArrayList<>();
int lastRank = turn == Color.WHITE ? 7 : 0;
for (Square f : board.squares()) {
Piece p = board.at(f);
if (p == null || p.color() != turn) continue;
MoveRule rule = Rules.BY_KIND.get(p.kind());
for (Square t : rule.targets(board, f)) {
if (p.kind() == Kind.PAWN && t.rank() == lastRank) {
for (Kind k : PROMOTIONS) out.add(new Move(f, t, k));
} else {
out.add(new Move(f, t, null));
}
}
if (p.kind() == Kind.PAWN && enPassant != null && rule.attacks(board, f).contains(enPassant)) {
out.add(new Move(f, enPassant, null));
}
}
addCastling(out);
return out;
}
// The king moves two squares toward a rook that can still castle, and the rook jumps over it.
// Not out of check, not through an attacked square, and only with every square between empty.
// The square the king lands on is checked by safeAfter like any other move.
private void addCastling(List<Move> out) {
if (inCheck()) return;
Square king = new Square(4, turn == Color.WHITE ? 0 : 7);
for (Square rook : castlingRooks) {
if (rook.rank() != king.rank()) continue;
int dir = rook.file() > king.file() ? 1 : -1;
boolean empty = true;
for (Square s = king.plus(dir, 0); !s.equals(rook); s = s.plus(dir, 0)) empty &= board.at(s) == null;
if (empty && !attacked(board, king.plus(dir, 0), turn.other())) out.add(new Move(king, king.plus(2 * dir, 0), null));
}
}
// Play the move on a copy and look: is our king attacked? It catches pins and discovered
// checks with no special code.
private boolean safeAfter(Move m) {
Board after = board.copy();
apply(after, m);
return !attacked(after, after.kingOf(turn), turn.other());
}
// No legal move is the end: checkmate if the king is attacked, stalemate (a draw) if not.
private void updateState() {
if (legalMoves().isEmpty()) state = inCheck() ? State.CHECKMATE : State.STALEMATE;
}
// Does any piece of color `by` threaten target? Static, so it can test a copied board too.
static boolean attacked(Board b, Square target, Color by) {
for (Square s : b.squares()) {
Piece p = b.at(s);
if (p != null && p.color() == by && Rules.BY_KIND.get(p.kind()).attacks(b, s).contains(target)) return true;
}
return false;
}
// The special moves are recognised from the board: a pawn moving diagonally to an empty
// square is en passant, and a king moving two files is castling.
static void apply(Board b, Move m) {
Piece p = b.at(m.from());
if (p.kind() == Kind.PAWN && m.from().file() != m.to().file() && b.at(m.to()) == null) {
b.put(new Square(m.to().file(), m.from().rank()), null);
}
if (p.kind() == Kind.KING && Math.abs(m.to().file() - m.from().file()) == 2) {
int dir = Integer.signum(m.to().file() - m.from().file());
Square rook = new Square(dir > 0 ? 7 : 0, m.from().rank());
b.put(m.from().plus(dir, 0), b.at(rook));
b.put(rook, null);
}
b.put(m.to(), m.promotion() == null ? p : new Piece(m.promotion(), p.color()));
b.put(m.from(), null);
}
}
Go deeperWhere castling rights live, and why the legality test doesn't care2 min
Option How Problem
hasMoved on each piece Set when a piece moves Pieces stop being values; a set-up position needs flags
Search the move history “Has anything left e1 or h1?” Every castling check reads the whole game
A set of rooks (our choice) Removed on king move, rook move, capture None: four squares at most, and easy to set up in tests

Chess notation (FEN, the standard text format for a position) stores castling rights the same way, as up to four letters. That’s a sign it’s the right amount of state.

Why doesn’t the pinned-piece test need to know about castling? Because castling is just another move handed to the same test. addCastling checks the parts the test can’t see: that the king isn’t in check now, and doesn’t cross an attacked square. Then the move goes through safeAfter like any other, which checks the square the king lands on. Every special move reuses the one legality rule, instead of adding its own.

This is enough for a mid-level answer. A rule per kind. Legality by trying the move and checking the king. And the game ending when there are no legal moves. The rest of the page is what makes it a senior answer.

Here is everything together. Game enforces the rules and owns the little history they need. Board holds the pieces and copies itself cheaply. Each kind’s MoveRule lists where it can go and what it attacks. SharedGame lets two players on a network submit moves safely.

Chess: full class diagram

SharedGame

-game: Game

+submit(player, expectedPly, from, to, promotion) : Result

Game

-turn: Color

-state: State

-ply

-castlingRooks: Set<Square>

-enPassant: Square

+move(from, to, promotion) : Result

+legalMoves() : List<Move>

+inCheck() : bool

+winner() : Color

Board

+at(Square) : Piece

+put(Square, Piece)

+copy() : Board

+kingOf(Color) : Square

Rules

+BY_KIND: Map<Kind, MoveRule>

«interface»

MoveRule

+targets(Board, Square)

+attacks(Board, Square)

Step

PawnRule

«value»

Piece

+kind

+color

«value»

Square

«value»

Move

«enum»

State

«enum»

Result

SharedGame

-game: Game

+submit(player, expectedPly, from, to, promotion) : Result

Game

-turn: Color

-state: State

-ply

-castlingRooks: Set<Square>

-enPassant: Square

+move(from, to, promotion) : Result

+legalMoves() : List<Move>

+inCheck() : bool

+winner() : Color

Board

+at(Square) : Piece

+put(Square, Piece)

+copy() : Board

+kingOf(Color) : Square

Rules

+BY_KIND: Map<Kind, MoveRule>

«interface»

MoveRule

+targets(Board, Square)

+attacks(Board, Square)

Step

PawnRule

«value»

Piece

+kind

+color

«value»

Square

«value»

Move

«enum»

State

«enum»

Result

Scroll or pinch to zoom, drag to pan
  • Color key
  • Facade
  • Entity
  • Value object
  • Enum
  • Interface
  • Implementation
chess· read only
AllInOne.java
AllInOne.java
// The complete design, built from every stage on this page.
import java.util.*;
import java.util.stream.*;
// The two sides. other() says whose turn comes next.
enum Color {
WHITE, BLACK;
Color other() { return this == WHITE ? BLACK : WHITE; }
}
// The six kinds of piece. How each one moves lives in the rules, not here.
enum Kind { KING, QUEEN, ROOK, BISHOP, KNIGHT, PAWN }
// A piece is only its kind and color. It does not store its square: the board does, so moving
// a piece changes one place, not two.
record Piece(Kind kind, Color color) {}
// A square by file (a to h as 0 to 7) and rank (1 to 8 as 0 to 7).
record Square(int file, int rank) {
static Square of(String name) { return new Square(name.charAt(0) - 'a', name.charAt(1) - '1'); }
boolean onBoard() { return file >= 0 && file < 8 && rank >= 0 && rank < 8; }
Square plus(int df, int dr) { return new Square(file + df, rank + dr); }
@Override
public String toString() { return "" + (char) ('a' + file) + (rank + 1); }
}
// The 64 squares and what stands on each. It knows nothing about the rules, which makes it
// cheap to copy when the game wants to test a move.
class Board {
private final Piece[][] cells = new Piece[8][8]; // [rank][file]; null is an empty square
Piece at(Square s) { return cells[s.rank()][s.file()]; }
void put(Square s, Piece p) { cells[s.rank()][s.file()] = p; }
// Trying a move on a copy is how the game asks "would my king be safe?". 64 references
// copy in nanoseconds.
Board copy() {
Board b = new Board();
for (int r = 0; r < 8; r++) b.cells[r] = cells[r].clone();
return b;
}
// All 64 squares, so callers loop over squares, not array indexes.
List<Square> squares() {
List<Square> out = new ArrayList<>();
for (int r = 0; r < 8; r++) for (int f = 0; f < 8; f++) out.add(new Square(f, r));
return out;
}
// Every check test starts from the king's square.
Square kingOf(Color color) {
for (Square s : squares()) {
Piece p = at(s);
if (p != null && p.kind() == Kind.KING && p.color() == color) return s;
}
throw new IllegalStateException("no " + color + " king");
}
// The starting position: white on ranks 1 and 2, black on ranks 7 and 8.
static Board standard() {
Board b = new Board();
Kind[] backRank = {Kind.ROOK, Kind.KNIGHT, Kind.BISHOP, Kind.QUEEN, Kind.KING, Kind.BISHOP, Kind.KNIGHT, Kind.ROOK};
for (int f = 0; f < 8; f++) {
b.put(new Square(f, 0), new Piece(backRank[f], Color.WHITE));
b.put(new Square(f, 1), new Piece(Kind.PAWN, Color.WHITE));
b.put(new Square(f, 6), new Piece(Kind.PAWN, Color.BLACK));
b.put(new Square(f, 7), new Piece(backRank[f], Color.BLACK));
}
return b;
}
}
// What a move request gets back. STALE is for online play: the move was made against an old
// position.
enum Result { OK, NO_PIECE, NOT_YOUR_TURN, ILLEGAL_MOVE, GAME_OVER, STALE }
// One game of chess. It owns the board, plus what the board alone can't tell you: whose turn it
// is, which rooks may still castle, and where en passant is possible.
class Game {
// What a pawn may become: never a king, and it can't stay a pawn.
private static final List<Kind> PROMOTIONS = List.of(Kind.QUEEN, Kind.ROOK, Kind.BISHOP, Kind.KNIGHT);
private final Board board;
private Color turn;
private State state = State.PLAYING;
private int ply; // moves made so far, by both sides
// The rooks that can still castle, by square. A king's move removes both of its side's; a
// rook moving or being captured removes its own. That is everything castling remembers.
private final Set<Square> castlingRooks = new HashSet<>();
// The square a pawn skipped over on the last move, if it moved two: an enemy pawn may capture
// it there, on this move only.
private Square enPassant;
Game() { this(Board.standard(), Color.WHITE); }
// Any position, for puzzles and tests. A king and rook on their starting squares may castle.
Game(Board board, Color turn) {
this.board = board;
this.turn = turn;
for (Color c : Color.values()) {
int rank = c == Color.WHITE ? 0 : 7;
if (!new Piece(Kind.KING, c).equals(board.at(new Square(4, rank)))) continue;
for (int file : new int[] {0, 7}) {
Square corner = new Square(file, rank);
if (new Piece(Kind.ROOK, c).equals(board.at(corner))) castlingRooks.add(corner);
}
}
updateState();
}
Result move(String from, String to) { return move(from, to, null); }
// A pawn reaching the last rank becomes a queen unless another piece is asked for.
Result move(String from, String to, Kind promotion) {
if (state != State.PLAYING) return Result.GAME_OVER;
Square f = Square.of(from);
Square t = Square.of(to);
Piece p = board.at(f);
if (p == null) return Result.NO_PIECE;
if (p.color() != turn) return Result.NOT_YOUR_TURN;
Kind wanted = promotion == null ? Kind.QUEEN : promotion;
Move m = legalMoves().stream()
.filter(c -> c.from().equals(f) && c.to().equals(t) && (c.promotion() == null || c.promotion() == wanted))
.findFirst().orElse(null);
if (m == null) return Result.ILLEGAL_MOVE;
play(m, p);
return Result.OK;
}
// A move is legal if the pieces' rules allow it and the mover's own king isn't attacked
// after it.
List<Move> legalMoves() {
List<Move> out = new ArrayList<>();
for (Move m : candidates()) if (safeAfter(m)) out.add(m);
return out;
}
boolean inCheck() { return attacked(board, board.kingOf(turn), turn.other()); }
State state() { return state; }
Color turn() { return turn; }
int ply() { return ply; }
// The side that gave checkmate. null while playing, and after a stalemate.
Color winner() { return state == State.CHECKMATE ? turn.other() : null; }
Piece at(String square) { return board.at(Square.of(square)); }
// Plays a move already known to be legal, then updates what castling and en passant
// remember.
private void play(Move m, Piece p) {
apply(board, m);
if (p.kind() == Kind.KING) castlingRooks.removeIf(r -> r.rank() == m.from().rank());
castlingRooks.remove(m.from());
castlingRooks.remove(m.to());
boolean doublePush = p.kind() == Kind.PAWN && Math.abs(m.to().rank() - m.from().rank()) == 2;
enPassant = doublePush ? new Square(m.from().file(), (m.from().rank() + m.to().rank()) / 2) : null;
turn = turn.other();
ply++;
updateState();
}
// Every move the pieces' rules allow, plus the three that depend on the game's history rather
// than the board: en passant, castling, and promotion's choice of piece.
private List<Move> candidates() {
List<Move> out = new ArrayList<>();
int lastRank = turn == Color.WHITE ? 7 : 0;
for (Square f : board.squares()) {
Piece p = board.at(f);
if (p == null || p.color() != turn) continue;
MoveRule rule = Rules.BY_KIND.get(p.kind());
for (Square t : rule.targets(board, f)) {
if (p.kind() == Kind.PAWN && t.rank() == lastRank) {
for (Kind k : PROMOTIONS) out.add(new Move(f, t, k));
} else {
out.add(new Move(f, t, null));
}
}
if (p.kind() == Kind.PAWN && enPassant != null && rule.attacks(board, f).contains(enPassant)) {
out.add(new Move(f, enPassant, null));
}
}
addCastling(out);
return out;
}
// The king moves two squares toward a rook that can still castle, and the rook jumps over it.
// Not out of check, not through an attacked square, and only with every square between empty.
// The square the king lands on is checked by safeAfter like any other move.
private void addCastling(List<Move> out) {
if (inCheck()) return;
Square king = new Square(4, turn == Color.WHITE ? 0 : 7);
for (Square rook : castlingRooks) {
if (rook.rank() != king.rank()) continue;
int dir = rook.file() > king.file() ? 1 : -1;
boolean empty = true;
for (Square s = king.plus(dir, 0); !s.equals(rook); s = s.plus(dir, 0)) empty &= board.at(s) == null;
if (empty && !attacked(board, king.plus(dir, 0), turn.other())) out.add(new Move(king, king.plus(2 * dir, 0), null));
}
}
// Play the move on a copy and look: is our king attacked? It catches pins and discovered
// checks with no special code.
private boolean safeAfter(Move m) {
Board after = board.copy();
apply(after, m);
return !attacked(after, after.kingOf(turn), turn.other());
}
// No legal move is the end: checkmate if the king is attacked, stalemate (a draw) if not.
private void updateState() {
if (legalMoves().isEmpty()) state = inCheck() ? State.CHECKMATE : State.STALEMATE;
}
// Does any piece of color `by` threaten target? Static, so it can test a copied board too.
static boolean attacked(Board b, Square target, Color by) {
for (Square s : b.squares()) {
Piece p = b.at(s);
if (p != null && p.color() == by && Rules.BY_KIND.get(p.kind()).attacks(b, s).contains(target)) return true;
}
return false;
}
// The special moves are recognised from the board: a pawn moving diagonally to an empty
// square is en passant, and a king moving two files is castling.
static void apply(Board b, Move m) {
Piece p = b.at(m.from());
if (p.kind() == Kind.PAWN && m.from().file() != m.to().file() && b.at(m.to()) == null) {
b.put(new Square(m.to().file(), m.from().rank()), null);
}
if (p.kind() == Kind.KING && Math.abs(m.to().file() - m.from().file()) == 2) {
int dir = Integer.signum(m.to().file() - m.from().file());
Square rook = new Square(dir > 0 ? 7 : 0, m.from().rank());
b.put(m.from().plus(dir, 0), b.at(rook));
b.put(rook, null);
}
b.put(m.to(), m.promotion() == null ? p : new Piece(m.promotion(), p.color()));
b.put(m.from(), null);
}
}
// How one kind of piece moves: where the piece on from can go, ignoring check. Adding a piece,
// or a fairy-chess variant, is a new rule rather than a longer switch.
interface MoveRule {
List<Square> targets(Board board, Square from);
// The squares this piece threatens. The same as its targets for every piece but the pawn,
// which moves straight and captures diagonally.
default List<Square> attacks(Board board, Square from) { return targets(board, from); }
}
// Steps in each direction, once (king, knight) or repeatedly until something is in the way
// (rook, bishop, queen). An enemy piece in the way can be captured; an own piece can't.
record Step(int[][] directions, boolean slides) implements MoveRule {
public List<Square> targets(Board board, Square from) {
Color own = board.at(from).color();
List<Square> out = new ArrayList<>();
for (int[] d : directions) {
for (Square s = from.plus(d[0], d[1]); s.onBoard(); s = s.plus(d[0], d[1])) {
Piece p = board.at(s);
if (p == null || p.color() != own) out.add(s);
if (p != null || !slides) break;
}
}
return out;
}
}
// The pawn is the odd one out. It moves forward but captures diagonally, may move two squares
// from its start, and only goes one way. So it gets its own rule.
class PawnRule implements MoveRule {
public List<Square> targets(Board board, Square from) {
Color own = board.at(from).color();
int forward = own == Color.WHITE ? 1 : -1;
List<Square> out = new ArrayList<>();
Square one = from.plus(0, forward);
if (one.onBoard() && board.at(one) == null) {
out.add(one);
Square two = one.plus(0, forward);
boolean onStart = from.rank() == (own == Color.WHITE ? 1 : 6);
if (onStart && board.at(two) == null) out.add(two);
}
for (Square s : attacks(board, from)) {
Piece p = board.at(s);
if (p != null && p.color() != own) out.add(s);
}
return out;
}
public List<Square> attacks(Board board, Square from) {
int forward = board.at(from).color() == Color.WHITE ? 1 : -1;
return Stream.of(from.plus(-1, forward), from.plus(1, forward)).filter(Square::onBoard).toList();
}
}
// The one place that says how each kind of piece moves. Directions are {file, rank} steps.
final class Rules {
static final int[][] STRAIGHT = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}};
static final int[][] DIAGONAL = {{1, 1}, {1, -1}, {-1, 1}, {-1, -1}};
static final int[][] ALL = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}, {1, 1}, {1, -1}, {-1, 1}, {-1, -1}};
static final int[][] KNIGHT = {{1, 2}, {2, 1}, {2, -1}, {1, -2}, {-1, -2}, {-2, -1}, {-2, 1}, {-1, 2}};
// Six kinds of piece, four shapes of movement: the queen is the rook and bishop together, and
// the king and knight are steppers with different directions.
static final Map<Kind, MoveRule> BY_KIND = Map.of(
Kind.KING, new Step(ALL, false),
Kind.QUEEN, new Step(ALL, true),
Kind.ROOK, new Step(STRAIGHT, true),
Kind.BISHOP, new Step(DIAGONAL, true),
Kind.KNIGHT, new Step(KNIGHT, false),
Kind.PAWN, new PawnRule());
}
// Whether the game is still on. Checkmate and stalemate are both final.
enum State { PLAYING, CHECKMATE, STALEMATE }
// promotion is the piece a pawn becomes on the last rank, and null for every other move.
record Move(Square from, Square to, Kind promotion) {}
// A game played online. Each player's client sends a move with the ply it was made at, the
// number of moves it had seen. A move made against an old position, or the same move sent
// twice because a request timed out and was retried, is STALE and changes nothing. The lock
// makes checking the ply and playing the move one step, so two requests for the same ply can't
// both pass the check.
class SharedGame {
private final Game game;
SharedGame(Game game) { this.game = game; }
synchronized Result submit(Color player, int expectedPly, String from, String to, Kind promotion) {
if (expectedPly != game.ply()) return Result.STALE;
if (player != game.turn()) return Result.NOT_YOUR_TURN;
return game.move(from, to, promotion);
}
// A client reads this before it moves, so it can send the ply it has seen.
synchronized int ply() { return game.ply(); }
synchronized State state() { return game.state(); }
}
Read onlyJava 21Every file builds in CI15 files348 lines
all_in_one.py
all_in_one.py
Read onlyPython 3.12Every file builds in CI15 files401 lines
AllInOne.cs
AllInOne.cs
Read onlyC# · .NET 10Every file builds in CI16 files355 lines
all_in_one.go
all_in_one.go
Read onlyGo 1.24Every file builds in CI14 files482 lines
all-in-one.ts
all-in-one.ts
Read onlyTypeScript, strictEvery file builds in CI15 files419 lines

Three ideas hold it together:

  • Movement and legality are separate. Rules say where a piece can go (Strategy). The game says whether going there is allowed. No rule ever needs to know about check.
  • One legality test for every move. Normal moves, captures, castling, en passant and promotion all pass through safeAfter. Players only call move, never the board or the rules (Facade).
  • The smallest history that works. Two fields, the castling rooks and the en passant square, plus the ply count. Everything else is read from the board. Once the game ends, its state turns every move into GAME_OVER (State).

So far, both players share one device. Now each player’s app sends moves to a server.

Interviewerminute 26

The game is online. Each player’s app sends moves to your server. What goes wrong?

What they are testing

Whether you see where the danger is. It isn’t two players moving at once, since turns already prevent that. It’s one player’s move arriving twice, or a move made against a position that has changed. The red flag is “add a lock”, without saying what it makes happen as one step.

Two players can’t both have the turn, so they don’t race each other. The problems are subtler.

A retried move. A player’s phone sends “e2 to e4”. The request times out, so the phone sends it again. If both copies arrive, the first is played, and the second is checked against the new position. Usually it’s refused as illegal, but that’s luck, not design. The server can’t tell a retry from a new move, and it’s judging the move against a position the player never saw.

A move made against an old position. A player taps a move while their screen still shows an older position. The server must not play a move the player chose while looking at a different board.

The fix for both: every request carries the ply it was made at, which is the number of moves the player’s app had seen. The server plays the move only if that is still the current ply. Otherwise it answers STALE, and the app refreshes. A retried request carries the same ply as the original, so only one of them can match.

The check and the move must be one step, under one lock. Without it, two copies of the same request could both read “ply 12” before either moves, and both pass.

Sequence: a move retried after a timeout
GameSharedGameWhite's phoneGameSharedGameWhite's phonethe reply is lost, the phone retriesthe board shows e4, nothing played twicesubmit(white, ply 0, e2, e4)ply is 0, play e2 to e4OK, ply is now 1submit(white, ply 0, e2, e4)STALE, ply is 1refreshGameSharedGameWhite's phoneGameSharedGameWhite's phonethe reply is lost, the phone retriesthe board shows e4, nothing played twicesubmit(white, ply 0, e2, e4)ply is 0, play e2 to e4OK, ply is now 1submit(white, ply 0, e2, e4)STALE, ply is 1refresh
Scroll or pinch to zoom, drag to pan
  • Color key
  • Facade
Option How Retry played twice? Cost
1. Expected ply, checked under a lock (our choice) Refuse any move not made at this ply No One lock per game; apps send the ply
2. A lock, no ply Moves go one at a time, then are checked on the board Possible Moves judged on a board the player didn’t see
3. A request id, remembered Refuse an id seen before No Stores ids; old positions still accepted
4. One worker or queue per game Every move goes through one worker Needs 1 or 3 too One at a time, without a lock
SharedGame.javashared_game.pySharedGame.csshared_game.goshared-game.ts
// What a move request gets back. STALE is for online play: the move was made against an old
// position.
enum Result { OK, NO_PIECE, NOT_YOUR_TURN, ILLEGAL_MOVE, GAME_OVER, STALE }
// A game played online. Each player's client sends a move with the ply it was made at, the
// number of moves it had seen. A move made against an old position, or the same move sent
// twice because a request timed out and was retried, is STALE and changes nothing. The lock
// makes checking the ply and playing the move one step, so two requests for the same ply can't
// both pass the check.
class SharedGame {
private final Game game;
SharedGame(Game game) { this.game = game; }
synchronized Result submit(Color player, int expectedPly, String from, String to, Kind promotion) {
if (expectedPly != game.ply()) return Result.STALE;
if (player != game.turn()) return Result.NOT_YOUR_TURN;
return game.move(from, to, promotion);
}
// A client reads this before it moves, so it can send the ply it has seen.
synchronized int ply() { return game.ply(); }
synchronized State state() { return game.state(); }
}

Why is this correct? Under the game’s lock, reading the ply, checking the move and playing it happen as one step. The first copy of a request moves the ply on. Every later copy, and every move made on an older screen, sees a different ply and gets STALE.

What does it cost? One lock per game, not one for the whole server, so games never wait for each other. And the app must send the ply with each move.

Go deeperHow each language does it1 min

In Java, Python, C# and Go, each request runs on its own thread. So the lock is what makes the check and the move one step. TypeScript runs one callback at a time, so it needs no lock, as long as nothing waits with await between the check and the move. Its version saves the move to a database afterwards, once the ply has already moved on.

A game can be created from any board. The tests build positions from short strings like "Ke1 Rh1 ke8". The letter is the piece, upper case for white. Castling rights are worked out from the position. So a test is one line to set up, then a few moves. No test plays twenty moves to reach a position. The tests that matter:

  • Each piece moves by its rule, and errors come back as the right result.
  • A pinned rook can move along the pin, but not off it.
  • A player in check must answer it.
  • Fool’s mate (f3, e5, g4, Qh4) is checkmate, and the next move gets GAME_OVER.
  • King f7, queen g6 against king h8 is stalemate, not checkmate.
  • Castling moves both pieces, and is refused through an attacked square.
  • A rook that moves away and back has lost its castling right.
  • En passant works only on the very next move, and removes the captured pawn.
  • Promotion makes the chosen piece, and a queen by default.
  • Eight threads submit the same move for ply 0: exactly one is played, and seven are stale.
Go deeperThe tests in five languages2 min

The last test fails in Java, Python and Go if the lock is removed: several copies pass the ply check before any of them moves. The Python version makes threads switch often, so the race shows.

Main.javaexample.pyProgram.csmain.goexample.ts
// Builds a position from pieces like "Ke1 Ra1 ke8": the letter is the piece (N for knight), upper
// case for white and lower case for black.
static Game position(String pieces, Color toMove) {
Board b = new Board();
Map<Character, Kind> kinds = Map.of('K', Kind.KING, 'Q', Kind.QUEEN, 'R', Kind.ROOK, 'B', Kind.BISHOP, 'N', Kind.KNIGHT, 'P', Kind.PAWN);
for (String p : pieces.split(" ")) {
char c = p.charAt(0);
Color color = Character.isUpperCase(c) ? Color.WHITE : Color.BLACK;
b.put(Square.of(p.substring(1)), new Piece(kinds.get(Character.toUpperCase(c)), color));
}
return new Game(b, toMove);
}
static void play(Game g, String... moves) {
for (String m : moves) assertEquals(Result.OK, g.move(m.substring(0, 2), m.substring(2, 4)));
}
@Test
void eachPieceMovesByItsOwnRule() {
Game g = new Game();
assertEquals(Result.NO_PIECE, g.move("e4", "e5"));
assertEquals(Result.NOT_YOUR_TURN, g.move("e7", "e5"));
assertEquals(Result.ILLEGAL_MOVE, g.move("a1", "a3")); // the pawn on a2 is in the way
assertEquals(Result.ILLEGAL_MOVE, g.move("f1", "f3")); // a bishop doesn't move straight
play(g, "e2e4", "e7e5", "g1f3", "b8c6", "f1c4");
assertEquals(new Piece(Kind.BISHOP, Color.WHITE), g.at("c4"));
}
@Test
void aPinnedPieceCannotLeaveTheLine() {
Game g = position("Ke1 Re2 re8 kh8", Color.WHITE);
assertEquals(Result.ILLEGAL_MOVE, g.move("e2", "d2")); // would expose the king
assertEquals(Result.OK, g.move("e2", "e5")); // stays between them
}
@Test
void checkMustBeAnswered() {
Game g = position("Ke1 Rh1 re8 ka8", Color.WHITE);
assertTrue(g.inCheck());
assertEquals(Result.ILLEGAL_MOVE, g.move("h1", "h2"));
assertEquals(Result.OK, g.move("e1", "d1"));
}
@Test
void foolsMateEndsTheGame() {
Game g = new Game();
play(g, "f2f3", "e7e5", "g2g4", "d8h4");
assertEquals(State.CHECKMATE, g.state());
assertEquals(Color.BLACK, g.winner());
assertEquals(Result.GAME_OVER, g.move("a2", "a3"));
}
@Test
void noMovesWithoutCheckIsStalemate() {
Game g = position("Kf7 Qg5 kh8", Color.WHITE);
play(g, "g5g6");
assertEquals(State.STALEMATE, g.state());
assertNull(g.winner());
}
@Test
void castlingMovesBothPiecesAndNeedsASafePath() {
Game g = position("Ke1 Ra1 Rh1 rf8 ke8", Color.WHITE);
assertEquals(Result.ILLEGAL_MOVE, g.move("e1", "g1")); // the king would cross f1, which is attacked
assertEquals(Result.OK, g.move("e1", "c1"));
assertEquals(new Piece(Kind.ROOK, Color.WHITE), g.at("d1"));
assertNull(g.at("a1"));
}
@Test
void castlingIsLostOnceTheRookHasMoved() {
Game g = position("Ke1 Rh1 ke8", Color.WHITE);
play(g, "h1h2", "e8d8", "h2h1", "d8e8");
assertEquals(Result.ILLEGAL_MOVE, g.move("e1", "g1"));
}
@Test
void enPassantIsOnlyAvailableRightAway() {
Game g = position("Ke1 Pe5 ke8 pd7", Color.BLACK);
play(g, "d7d5", "e5d6");
assertNull(g.at("d5")); // the pawn that passed was captured
Game late = position("Ke1 Pe5 ke8 pd7", Color.BLACK);
play(late, "d7d5", "e1e2", "e8f8");
assertEquals(Result.ILLEGAL_MOVE, late.move("e5", "d6"));
}
@Test
void aPawnOnTheLastRankBecomesTheChosenPiece() {
Game g = position("Ke1 Pa7 kh7", Color.WHITE);
assertEquals(Result.OK, g.move("a7", "a8", Kind.KNIGHT));
assertEquals(new Piece(Kind.KNIGHT, Color.WHITE), g.at("a8"));
Game q = position("Ke1 Pa7 kh7", Color.WHITE);
assertEquals(Result.OK, q.move("a7", "a8"));
assertEquals(new Piece(Kind.QUEEN, Color.WHITE), q.at("a8"));
}
@Test
void racingSubmissionsForOnePlyPlayOnce() throws Exception {
SharedGame first = new SharedGame(new Game());
assertEquals(Result.NOT_YOUR_TURN, first.submit(Color.BLACK, 0, "e7", "e5", null));
for (int round = 0; round < 100; round++) {
SharedGame shared = new SharedGame(new Game());
CountDownLatch start = new CountDownLatch(1);
AtomicInteger accepted = new AtomicInteger();
AtomicInteger stale = new AtomicInteger();
List<Thread> threads = new ArrayList<>();
for (int i = 0; i < 8; i++) { // the same move, retried by an impatient client
Thread t = new Thread(() -> {
try {
start.await();
} catch (InterruptedException e) {
return;
}
Result r = shared.submit(Color.WHITE, 0, "e2", "e4", null);
if (r == Result.OK) accepted.incrementAndGet();
if (r == Result.STALE) stale.incrementAndGet();
});
t.start();
threads.add(t);
}
start.countDown();
for (Thread t : threads) t.join();
assertEquals(1, accepted.get());
assertEquals(7, stale.get()); // every other copy was rejected cleanly, none half-played
assertEquals(1, shared.ply());
}
}
Go deeperEvery edge case, and where it is handled2 min
Case What happens Where
No piece on the starting square NO_PIECE Game.move
Moving the opponent’s piece NOT_YOUR_TURN Game.move
Capturing your own piece Never listed by any rule Step, PawnRule
Moving into check, or a pinned piece ILLEGAL_MOVE safeAfter
Ignoring a check ILLEGAL_MOVE: the king is still attacked safeAfter
Castling out of, through, or into check Not listed, or refused by the same test addCastling, safeAfter
Castling after the king or rook moved Not listed: the rook left the set play
Castling after the rook was captured Not listed: captured on its square play
En passant a move too late Not listed: the square was cleared play
En passant that exposes the king on a row Refused: the copy shows the attack safeAfter
Promotion with no choice given A queen Game.move
Any move after checkmate or stalemate GAME_OVER Game.move
A position created already checkmated Starts finished constructor
The same move sent twice The second is STALE SharedGame.submit
Decision We chose We gave up
How pieces move A MoveRule per kind; three rules for six pieces Piece classes with behaviour
What a piece is A value: kind and color An identity for each piece
Legality Try every move on a copy; refuse if the own king is hit The speed of make-unmake or bitboards
The end No legal move: checkmate if in check, else stalemate Draw rules, left as an extension
Castling, en passant A set of castling rooks and one en passant square A full move history, until undo needs it
Online play The expected ply, checked with the move under a lock Accepting moves made on an out-of-date screen
Go deeperGenuinely different designs, and when to pick them2 min

Bitboards. Chess engines store the board as twelve 64-bit numbers, one per kind and color. They work out moves with bit operations, and tables computed in advance. They generate millions of moves a second, instead of thousands. It’s the right choice for an engine. It’s too hard to read for a game server, where the object design is fast enough by a wide margin.

Make and unmake, instead of copying. Instead of copying the board to test each move, engines make the move in place and undo it afterwards. That avoids the copies, at the cost of having to restore everything a move changed, exactly. That’s the same data the undo extension needs.

Skip the legality test, and check for a king capture. Some engines skip the legality test while searching. They only throw a position away when a king could be captured. It’s faster for search, and wrong for a game that must refuse illegal moves straight away.

  • 0 to 3. Two people, rules enforced, checkmate and stalemate, online.
  • 3 to 5. The promises. Name “no move leaves your king in check” as the hard one.
  • 5 to 25. The switch and its problems. Move rules as strategies. Legality on a copy, and the end of the game. Then castling, en passant and promotion. Code Step, safeAfter and addCastling.
  • 25 to 27. Walk through the full diagram: movement apart from legality, one test for every move.
  • 27 to 32. Online play: the expected ply, checked under a lock.
  • 32 to 45. Draw rules and undo from one move list, then your own questions.

If time is short, describe castling and en passant in a sentence each. Spend the time on the legality rule, and the end of the game. Those decide this question for most interviewers.

Interviewerminute 32

Add draws by repetition and the fifty-move rule, and an undo button for casual games. What changes?

What they are testing

Whether you see that all three need the same thing: a record of past positions or moves. The strong answer adds one history and works out the rest from it.

Most extensions fit into one place:

Extension What changes Touches the rules?
Undo A move list storing what each move changed No
Fifty-move rule A counter of plies since a capture or pawn move No
Threefold repetition A count per position key No
Clocks A clock per side, switched in play No
Chess960 Castling from other starting squares, kept in the same set Only addCastling
A new kind of piece One more MoveRule in the table Only the table
Save and replay Store the move list; replay from the start No
Go deeperAdding undo and draw rules, step by step2 min
  1. Keep a list of moves, each with what it changed. Besides the move itself, each entry stores the piece it captured (if any). It also stores the castling rooks and en passant square from before the move. That’s everything the move destroyed.
  2. Undo takes the last entry off, and restores it. Move the piece back. Put the captured piece back on its square (for en passant, that’s beside the target square). Move the rook back if it was castling. Turn a promoted piece back into a pawn. Restore the two history fields. Then lower the ply count, and give the turn back.
  3. The fifty-move rule is a counter. It counts plies since the last capture or pawn move, and resets in play. At 100 plies (fifty moves each), either player may claim a draw.
  4. Threefold repetition counts positions. After each move, build a key from the board, the side to move, the castling rooks and the en passant square. Count it in a map. When a count reaches three, the game can be drawn. Zobrist hashing makes that key a single 64-bit number. It’s updated in a few steps per move, instead of rebuilt from the whole board.
  5. Undo must undo these too. Lower the position’s count, and restore the fifty-move counter, which the entry can also store.

The move rules and the legality test don’t change at all. History was kept small on purpose, and these extensions add it only where a rule needs it.

Go deeperFour more follow-up questions2 min
Interviewer asksIsn't listing every legal move after every move slow?

A position has about 30 to 40 moves, each tested on a copy against at most 16 enemy pieces. That’s tens of thousands of simple steps, well under a millisecond. A server for human games makes a few moves a minute per game. It only matters for an engine searching millions of positions, which is where bitboards and make-unmake come in.

Interviewer asksHow would you add a computer opponent?

Behind the same submit as a human. A Player interface with a human and an engine implementation, where the engine is given the position and returns a move. The engine has its own fast board inside. The Game stays the referee, so a buggy engine still can’t make an illegal move.

Interviewer asksWhy is a Piece a value and not an object with an id?

Nothing in chess cares which rook is which, only where the rooks are. Values make the board copy cheap. They make promotion a matter of putting a different value on the square. And they make two positions with the same pieces equal. Castling rights are the one place identity seems to matter, and they’re kept by square instead.

Interviewer asksHow do spectators see moves as they happen?

SharedGame publishes each accepted move, with its ply, to everyone watching that game. (That’s the Observer pattern.) When watchers are on other servers, it uses a pub-sub channel. A watcher who sees ply 12 right after ply 10 knows it missed one, and fetches the position.