Loading
Loading
Modelling Case Studies
Pieces, a board, moves and rules: where polymorphism helps, where a table of directions is simpler, and why legality belongs to the game rather than the piece.
By the end of this chapter
Two players play standard chess on one device. The program must reject illegal moves, detect check, checkmate and stalemate, support castling, en passant and promotion, and allow moves to be undone. A computer opponent, clocks and network play are out of scope for now.
| Entity | Is the only thing that knows |
|---|---|
| Position (square) | A file and rank on the board — an immutable value |
| Piece | Its type and colour |
| Board | Which piece stands on which square |
| GameState | Side to move, castling rights, en passant target, move counters |
| Move | From, to, and what kind of move it is |
| Rules | Which moves are legal in a given state, and what the result is |
| Game | The sequence of moves played and how to undo them |
public record Position(int file, int rank) { public Position { if (file < 0 || file > 7 || rank < 0 || rank > 7) throw new IllegalArgumentException("off board: " + file + "," + rank); } public Optional<Position> offset(int df, int dr) { int f = file + df, r = rank + dr; return (f < 0 || f > 7 || r < 0 || r > 7) ? Optional.empty() : Optional.of(new Position(f, r)); }}The textbook design is an abstract Piece with a subclass per piece type, each overriding a method that lists where it can move. It reads naturally and it works. It is worth asking, though, what actually differs between the subclasses.
abstract class Piece { final Colour colour; Piece(Colour colour) { this.colour = colour; } abstract List<Position> candidateMoves(Position from, Board board);} final class Knight extends Piece { private static final int[][] JUMPS = {{1,2},{2,1},{2,-1},{1,-2},{-1,-2},{-2,-1},{-2,1},{-1,2}}; Knight(Colour colour) { super(colour); } List<Position> candidateMoves(Position from, Board board) { List<Position> out = new ArrayList<>(); for (int[] j : JUMPS) from.offset(j[0], j[1]) .filter(to -> !board.hasPieceOf(to, colour)) .ifPresent(out::add); return out; }}// ... Bishop, Rook and Queen repeat the same sliding loop with other directionsenum PieceType { KNIGHT(false, new int[][]{{1,2},{2,1},{2,-1},{1,-2},{-1,-2},{-2,-1},{-2,1},{-1,2}}), BISHOP(true, new int[][]{{1,1},{1,-1},{-1,1},{-1,-1}}), ROOK(true, new int[][]{{1,0},{-1,0},{0,1},{0,-1}}), QUEEN(true, new int[][]{{1,1},{1,-1},{-1,1},{-1,-1},{1,0},{-1,0},{0,1},{0,-1}}), KING(false, new int[][]{{1,1},{1,-1},{-1,1},{-1,-1},{1,0},{-1,0},{0,1},{0,-1}}), PAWN(false, new int[][]{}); // pawns are special; a rule handles them final boolean slides; final int[][] directions; PieceType(boolean slides, int[][] directions) { this.slides = slides; this.directions = directions; }} // One generator serves every piece except the pawn.List<Position> candidates(Piece piece, Position from, Board board) { List<Position> out = new ArrayList<>(); for (int[] d : piece.type().directions) { Optional<Position> next = from.offset(d[0], d[1]); while (next.isPresent()) { Position to = next.get(); if (board.hasPieceOf(to, piece.colour())) break; // own piece blocks out.add(to); if (!piece.type().slides || board.isOccupied(to)) break; // capture ends a slide next = to.offset(d[0], d[1]); } } return out;}Approaches
Pieces genuinely behave differently — fairy-chess variants with pieces that swap, teleport or have effects beyond movement.
Standard chess, where five of six pieces differ only in directions and whether they slide.
The usual best answer: geometry as data; pawn moves, castling, en passant and promotion as separate rules that can each be tested alone.
List<Move> legalMoves(GamePosition pos, Colour side) { List<Move> legal = new ArrayList<>(); for (Move m : pseudoLegal(pos, side)) { Undo undo = pos.apply(m); // make if (!isAttacked(pos, pos.kingOf(side), side.opponent())) legal.add(m); pos.undo(undo); // unmake } return legal;} GameStatus status(GamePosition pos, Colour side) { boolean inCheck = isAttacked(pos, pos.kingOf(side), side.opponent()); if (!legalMoves(pos, side).isEmpty()) return inCheck ? GameStatus.CHECK : GameStatus.ONGOING; return inCheck ? GameStatus.CHECKMATE : GameStatus.STALEMATE;}Undo needs more than from and to. Reversing a capture needs the captured piece; reversing a king move needs the castling rights that existed before it; reversing anything needs the previous en passant square. Record what a move destroyed at the moment it is applied, and undo becomes exact. This is the Command pattern with its undo half taken seriously.
enum MoveKind { NORMAL, DOUBLE_PAWN_PUSH, CASTLE_KINGSIDE, CASTLE_QUEENSIDE, EN_PASSANT, PROMOTION } record Move(Position from, Position to, MoveKind kind, Optional<PieceType> promotion) {} // Everything apply() overwrote, so undo() can restore it exactly.record Undo(Move move, Optional<Piece> captured, CastlingRights castlingBefore, Optional<Position> enPassantBefore, int halfmoveClockBefore) {}Before and after
Move(from, to) only
Move plus Undo record
Sign in to keep a note here. It is free, and nobody else can see it.
Sign inWhat this chapter was for:
En passant captures a pawn that is not on the destination square, so even 'what was captured' cannot be recovered from the move alone.