Guide · System design
Chess LLD: Design a Chess Game in Java, Pieces to Checkmate
30 min readUpdated By Saket Bhatnagar
Researched and drafted with AI, reviewed and edited by Saket Bhatnagar.
In short
- Chess LLD (low-level design) is the class design for a playable chess game. It has a board, the pieces, moves, a rule engine that holds the rules, and a game object that runs turns.
- Keep the board dumb. It only records which piece stands on which square, while the separate rule engine decides which moves are allowed.
- Each piece lists its pseudo-legal moves, the moves its shape allows. The rule engine tries each one and drops any that leaves your own king attacked, which is called check.
- Three special moves need memory the board cannot show. They are castling (king and rook move together), en passant and promotion (a pawn becoming another piece). En passant takes an enemy pawn that has just moved two squares and landed beside yours. Keep that memory in a game state object and inside each move.
- Beginners can stop after the special moves. Engineers get endings and draws, undo, and why repetition must ignore some en passant squares. They also see the usual perft test, a count of every move sequence from the starting position, let three of our four planted bugs through.
What goes wrong when a chess app checks only how pieces move?
It accepts illegal moves, because many chess rules depend on the whole position, not on one piece. A good chess LLD puts each of those rules in one clear place.
Picture a common setup. A small team runs an online chess app for club players, used for rapid games and weekend league matches. The first move checker was one long method with a big switch on the piece type. The setup is made up, but the four bugs below are the classic ones.
The checker handled ordinary moves well. Then the club secretaries started sending bug reports, and they fell into four piles.
- A player castled while their king was in check. The app moved king and rook as if nothing was wrong.
- A player moved a pinned bishop. Pinned means the bishop stood between its own king and an enemy rook, so moving it exposed the king.
- A pawn captured en passant three moves after the enemy pawn had advanced. The rule allows that capture only on the very next move.
- A game where the player to move had no legal move, but was not in check, was shown as a win. It should have been a draw by stalemate.
All four bugs share one root. The checker looked at one piece at a time, but each of these rules needs the whole position, or something from earlier in the game. This guide rebuilds the checker as proper classes, and the same design holds up in an LLD interview.
What is a chess LLD, and what does it cover?
A chess LLD is the set of classes, fields and methods that make a two-player chess game work in code. It answers how squares, pieces, moves, turns and rules fit together.
High-level design is about servers, databases and how many users a system serves. Low-level design zooms in on one program. You decide which classes exist, what each one owns, and how they call each other.
Chess is a favourite LLD question for a simple reason. Everyone knows the board, but the rules hide real design problems. Six piece types move differently, three special moves need hidden memory, and a game can end in several ways.
A few words come up again and again, so here they are in plain terms.
- Square: one of the 64 cells, named by a file (column a to h) and a rank (row 1 to 8), like e4.
- Move: one piece going from one square to another, plus any extra detail, such as the piece chosen when a pawn promotes.
- Check: your king is attacked, meaning an enemy piece could capture it if it were the enemy's turn.
- Pseudo-legal move: a move the piece's shape allows on the current board, before asking whether it leaves your own king in check.
- Legal move: a pseudo-legal move that also leaves your own king out of check. Only legal moves may be played.
What are the requirements for a chess game design?
The core requirements are to let two players take turns, accept only legal moves, and detect when and how the game ends. Everything else, like clocks and saved games, comes after those.
In an interview, say these out loud before drawing any class. It shows you know where the scope ends. The team wrote this list, split into must-haves and later additions.
- Must have: set up the standard starting position, with White to move first.
- Must have: accept a move only if it is legal for the player whose turn it is.
- Must have: support castling, en passant and pawn promotion.
- Must have: detect check, checkmate, stalemate and the draws that happen without a claim.
- Must have: keep a full move history, so a game can be replayed or a disputed move checked.
- Later: chess clocks, draw offers, resignation, saved games and an export for other chess tools.
Notice what is missing. There is no computer opponent and no move suggestions. Those belong to a chess engine, a program that searches for good moves, which is a different problem from enforcing the rules.
What classes do you need to design a chess game?
You need about eight classes: Game, Board, Square, Piece with one subclass per piece type, Move, GameState, RuleEngine and Player. Each one owns a single job.
Think of a tournament game. The wooden board only shows where the pieces stand. The arbiter, the official who applies the rules, decides whether a move was legal. The scoresheet records every move in order.
The classes map onto that picture. Board is the wooden board. RuleEngine is the arbiter. The move history is the scoresheet, and Game is the game itself, passing the turn from one player to the other.
Where the picture breaks: a real arbiter acts on an illegal move once it has been completed. In a standard game, Article 7.5.1 of the FIDE Laws, the official rules of the International Chess Federation, then puts the earlier position back. Your RuleEngine refuses the move before the board changes.
Here is the class map. Start at Game in the middle, since every request from a player enters there. Notice that only RuleEngine knows the rules.
Chess LLD classes and who owns what
Drag to exploreGame coordinates, Board stores, RuleEngine decides. Notice that no arrow lets a Piece change the board by itself.
Diagram connections:
- Player to Game (Submits a move)
- Game to GameState (Owns)
- Game to Board (Owns)
- Board to Piece and 6 subclasses (Holds up to 32)
- Game to RuleEngine (Asks for legal moves)
- RuleEngine to Piece and 6 subclasses (Collects pseudo moves)
- Game to Move history (Appends each move)
- Player: Has a name, a rating and a colour
- Game: Runs turns, history and the final result
- GameState: Side to move, castling rights, en passant
- Board: An 8 by 8 grid of squares, nothing more
- Piece and 6 subclasses: Each lists the moves its shape allows
- RuleEngine: Filters legal moves, decides endings
- Move history: Every move in order, used for undo
Square is a small value object, which means two squares with the same file and rank are equal. In Java you can write it as a record, a class whose fields are fixed when it is created. Records became a standard Java feature in Java 16.
Move is a value object too. It holds the from square, the to square and a type: normal, two-square pawn advance, castle, en passant or promotion. For a promotion it also holds the chosen piece.
GameState holds everything about the position that you cannot see by looking at the pieces. That is the side to move, which castlings are still allowed, the en passant square, and two counters.
FEN, short for Forsyth-Edwards Notation, is a one-line text format for a chess position. It is defined in the PGN (Portable Game Notation) standard, the plain-text format for saving chess games. FEN has six fields: piece placement, active colour, castling availability, en passant target square, halfmove clock (single moves since the last pawn move or capture) and fullmove number.
Board supplies the first field and GameState supplies the other five. If one of the six has nowhere to come from, your design is missing state.
How do you model chess pieces and their moves in Java?
Model Piece as an abstract class with a colour and one abstract method that lists its pseudo-legal moves. Each of the six subclasses fills in that method with its own movement pattern.
This is polymorphism: many classes share one method name, and each answers it in its own way. RuleEngine can ask any piece for its moves without knowing which piece it is. A new piece for a club variant means one new subclass.
Pieces fall into three groups. Sliders, the rook, bishop and queen, move any distance until something blocks them. The knight and king take one fixed step in each direction. The pawn is its own group, because it moves one way and captures another.
The sliders share one routine, so it lives in the parent class. In the code, Square.offset returns the next square in a direction, or null when that step would leave the board. Board.at returns the piece on a square, or null if it is empty. The code uses Java records, so it needs Java 16 or newer, and we ran it on Java 17 and 21.
abstract class Piece {
static final int[][] STRAIGHT = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}};
static final int[][] DIAGONAL = {{1, 1}, {1, -1}, {-1, 1}, {-1, -1}};
final Color color;
Piece(Color color) { this.color = color; }
abstract List<Move> pseudoMoves(Board board, Square from);
// Rook, bishop and queen share this: walk each direction until blocked
protected List<Move> slide(Board board, Square from, int[][] dirs) {
List<Move> moves = new ArrayList<>();
for (int[] d : dirs) {
Square to = from.offset(d[0], d[1]);
while (to != null) {
Piece other = board.at(to);
if (other == null) moves.add(new Move(from, to));
else { if (other.color != color) moves.add(new Move(from, to)); break; }
to = to.offset(d[0], d[1]);
}
}
return moves;
}
// step() for the knight and king, and small helpers, follow in the full file
}The line with else matters most. When the walk meets a piece, it adds a capture only if that piece belongs to the other side. Then it stops, because no slider can jump over anything.
A Rook subclass now needs one line in its pseudoMoves method: return slide(b, from, STRAIGHT). A Bishop passes DIAGONAL, and a Queen passes all eight directions.
The knight and king use a sister routine, step, which tries each direction once instead of walking. The pawn needs its own code. It moves forward one square, or two from its starting rank if both squares are empty, and captures only diagonally forward.
A pawn that reaches the far rank must promote. The FIDE Laws say it becomes a queen, rook, bishop or knight of the same colour, as part of the same move. So the pawn's code creates four moves there, one per choice.
How do you check that a chess move is legal?
A move is legal when the piece's shape allows it and, after making it, your own king is not in check. The simple way to test that is to make the move, look for attacks on your king, then take it back.
The FIDE Laws state the rule directly. Article 3.9.2 says no piece may move in a way that exposes its own king to check or leaves it in check.
That one rule covers three cases: pins, moving the king into check, and ignoring a check. So one filter can handle all three.
Here is the filter, together with the status method that reuses it. Game.apply makes a move and flips the side to move. Game.undo reverses the last one. Game.isKingAttacked asks whether any enemy piece attacks the king of a given colour.
public List<Move> legalMoves(Game game) {
Color me = game.sideToMove();
List<Move> legal = new ArrayList<>();
for (Move move : pseudoMoves(game)) {
game.apply(move); // try the move for real
boolean kingSafe = !game.isKingAttacked(me);
game.undo(); // put everything back
if (kingSafe) legal.add(move);
}
return legal;
}
public GameStatus status(Game game) {
boolean inCheck = game.isKingAttacked(game.sideToMove());
if (legalMoves(game).isEmpty()) return inCheck ? GameStatus.CHECKMATE : GameStatus.STALEMATE;
if (game.halfmoveClock() >= 150 || game.repeatCount() >= 5) return GameStatus.DRAWN;
if (game.board.isInsufficientMaterial()) return GameStatus.DRAWN;
return GameStatus.ACTIVE;
}Two lines carry the idea. The variable me is saved before the move, because apply flips the turn. So isKingAttacked(me) checks your king after your move, which is what Article 3.9.2 asks.
Take the pinned bishop from the bug list. White has a king on e1 and a bishop on e2, and a black rook stands on e8. The bishop's shape allows nine squares, so the bishop reports nine pseudo-legal moves.
The filter makes each one, sees the rook now attacking e1, and undoes it. All nine are dropped, and our test run confirms the bishop ends with zero legal moves.
The status method comes back later in this guide. For now, notice that it reuses legalMoves. One list of legal moves answers both questions: may this move be played, and is the game over?
The chess programming community calls the first list pseudo-legal and the filtered list legal. Its wiki notes that generating only legal moves takes extra work, and that pins are the main difficulty, especially with en passant.
Try and undo avoids that difficulty at the cost of speed. Our test code still counted about 4.9 million positions in a few seconds, on a single thread of a small 2-core test machine. That is far more than a club app needs.
Now follow one move through the system. Start at the top, when the server receives a move from a player, and see where a move can be turned away.
What happens when a player submits a move
Drag to exploreA move is checked against the list of legal moves, never against piece shape alone. Both No answers end at the same reject box.
Diagram connections:
- Player submits e2 to e4 to Is it this player's turn?
- Is it this player's turn? to Is the move in legal list? (Yes)
- Is it this player's turn? to Reject and keep position (No)
- Is the move in legal list? to Reject and keep position (No)
- Is the move in legal list? to Apply move, update state (Yes)
- Apply move, update state to Append to history
- Append to history to Work out game status
- Player submits e2 to e4: Player → Game
- Is it this player's turn?: Game → GameState
- Is the move in legal list?: Game → RuleEngine
- Reject and keep position: Game → Player
- Apply move, update state: Game → Board and GameState
- Append to history: Game → Move history
- Work out game status: Game → RuleEngine
How do you handle castling, en passant and promotion in code?
Give each special move its own move type, generate it with help from GameState, then pass it through the same legal-move filter as every other move. Each one needs a little extra memory and a few extra checks.
Here is the memory. GameState is a record, and Undo is what apply saves so that a move can be reversed later.
// Everything about the position that you cannot see by looking at the pieces.
// castling holds four bits: 1 = White kingside, 2 = White queenside, 4 = Black kingside, 8 = Black queenside.
record GameState(Color sideToMove, int castling, Square enPassant, int halfmoveClock, int fullmoveNumber) {}
// What a move destroyed, so undo can put it back exactly.
record Undo(Move move, Piece moved, Piece captured, Square capturedOn, GameState before) {}Castling
Castling moves the king two squares toward a rook, and the rook jumps to the square the king crossed. FIDE Article 3.8 splits its conditions into two kinds.
The right to castle is lost for good once the king has moved, or once that rook has moved. So GameState keeps four flags: White kingside, White queenside, Black kingside and Black queenside.
Castling is blocked for the moment, but the right is not lost, when a piece stands between king and rook. It is also blocked when the king's own square, the square it crosses, or the square it lands on is attacked. That attack check is what the old checker forgot.
Read the code as three gates: the king is not in check now, the path between king and rook is empty, and the squares the king uses are safe.
private void addCastling(Game game, List<Move> moves) {
Color me = game.sideToMove();
Color them = me.opposite();
int rank = me == Color.WHITE ? 0 : 7;
int kingsideBit = me == Color.WHITE ? 1 : 4, queensideBit = me == Color.WHITE ? 2 : 8;
Board b = game.board;
Square e = new Square(4, rank);
if (b.isAttacked(e, them)) return; // never castle out of check
if ((game.state.castling() & kingsideBit) != 0
&& b.at(new Square(5, rank)) == null && b.at(new Square(6, rank)) == null
&& !b.isAttacked(new Square(5, rank), them) && !b.isAttacked(new Square(6, rank), them))
moves.add(new Move(e, new Square(6, rank), MoveType.CASTLE, ' '));
if ((game.state.castling() & queensideBit) != 0
&& b.at(new Square(3, rank)) == null && b.at(new Square(2, rank)) == null
&& b.at(new Square(1, rank)) == null // b1 must be empty but may be attacked
&& !b.isAttacked(new Square(3, rank), them) && !b.isAttacked(new Square(2, rank), them))
moves.add(new Move(e, new Square(2, rank), MoveType.CASTLE, ' '));
}Look at the queenside line. The b1 square must be empty, but it may be attacked, because the king never stands on it. Only the rook passes b1.
Moving the king clears both flags of its colour. Moving a rook from its home corner clears one flag, and so does capturing a rook on its home corner. The apply method in the undo section does all three.
En passant
En passant lets a pawn capture an enemy pawn that has just moved two squares and landed beside it, as if it had moved only one. The FIDE Laws, in Article 3.7, allow this capture only on the very next move.
That timing is the whole design. GameState stores one en passant square, set when a pawn advances two squares and cleared by the next move. The old checker searched the history for the last pawn move instead, which is how the late capture slipped through.
En passant also strains the undo step. The captured pawn does not stand on the square the capturing pawn lands on. So apply and undo must remove and restore it from the square beside it.
Promotion
Promotion needs the least memory. The Move object carries the chosen piece, and undo puts the original pawn back, because the Undo record saved the piece that moved.
| Special move | Extra state needed | Extra checks | Typical bug |
|---|---|---|---|
| Castling | Four castling-right flags | Empty path, and the king not in, through or into check | Castling out of check |
| En passant | One en passant square, or none | Only on the move right after the two-square advance | Capturing several moves late |
| Promotion | The chosen piece inside the Move | Only on the last rank, and only four piece choices | Always making a queen, no choice |
| Two-square pawn advance | Sets the en passant square | Both squares in front must be empty | None, but it feeds en passant |
How does a chess game detect checkmate, stalemate and draws?
A chess game detects checkmate and stalemate with one question: does the side to move have any legal move left? If not, it is checkmate when that king is in check, and stalemate when it is not.
Nobody wins. FIDE Article 5.2.1 says the game is drawn when the player to move has no legal move and is not in check. The old checker only asked whether the king was trapped, so it called stalemates wins.
Draws come in more forms. Some need a claim from a player, under Article 9, while others end the game at once.
- Threefold repetition: the same position appears for the third time. A player may claim a draw.
- Fifty-move rule: the last 50 moves by each player had no pawn move and no capture. A player may claim a draw.
- Fivefold repetition: the same position appears five times. The game is drawn without a claim.
- Seventy-five-move rule: the last 75 moves by each player had no pawn move and no capture. The game is drawn without a claim.
- Dead position: neither player can ever checkmate, such as a king against a king. The game is drawn at once.
This is why the status method checks 150 halfmoves and five repetitions. Those draws need nobody to claim them, so the server ends the game. The threefold and fifty-move draws become a Claim draw button.
FIDE also lets a player claim a repetition just before making the move that repeats it, by writing the move down first. A Claim draw button that works only after the move covers the common case, not that one.
The halfmove clock counts single moves by either side since the last pawn move or capture. Reset it to zero on either one, and add one otherwise. Fifty moves each means 100 halfmoves, and seventy-five each means 150.
A full dead-position test is hard, so the status method checks a safe subset. That is king against king, king and one bishop or knight against king, and king and bishop against king and bishop with both bishops on squares of one colour.
Do not widen that subset by guessing. King and two knights against a king is not a dead position, because some series of legal moves still ends in mate. Our test keeps that game active.
Follow the checks below in order after every move. Start at the top box, and notice that checkmate is decided before any draw.
Ending checks after each move
Drag to exploreCheckmate is tested first. FIDE Article 9.6.2 says a mate on the move that reaches 75 moves still wins.
Diagram connections:
- A legal move was played to Any legal move left?
- Any legal move left? to Is that king in check? (No)
- Is that king in check? to Checkmate (Yes)
- Is that king in check? to Stalemate, a draw (No)
- Any legal move left? to Fivefold, 75 moves or dead? (Yes)
- Fivefold, 75 moves or dead? to Automatic draw (Yes)
- Fivefold, 75 moves or dead? to Game continues (No)
- A legal move was played: Turn passes to the other side
- Any legal move left?: Uses the same legalMoves list
- Is that king in check?: Any enemy piece attacking it
- Checkmate: The side that moved wins
- Stalemate, a draw: No legal move, and not in check
- Fivefold, 75 moves or dead?: Repeat count, clock, material
- Automatic draw: No claim needed
- Game continues: Claim draw button may be active
We tested that order on a back-rank mate played as the 150th quiet halfmove. The status came back as checkmate. The same position with a quiet rook move instead came back drawn.
How do you know two chess positions are the same for repetition?
Two positions are the same only when the same pieces stand on the same squares, the same player is to move, and both sides have the same possible moves. That last part brings in castling rights and en passant.
FIDE Article 9.2.3 spells this out. A position where a pawn could have been captured en passant is not the same as one where it cannot. Castling rights count too, and they are lost only after the king or rook moves.
So you need a position key, a short text that is equal for two positions exactly when FIDE calls them the same. A map from key to count then gives repeatCount.
The first four FEN fields look like a ready-made key: pieces, side to move, castling rights and en passant square. There is one trap. The PGN standard writes an en passant square after every two-square pawn advance, even when no pawn can capture there.
Here is what that breaks. In move notation, 1.e4 is White's first move, a pawn to e4, and N means a knight. After 1.e4 the FEN says e3, though no black pawn can capture there. After 1.e4 Nf6 2.Nf3 Ng8 3.Ng1, the same position is back with a dash in that field.
FIDE counts those as the same position, but a raw FEN key sees two different strings. A repetition counter built on raw FEN would undercount, and a fivefold draw could be missed.
So the key writes the en passant square only when a legal en passant capture exists. Legal matters, not just a pawn standing beside it. A capture that would expose your own king along the rank is not a possible move.
// Same position for repetition = same pieces, same side to move, same castling rights,
// and the same en passant option. Count the en passant square only if a LEGAL capture exists.
public String positionKey(Game game) {
String[] fen = game.toFen().split(" ");
boolean epCapture = false;
for (Move m : legalMovesIfEnPassant(game)) epCapture |= m.type() == MoveType.EN_PASSANT;
return fen[0] + " " + fen[1] + " " + fen[2] + " " + (epCapture ? fen[3] : "-");
}The helper legalMovesIfEnPassant returns an empty list when there is no en passant square, so most positions skip the extra work.
We ran both cases. In the 1.e4 line, the key counted the position twice. In the second case, a white king on a5 and pawn on b5 faced a black pawn just arrived on c5 and a black rook on h5. The en passant capture was pseudo-legal but illegal, and the key wrote a dash.
How do you check whether a chess square is attacked?
Look outward from the square for each kind of attacker. Check the eight lines for sliders, the eight knight jumps, the king's eight neighbours, and the two squares an enemy pawn would capture from.
The tempting shortcut is to list every enemy pseudo-legal move and see whether one lands on the square. That works for the king's own square, because a piece stands there.
It fails for castling. Pawns list a diagonal move only when there is something to capture, so a pawn never reports an attack on an empty square like f1.
In the code below, at(square, colour) returns the piece there only if it has that colour, and null for a square off the board. Our test put a black pawn on g2 and the white king on e1 with castling rights. The enemy move list never named f1, though the pawn attacks it. The outward check below caught it, and castling was refused.
// Look outward from the square: is any piece of colour by attacking it?
boolean isAttacked(Square sq, Color by) {
int pawnRow = by == Color.WHITE ? -1 : 1; // white pawns attack from the rank below
for (int df : new int[]{-1, 1})
if (at(sq.offset(df, pawnRow), by) instanceof Pawn) return true;
for (int[] d : Knight.JUMPS) if (at(sq.offset(d[0], d[1]), by) instanceof Knight) return true;
for (int[] d : Queen.ALL) if (at(sq.offset(d[0], d[1]), by) instanceof King) return true;
for (int[] d : Queen.ALL) {
boolean straight = d[0] == 0 || d[1] == 0;
Square s = sq.offset(d[0], d[1]);
while (s != null && at(s) == null) s = s.offset(d[0], d[1]);
Piece p = s == null ? null : at(s);
if (p != null && p.color == by && (p instanceof Queen
|| (straight ? p instanceof Rook : p instanceof Bishop))) return true;
}
return false;
}How do you add undo, move history and saved games?
Keep every move in an ordered list, and store with each one what it destroyed, so undo can restore it exactly. Saved games then become the move list written in a standard format.
Undo is harder than it looks, because a move erases information. After a capture, the captured piece is gone. After a king move, the old castling flags are gone. After any move, the old en passant square and halfmove clock are gone.
So each history entry is an Undo record: the move, the piece that moved, the captured piece and its square, and the whole GameState from before. This is close to the command pattern, where each action is an object you can undo. Here Game does the undoing, and the Undo record is the snapshot it needs, often called a memento.
void apply(Move m) {
Piece piece = board.at(m.from());
Square capturedOn = m.type() == MoveType.EN_PASSANT
? new Square(m.to().file(), m.from().rank()) // the captured pawn is beside us
: m.to();
Piece captured = board.at(capturedOn);
history.push(new Undo(m, piece, captured, capturedOn, state));
board.remove(capturedOn);
board.remove(m.from());
board.put(m.to(), m.type() == MoveType.PROMOTION ? Piece.create(m.promoteTo(), piece.color) : piece);
if (m.type() == MoveType.CASTLE) moveCastlingRook(m, false);
int rights = state.castling();
if (piece instanceof King) rights &= piece.color == Color.WHITE ? ~3 : ~12;
rights &= ~rightLostAt(m.from()) & ~rightLostAt(m.to()); // a rook moved or was captured
Square ep = m.type() == MoveType.DOUBLE_PAWN
? new Square(m.from().file(), (m.from().rank() + m.to().rank()) / 2) : null;
int half = (piece instanceof Pawn || captured != null) ? 0 : state.halfmoveClock() + 1;
int full = state.fullmoveNumber() + (state.sideToMove() == Color.BLACK ? 1 : 0);
state = new GameState(state.sideToMove().opposite(), rights, ep, half, full);
}
void undo() {
Undo u = history.pop();
board.remove(u.move().to());
board.put(u.move().from(), u.moved()); // a promoted piece turns back into the pawn
if (u.captured() != null) board.put(u.capturedOn(), u.captured());
if (u.move().type() == MoveType.CASTLE) moveCastlingRook(u.move(), true);
state = u.before(); // rights, en passant square, clocks
}Notice that undo never recomputes anything. It puts back the saved piece, the saved capture and the saved GameState. Recomputing old castling rights or the old en passant square is where undo bugs come from.
Undo serves two jobs. The legal filter uses it once for every pseudo-legal move it tries. Casual games can also offer a take-back button, which tournament games switch off.
The legal filter must not touch the repetition counts, or every tried move would count as a visit. So a real move goes through one entry point, play, which checks, applies and counts.
// The single entry point for a real move: check it, make it, count the position.
boolean play(Move move) {
if (!rules.legalMoves(this).contains(move)) return false;
apply(move);
seen.merge(rules.positionKey(this), 1, Integer::sum);
return true;
}
void takeBack() {
seen.merge(rules.positionKey(this), -1, Integer::sum);
undo();
}For saved games, use PGN, the game format from that same standard. It writes moves in Standard Algebraic Notation (SAN), like Nf3 or exd5.
Kingside castling is written O-O and queenside O-O-O. The standard says these use the capital letter O, not the digit zero.
Export also gives club secretaries a way to settle disputes. They paste the PGN into a chess tool that reads it and replay the game move by move, without trusting the app's own code.
Clocks fit in as one more class. A ChessClock holds the time left for each side and an increment, extra seconds added after each move. Game switches the clock when a move is applied, and running out of time ends the game.
How do you test a chess move generator with perft?
Perft, short for performance test, counts every possible sequence of legal moves to a fixed depth. You compare your count with published numbers. A rule bug changes the count once the test reaches a position where that rule matters.
Depth is counted in plies, single moves by one side. From the starting position there are 20 legal first moves, and 400 ways for both sides to play one move each.
// Count every move sequence of a given length. Compare with the published numbers.
static long perft(Game game, int depth) {
if (depth == 0) return 1;
long nodes = 0;
for (Move move : game.rules.legalMoves(game)) {
game.apply(move);
nodes += perft(game, depth - 1);
game.undo();
}
return nodes;
}The Chess Programming Wiki publishes the counts for several test positions. We ran our code against three of them, on Java 17 and Java 21, and got the same numbers on both.
| Position | Depth | Published count | Our count |
|---|---|---|---|
| Starting position | 3 | 8,902 | 8,902 |
| Starting position | 4 | 197,281 | 197,281 |
| Starting position | 5 | 4,865,609 | 4,865,609 |
| Kiwipete (position 2) | 3 | 97,862 | 97,862 |
| Kiwipete (position 2) | 4 | 4,085,603 | 4,085,603 |
| Position 3 | 5 | 674,624 | 674,624 |
Here is the catch. The same wiki table shows zero castles and zero promotions from the starting position through depth 6, and no en passant before depth 5. A starting-position test to depth 4 never plays a correct special move, so it only catches a bug that invents one, as in the first row below.
Kiwipete, the wiki's position 2, fixes that. By the wiki's table it has castles from depth 1, en passant from depth 2 and promotions at depth 4. Its FEN is r3k2r/p1ppqpb1/bn2pnp1/3PN3/1p2P3/2N2Q1p/PPPBBPPP/R3K2R w KQkq -
To see the difference, we planted four bugs, one at a time, in copies of our code. Then we ran both positions.
| Planted bug | Starting position, depths 1 to 4 | Kiwipete |
|---|---|---|
| En passant square never cleared | Wrong at depth 3: 9,070 | Wrong at depth 3: 97,987 |
| Pawns seen attacking only occupied squares | All four counts correct | Wrong at depth 3: 97,898 |
| Castling allowed out of check | All four counts correct | Wrong only at depth 4: 4,086,290 |
| Captured rook keeps its castling right | All four counts correct | Wrong only at depth 4: 4,085,659 |
Three of the four bugs passed the starting-position test. Kiwipete caught all four, but two only at depth 4, which took several seconds per run on our 2-core test machine.
When a count is wrong, split it by first move, a check the Chess Programming Wiki calls divide. Print the count under each legal first move and compare with a trusted engine's split, then repeat one level down until the bad move appears.
Which design choices do interviewers push on in a chess LLD?
Interviewers usually push on where the rules live, how the board is stored, and how you would add a variant. Have a short answer and a trade-off ready for each.
Where do the rules live?
Piece shapes live in Piece subclasses. Everything that needs the whole position lives in RuleEngine: king safety, castling, en passant and endings.
How is the board stored?
An 8 by 8 array of Piece references is easy to read. Many engines use bitboards, 64-bit numbers with one bit per square, which test many squares at once.
Why not put moves on Board?
Board would then need to know rules, turns and history. Keeping it a plain store means you can copy it, print it and test it alone.
How would you add a variant?
RuleEngine is already an interface, and StandardRules implements it for standard chess. A variant extends StandardRules and overrides only the methods that differ, so make those protected, not private.
How do two players play online?
The server holds the Game and checks every move. Clients only send moves and draw the board, so a changed client cannot change the position.
One more rule keeps the design honest. Only Game changes the board, and only through apply, undo and play. That single entry point is what makes a bug easy to trace.
How do you explain chess LLD in a system design interview?
Explain it from the outside in: requirements, then the main classes, then the legal-move filter, then special moves and endings. Keep each step short, and let the interviewer pull you deeper.
State the scope
Two players, standard rules, legal moves only, all endings detected. Clocks and computer play are out unless asked.
Draw the classes
Game, Board, Square, Piece and its subclasses, Move, GameState, RuleEngine and Player. Say in one line what each one owns.
Show piece movement
One abstract method for pseudo-legal moves, with a shared slide routine for rook, bishop and queen.
Explain the legal filter
Try each move, check whether your own king is attacked, undo it. Pins and moving into check are then handled without extra code.
Cover the special moves
Castling rights and the en passant square live in GameState. Promotion carries the chosen piece in Move.
Finish with endings and tests
No legal moves splits into checkmate or stalemate. Add repetition and move-count draws, then name perft on Kiwipete as your test.
Questions people ask
- Should Piece be an abstract class or an interface in a chess LLD?
- Either works, and an abstract class is slightly simpler here. Every piece shares a colour field and the slide and step routines, and an abstract class can hold both. An interface fits better if pieces share no code, or if a class must also extend something else.
- Should each chess piece know whether its own move is legal?
- No, a piece should only list the moves its shape allows. Legality depends on the whole position: pins, checks, castling rights and the en passant square. Putting that in each piece would copy the same king-safety check into six classes.
- How do you detect check in a chess game?
- Find the king's square, then look outward from it for enemy attackers. Check the eight lines, the knight jumps, the neighbouring squares and the two pawn capture squares. Listing enemy moves also works for the king's own square, but it misses pawn attacks on the empty squares castling needs.
- How do you represent a chess board in Java?
- The simplest choice is an 8 by 8 array of Piece references, with null for an empty square. It is easy to read and debug. Many chess engines use bitboards instead, which are 64-bit long values with one bit per square, so one bitwise instruction can test many squares at once.
- What design patterns are used in a chess game LLD?
- Common ones are polymorphism for piece movement, command-style move objects with a saved undo record, and the strategy pattern for swappable rule sets in variants. An observer, a listener told about each change, can push board updates to the players' screens.
- What is the difference between threefold repetition and fivefold repetition?
- Threefold repetition lets a player claim a draw, while fivefold repetition ends the game as a draw without any claim. Both count the same position, meaning the same pieces, the same side to move, and the same castling and en passant options.
- Do I need to implement the fifty-move rule in a chess interview?
- Mention it, and show where it lives, but you rarely need to code it. Say that GameState keeps a halfmove clock, reset on any pawn move or capture. Then say the fifty-move draw needs a claim, while seventy-five moves ends the game without one.
Sources and further reading
- FIDE Laws of Chess taking effect from 1 January 2023 (FIDE Handbook): Articles 3.7, 3.8, 3.9, 5.2, 7.5, 9.2, 9.3 and 9.6
- Standard: Portable Game Notation Specification and Implementation Guide (Edwards, 1994): SAN castling 8.2.3, FEN fields 16.1
- Move Generation: legal and pseudo-legal moves (Chess Programming Wiki)
- Perft Results: starting position, Kiwipete and Position 3 counts (Chess Programming Wiki)
- Bitboards (Chess Programming Wiki)
- JEP 395: Records, final in JDK 16 (OpenJDK)