Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Making and Unmaking Moves

Move generation may produce thousands of candidate moves for a position, but a chess engine only becomes useful once it can efficiently execute and reverse those moves. During search, every generated move is typically made, evaluated, and then unmade again before the next move is considered. This process repeats millions of times per second, making move execution one of the most performance-critical parts of the engine.

As discussed in the previous section, the board implementation relies heavily on incremental updates. When a move is made, only the information affected by that move is updated. Piece bitboards, occupancy information, evaluation terms, game phase values, Zobrist keys, castling permissions, and en passant information are all modified incrementally rather than recomputed from scratch.

The make() function is responsible for applying a move and verifying that it is legal. The unmake() function reverses the operation and restores the previous position. Together, these functions form the foundation of the engine’s search process.

permissions_per_square()

This helper function creates a lookup table that determines which castling rights remain available after a move involving a particular square. Most squares leave castling permissions unchanged, while the king and rook starting squares remove the appropriate castling rights.

#![allow(unused)]
fn main() {
const fn permissions_per_square() -> PermissionsPerSquare {
    let mut cp: PermissionsPerSquare = [Permissions::ALL.as_u64(); NrOf::SQUARES];

    cp[Square::A1.idx()] &= !Permissions::WQ.as_u64();
    cp[Square::E1.idx()] &= !Permissions::WK.as_u64() & !Permissions::WQ.as_u64();
    cp[Square::H1.idx()] &= !Permissions::WK.as_u64();
    cp[Square::A8.idx()] &= !Permissions::BQ.as_u64();
    cp[Square::E8.idx()] &= !Permissions::BK.as_u64() & !Permissions::BQ.as_u64();
    cp[Square::H8.idx()] &= !Permissions::BK.as_u64();

    cp
}
}

make()

The make() function executes a move on the board and determines whether that move is legal. Before making any changes, the current game state is pushed onto the history stack so it can later be restored by unmake().

The function then processes the move step by step:

  1. Save the current game state.
  2. Extract all move information.
  3. Update the half-move clock and en passant square.
  4. Remove captured pieces.
  5. Move the piece to its destination square.
  6. Handle promotions, en passant captures, and castling.
  7. Update castling rights.
  8. Switch the side to move.
  9. Increment the full-move counter when Black moves.
  10. Verify legality by checking whether the moving side’s king is left in check.
  11. Undo the move immediately if it proves illegal.

A key design feature is that all board modifications are performed through the incremental update functions introduced earlier. As a result, evaluation terms, phase values, and Zobrist keys remain synchronized automatically throughout the move-making process. Below is Rustic’s make() function:

#![allow(unused)]
fn main() {
pub fn make(&mut self, m: Move, mg: &MoveGenerator) -> bool {
    // Create the unmake info and store it.
    let mut current_game_state = self.game_state;
    current_game_state.next_move = m;
    self.history.push(current_game_state);

    // Set "us" and "opponent"
    let us = self.us();
    let opponent = self.opponent();

    // Dissect the move so we don't need "m.function()" and type casts everywhere.
    let piece = m.piece();
    let from = m.from();
    let to = m.to();
    let captured = m.captured();
    let promoted = m.promoted();
    let castling = m.castling();
    let double_step = m.double_step();
    let en_passant = m.en_passant();

    // Shorthands
    let is_promotion = promoted != Piece::None;
    let is_capture = captured != Piece::None;
    let has_permissions = self.game_state.castling > Bitboard::empty();

    // Assume this is not a pawn move or a capture.
    self.game_state.half_move_clock += 1;

    // Every move except double_step unsets the up-square.
    if self.game_state.en_passant.is_some() {
        self.clear_ep_square();
    }

    // If a piece was captured with this move then remove it. Also reset half_move_clock.
    if is_capture {
        self.remove_piece(opponent, captured, to);
        self.game_state.half_move_clock = 0;
        // Change castling permissions on rook capture in the corner.
        if captured == Piece::Rook && has_permissions {
            self.update_castling_permissions(
                self.game_state.castling & Bitboard::new(CASTLING_PERMS[to]),
            );
        }
    }

    // Make the move. Just move the piece if it's not a pawn.
    if piece != Piece::Pawn {
        self.move_piece(us, piece, from, to);
    } else {
        // It's a pawn move. Take promotion into account and reset half_move_clock.
        self.remove_piece(us, piece, from);
        self.put_piece(us, if !is_promotion { piece } else { promoted }, to);
        self.game_state.half_move_clock = 0;

        // After an en-passant maneuver, the opponent's pawn must also be removed.
        if en_passant {
            self.remove_piece(opponent, Piece::Pawn, Square::from(to as u8 ^ 8));
        }

        // A double-step is the only move that sets the ep-square.
        if double_step {
            self.set_ep_square(Square::from(to as u8 ^ 8));
        }
    }

    // Remove castling permissions if king/rook leaves from starting square.
    // (This will also adjust permissions when castling, because the king moves.)
    if (piece == Piece::King || piece == Piece::Rook) && has_permissions {
        self.update_castling_permissions(
            self.game_state.castling & Bitboard::new(CASTLING_PERMS[from]),
        );
    }

    // If the king is castling, then also move the rook.
    if castling {
        match to {
            Square::G1 => self.move_piece(us, Piece::Rook, Square::H1, Square::F1),
            Square::C1 => self.move_piece(us, Piece::Rook, Square::A1, Square::D1),
            Square::G8 => self.move_piece(us, Piece::Rook, Square::H8, Square::F8),
            Square::C8 => self.move_piece(us, Piece::Rook, Square::A8, Square::D8),
            _ => panic!("Error moving rook during castling."),
        }
    }

    // Swap the side to move.
    self.swap_side();

    // Increase full move number if black has moved
    if us == Side::Black {
        self.game_state.fullmove_number += 1;
    }

    /*** Validating move: see if "us" is in check. If so, undo everything. ***/
    let is_legal = !mg.square_attacked(self, opponent, self.king_square(us));
    if !is_legal {
        self.unmake();
    }

    // When running in debug mode, check the incrementally updated
    // values such as Zobrist key and material count.
    debug_assert!(check_incrementals(self));

    // Report if the move was legal or not.
    is_legal
}
}

