Pseudo-legal moves are easy. Legal moves are hard. Last week's piece classes happily generated bishop moves through the king, leaving the king in check. They had no concept of castling rights, en passant, or the king's own danger. This week we wrap the geometry in chess's actual rule book — and it's bigger than you'd think. The official FIDE handbook for movement is about 30 pages. We won't implement everything (no draws by 50-move rule timing, for now), but we'll cover what makes a move list correct.

The Move type

enum class PromoTo { None, Knight, Bishop, Rook, Queen };

struct Move {
    Square   from, to;
    PromoTo  promotion = PromoTo::None;
    bool     is_castle_kingside  = false;
    bool     is_castle_queenside = false;
    bool     is_en_passant       = false;
};

From, to, plus four flags for the special moves. promotion is needed because moving a pawn to the back rank is two pieces of information: where, and what it becomes. Without that, every game with a promoted pawn becomes ambiguous.

Make and unmake

Like in tic-tac-toe Minimax, we'll need to do a move, recurse, then undo. Copying the whole board on every recursive call is wasteful — the engine searches millions of positions per second, and we want most of that time spent thinking, not allocating. So we expose make_move and unmake_move on the Board, and snapshot only the small bits of state that the move can affect:

struct Undo {
    std::unique_ptr<Piece> captured;
    bool    prev_castle_wk, prev_castle_wq, prev_castle_bk, prev_castle_bq;
    std::optional<Square> prev_ep;
    int     prev_halfmove_clock;
};

Undo Board::make_move(Move m);
void Board::unmake_move(Move m, Undo u);

The Undo records everything make_move destroys, so unmake_move can put it back exactly. The captured piece comes home, castling rights revert, en passant target restores, the half-move clock rolls back. Anything else (the pieces themselves, since pawn-promotion replaces the moving piece) is reconstructed deterministically from the move.

The illegal-by-self-check filter

The classic mistake on a first chess engine: a pinned piece is allowed to move because the geometry says it can. The fix is the same in every engine that doesn't use pre-computed pin tables: try the move on a temporary board and check whether your own king is now under attack.

bool is_legal(Board& b, Move m) {
    Color us = b.side_to_move();
    Undo u = b.make_move(m);
    bool in_check = b.is_attacked(b.king_square(us), other(us));
    b.unmake_move(m, std::move(u));
    return !in_check;
}

is_attacked(square, by_color) is the workhorse: does any piece of by_color see square? You can write it directly (loop over enemy pieces, ask each one for its pseudo-legal moves, check if square is in the list) — quadratic and slow but trivially correct. Faster engines invert the question (cast rays from the target square outwards), but premature optimisation costs correctness, and our engine doesn't need it yet.

Castling — three preconditions

Castling is the only move where two pieces move at once, and it has a stack of preconditions:

  1. Neither the king nor the rook has moved this game (the castling-rights bits track this).
  2. No pieces between them.
  3. The king is not currently in check, does not pass through an attacked square, and does not land on an attacked square.

The rights bits get cleared whenever the king or that side's rook moves (or is captured). The "not through check" rule is the subtle one — it means generating a candidate castle, then asking is_attacked for each of the squares the king crosses.

En passant — one tiny window

If your pawn is on the 5th rank and an enemy pawn moves two squares forward and lands beside you, you may capture it as if it had moved one square. The capture happens immediately on your next move, or never. The board tracks an en_passant_target — the square the captured pawn would be on for legal en-passant — set whenever a pawn moves two squares, cleared at the next move regardless.

This is two extra lines in pawn move generation and one in make_move. It's the most surprising rule for beginners, and the most common bug in homemade engines.

Promotion — four moves, not one

When pawn move generation produces a move to the 8th (white) or 1st (black) rank, expand it into four candidate moves with promotion set to Knight, Bishop, Rook, and Queen. The search will pick the right one. (For the casual AI in Week 43, almost always Queen — but underpromotion to Knight matters in roughly one game out of fifty, and a correct engine has to consider all four.)

Game termination

After every move, check:

The first three are mandatory; the last two are claimable. For our hobby engine, mandatory checks are enough.

Verifying the engine: perft

Chess programmers test move generation with perft: count all leaf nodes at depth N from a given position, compare to known values. It catches every off-by-one bug, every missed castle, every flawed en passant.

uint64_t perft(Board& b, int depth) {
    if (depth == 0) return 1;
    uint64_t total = 0;
    for (auto& m : b.all_legal_moves()) {
        auto u = b.make_move(m);
        total += perft(b, depth - 1);
        b.unmake_move(m, std::move(u));
    }
    return total;
}

From the starting position, the canonical perft results are:

If your engine matches these, your move generation is correct. If it doesn't, perft from a "Kiwipete" position (a famously tricky test position) will pinpoint which rule is wrong. This is one of the most useful test patterns in computer game programming — exhaustive enumeration as a correctness oracle.

Why this matters

Most chess engines are 90% rule-bookkeeping and 10% search. Get the rules wrong and the search amplifies the bug — the AI confidently plays moves that aren't legal, your opponent's king vanishes, the game ends in absurdity. The reason serious chess engines have lived for decades is not because their search is clever but because their move generators are correct. Same lesson applies in any game with rich rules: state machines and validators are the foundation.

A buggy AI is just a fast way to be wrong.

Try it yourself

What's next

The engine knows the rules. Now it needs to think. Pure Minimax (Week 38) explored all 250,000 tic-tac-toe positions in milliseconds; for chess it would never finish. The fix is alpha-beta pruning — provably equivalent to Minimax, but skips entire subtrees once they can't change the answer. Combined with a depth limit and a positional evaluator, it's enough to make a chess opponent that beats casual humans.

Week 43 is Chess AI — alpha-beta pruning.

Quick check

1. How many distinct preconditions does castling have?
  1. Two — king and rook unmoved
  2. Three — unmoved pieces, no pieces between them, and king not in/through/into check
  3. Four — including being in your own half
Reveal Answer

Answer: B. The 'not through check' rule is the subtle one — it means the squares the king crosses must also be unattacked.

2. From the standard chess starting position, perft(4) should equal…
  1. 100,000
  2. 197,281
  3. 1,000,000
Reveal Answer

Answer: B. If your engine doesn't print 197,281, your move generator has a bug.

3. When a pawn promotes, the move expands into how many candidates?
  1. A single Queen move
  2. Four candidates: Knight, Bishop, Rook, Queen
  3. Eight
Reveal Answer

Answer: B. Underpromotion (especially to a Knight) matters about once in fifty real games, but a correct engine considers all four.