June 14, 2024
Generating pseudo-legal and legal moves, along with making/unmaking them
Now that we have a way to represent the Board
, let's examine how we can generate, make, and unmake moves to change its state.
A great benefit of using bitboards to represent positions is how efficiently we can generate moves with them.
If we have a bitboard with a single 1
bit in it, a move from that square can be represented by just one bitwise shift instruction. We can pre-determine how much to shift by determining the offset that each cardinal direction is. For example, examine the offsets each other square is from an initial square of d5
:
-27
-26
-25
-24
-23
-22
-21
-20
-19
-18
-17
-16
-15
-14
-13
-12
-11
-10
-9
-8
-7
-6
-5
-4
-3
-2
-1
0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
A move in each cardinal direction is an offset of +/- 1
or +/- 8
. For convenience, lets store these four directions as constants in a Direction
impl:
impl Direction {
pub const N: isize = -8;
pub const E: isize = 1;
pub const S: isize = 8;
pub const W: isize = -1;
}
A more complicated move can be calculated by adding up any sequence of cardinal directions. Say we want to move both down and to the right, we can just shift by the result of Direction::S + Direction::E
.
impl Direction {
// ... previous fields ...
pub const NE: isize = Direction::N + Direction::E;
pub const NW: isize = Direction::N + Direction::W;
pub const SE: isize = Direction::S + Direction::E;
pub const SW: isize = Direction::S + Direction::W;
}
Finally, we have to setup a custom shift instruction for our Bitboard
, as Rust doesn't allow bitwise shifting of negative numbers. To handle this, we can conditionally shift left or right based on the sign of the number:
impl Shr<isize> Bitboard {
fn shr(self, dir: isize) -> Bitboard {
if dir >= 0 {
// weird notation because Bitboard wraps a u64
// the main idea is that we do a right shift by dir when it is positive
Bitboard(self.0 >> dir)
} else {
// and we do a left shift by -dir when it is negative (to make it positive)
Bitboard(self.0 << -dir)
}
}
}
Now for example, this is what a single knight move going Direction::N + Direction::E + Direction::E
might look like from that initial d5
square:
knight_board
knight_board >> (N + E + E)
First, let's consider the generation of pseudo-legal moves, which are defined as moves that follow the rules of each piece's movement patterns, but do not consider the safety of the king. We begin with generating these, as considering full legality of a move has some extra caveats which will be discussed later on.
To generate pseudo-legal moves for a position, the general process is to iterate through each Piece
of the actively moving Color
, and generate a bitboard of possible destination squares for this piece to move to. The process for generating moves varies per piece type, so we can roughly split each piece into 3 categories.
The simplest pieces to generate moves for are the jumping pieces, which have a constant set of moves that can either be made or are blocked. For both the King
and Knight
, we can just take a bitwise OR
of all resulting bitboards that are shifted from the piece's initial square.
Sticking with d5
as our initial square, the king and knight bitboards would look as follows:
king_moves
knight_moves
Care must be taken when moving near the edges of the board. Because our bitboard is essentially just a flat array of bits, a piece can shift off of one side of the board and appear on the other. We only really need to worry about horizontal overflow, because overflow in the vertical direction would just result in the bit disappearing, which works perfectly for us.
To handle this, we can use file masks to filter out pieces on certain files before doing a shift. For example, moves for the King
are generated as follows. Notice that whenever there is horizontal movement, a file-masked version of the position is used.
fn generate_king_moves(king_position: Bitboard) -> Bitboard {
// board to store all valid moves
let mut board = Bitboard::empty();
// generate masked position bitboards
let king_position_a_mask = king_position & FileBoundMask::A;
let king_position_h_mask = king_position & FileBoundMask::H;
// generate moves by bit shifting in each direction
board |= king_position_a_file_masked >> Direction::NW;
board |= king_position_a_file_masked >> Direction::W;
board |= king_position_a_file_masked >> Direction::SW;
board |= king_position >> Direction::N;
board |= king_position >> Direction::S;
board |= king_position_h_file_masked >> Direction::NE;
board |= king_position_h_file_masked >> Direction::E;
board |= king_position_h_file_masked >> Direction::SE;
board
}
Finally, we just have to determine which of the possible locations are actually valid squares. Obviously, from the initial position, the king can't move anywhere becasue it is surrounded by neighboring pieces.
To remedy this, we need to build another bitboard to mask out invalid squares. In our case, the only invalid squares are those of the same color, so we can bitwise NOT
that board, resulting in a bitboard that will mask out moves landing on same-color pieces.
generate_king_moves(king_position) & !board.active_pieces();
This is what the process looks like visually, with the initial Knight
on b1
:
generate_knight_moves()
!board.active_pieces()
generate_knight_moves() & !board.active_pieces()
Although the Pawn
is technically a jumping piece, it have some extra rules to consider. Let's split up pawn moves into 3 categories:
Direction::N
for White
, Direction::S
for Black
.Direction::E
or Direction::W
here, file masks are used again for each side.The final 3 piece types, Bishop
, Rook
, and Queen
are slightly more difficult to find moves for, due to the nature of their movement. They can move continuously in a direction, but upon hitting any piece, they must stop. The process for efficiently generating these moves is as follows:
OR
-ing as we go, until we eventually shift onto the other side of the board.Direction::NE
from b2
:generate_sliding_ray(b2_board, Direction::NE)
For each direction, find the first blocking piece's square
AND
the generated sliding ray with a mask containing all pieces, resulting in a blocker_board
containing all pieces that would block this ray.blocker_board
.
1
s or 0
s in the bitboard.impl Bitboard {
pub fn get_first_square(self) -> Square {
self.0.leading_zeros() as Square
}
pub fn get_last_square(self) -> Square {
(63 - self.0.trailing_zeros()) as Square
}
}
first_blocker
square.XOR
the initial ray with a ray in the same direction from the first_blocker
's square.b2
Direction::NE
ray on square e5
:b2_NE_ray
XOR
blocker_ray
=
clipped_attack
The entire process is summed up in the following function. To allow this same function to be used for both Bishop
, Rook
, and Queen
, we can just pass a slice of directions to be used, depending on the piece we are generating moves for.
fn generate_sliding_moves(pos: Bitboard, dirs: &[isize], board: &Board) -> Bitboard {
let mut moves = Bitboard::empty();
for direction in dirs {
// generate sliding ray and find all pieces blocking it
let ray = generate_sliding_ray(pos, direction);
let blocker_board = ray & board.all_pieces();
if blocker_board.is_empty() {
// if no blockers, add the entire ray to moves
moves |= ray
} else {
// if there is a blocker, find the first one in the ray
let first_blocker = if direction > 0 {
blocker_board.get_first_square()
} else {
blocker_board.get_last_square()
};
// then clip the attack from the first blocker and add it to moves
let blocker_pos = Bitboard::shifted_by(first_blocker);
moves |= ray ^ generate_sliding_ray(blocker_pos, direction);
}
}
moves
}
As usual, we can then mask out all same-colored pieces after generating the moves per sliding piece.
Generating legal moves can be done in one of 2 ways:
Otter decides to generate legal moves in-place as opposed to the slower strategy, so there are some extra masking boards that need to be built before the moves are generated.
king_move_mask
: Masks out all squares currently being attacked
King
so that it cannot move into an attacked square.capture_mask
: Mask of all opposing pieces currently putting the king into check
block_mask
: Mask of the sliding attack rays of opposing sliding pieces currently putting the king into check
pin_mask
: Mask of the sliding attack rays of opposing sliding pieces currently pinning a friendly piece onto the king
These masks are applied throughout the move generation phase when required, so that all moves generated are valid, legal moves to be made.
The final step is converting the move bitboards into a struct containing all required data for the Board
to make the move. This is also done during move generation, and each Move
stores the following:
struct Move {
from: Square, // where the piece is moving from
to: Square, // where to piece is moving to
piece: Piece, // the type of piece moving
flag: MoveFlag, // designates the type of move, see below
}
enum MoveFlag {
Quiet, // regular move that doesn't have any flags
Capture(Piece), // opponent piece that was captured
Promotion(Piece), // pawn was promoted into a piece
CapturePromotion(Piece, Piece), // opponent piece that was captured and the piece promoted into
PawnDoubleMove(Square), // a pawn double moved, stores the en passant target square
EnPassantCapture(Square), // holds the square of the captured (just en passant-ed) pawn
KingCastle, // kingside castle
QueenCastle, // queenside castle
}
Finally the Board
must be able to make and unmake Move
s. Obviously we need to be able to make moves, and unmaking moves is essential later on once we begin searching and evaluating move trees.
Making moves is relatively straightforward, but we need to store a history of previous GameState
s and Move
s to be able to unmake moves. Unmaking a move is essentially reverting any changes from the previously made move and restoring the previous game state.
struct Board {
// ... previous fields ...
history: Vec<(Move, GameState)>,
}
impl Board {
pub fn make_move(&mut self, m: Move) {
// push this move and current history for unmaking later on
self.history.push((m, self.game_state));
// finish making move and update game state
}
pub fn unmake_move(&mut self) {
// pop previous move from history
let (m, prev_state) = match self.move_history.pop() {
Some(history) => history,
None => return, // if no history, return early
};
// finish unmaking move
// and restore previous game state
self.game_state = prev_state;
}
}