unmake()

The unmake() function restores the board to its previous state.

Unlike make(), it does not need to recompute evaluation data, Zobrist keys, castling rights, or other incrementally maintained information. Those values are recovered instantly by restoring the previously saved game state from the history stack.

Once the game state has been restored, the function only needs to reverse the physical piece movement on the board. It moves pieces back to their original squares, restores captured pieces, undoes promotions, restores castling rook moves, and recreates en passant captures when necessary. This approach makes unmaking moves extremely efficient. This is the implementation of Rustic’s unmake() function:

#![allow(unused)]
fn main() {
pub fn unmake(&mut self) {
    // Restore the previous game state from the game state history. If
    // there is none (because we're at the starting position), we can
    // immediately return without unmaking a move.
    if let Some(gs) = self.history.pop() {
        self.game_state = gs;
    } else {
        return;
    }

    // Set the shorthand variables.
    let us = self.us();
    let opponent = self.opponent();
    let m = self.game_state.next_move;
    let piece = m.piece();
    let from = m.from();
    let to = m.to();
    let captured = m.captured();
    let promoted = m.promoted();
    let castling = m.castling();
    let en_passant = m.en_passant();

    // Moving backwards...
    if promoted == Piece::None {
        reverse_move(self, us, piece, to, from);
    } else {
        remove_piece(self, us, promoted, to);
        put_piece(self, us, Piece::Pawn, from);
    }

    // The king's move was already undone as a normal move.
    // Now undo the correct castling rook move.
    if castling {
        match to {
            Square::G1 => reverse_move(self, us, Piece::Rook, Square::F1, Square::H1),
            Square::C1 => reverse_move(self, us, Piece::Rook, Square::D1, Square::A1),
            Square::G8 => reverse_move(self, us, Piece::Rook, Square::F8, Square::H8),
            Square::C8 => reverse_move(self, us, Piece::Rook, Square::D8, Square::A8),
            _ => panic!("Error: Reversing castling rook move."),
        };
    }

    // If a piece was captured, put it back onto the to-square
    if captured != Piece::None {
        put_piece(self, opponent, captured, to);
    }

    // If this was an e-passant move, put the opponent's pawn back
    if en_passant {
        put_piece(self, opponent, Piece::Pawn, Square::from(to as u8 ^ 8));
    }
}
}

Why we have separate move-reversal functions

At first glance, it may seem unnecessary to have separate put_piece(), remove_piece(), and reverse_move() functions dedicated to move reversal. After all, the board already contains functions with the same names.

The reason lies in the engine’s incremental update system.

The board-level versions of put_piece() and remove_piece() update much more than the piece placement itself. They also update:

  • Zobrist hash keys
  • Game phase values
  • Material and PSQT information
  • … and other incremental data

When making a move, these updates are required because the position is changing.

When unmaking a move, however, all of this information has already been restored from the history stack at the start of unmake(). Reversing the incremental updates manually would therefore perform unnecessary work and could even corrupt the restored values.

For this reason, the move module contains lightweight helper functions that only retract the last piece move. They update bitboards and piece lists without touching any incrementally maintained data.

remove_piece()

Removes a piece from the board without touching anything else.

#![allow(unused)]
fn main() {
fn remove_piece(board: &mut Board, side: Side, piece: Piece, square: Square) {
    board.bb_pieces[side][piece] ^= BB_SQUARES[square.idx()];
    board.bb_side[side] ^= BB_SQUARES[square.idx()];
    board.piece_list[square] = Piece::None;
}
}

put_piece()

Puts a piece onto the board without touching anything else.

#![allow(unused)]
fn main() {
fn put_piece(board: &mut Board, side: Side, piece: Piece, square: Square) {
    board.bb_pieces[side][piece] |= BB_SQUARES[square.idx()];
    board.bb_side[side] |= BB_SQUARES[square.idx()];
    board.piece_list[square] = piece;
}
}

reverse_move()

This function uses the two previous functions to reverse a move, again without touhcing any of the other data in the board or gamestate.

#![allow(unused)]
fn main() {
fn reverse_move(board: &mut Board, side: Side, piece: Piece, remove: Square, put: Square) {
    remove_piece(board, side, piece, remove);
    put_piece(board, side, piece, put);
}
}

Conclusion

The make() and unmake() functions are among the most heavily used routines in the entire engine. Every search node depends on them, and their efficiency has a direct impact on overall playing strength.

The implementation presented here combines two important ideas. First, all state changes are maintained incrementally while making moves, avoiding expensive recomputation. Second, unmaking moves is simplified by restoring the complete game state from history rather than attempting to reverse every incremental update manually.

This combination provides both correctness and speed, allowing the engine to explore vast numbers of positions while keeping board updates extremely efficient.

Now there is a choice on how to continue reading. There are three more support functions:

  • Detecting the bishop pair
  • Detecting if mate can still be forced
  • Detecting insufficient material according to FIDE rules

These functions are mainly used by the evaluation and the XBoard protocol; they are not strictly necessary to get the engine to play legal chess. You can return to them later when you need them. So: