Non-slider Initialization (King)
Attack bitboards
The king is the easiest piece to start with because its attacks are entirely independent of board occupancy. No matter what pieces are on the board, a king always attacks the same surrounding squares. For example, here is the attack bitboard for a king on E4:
8 0 0 0 0 0 0 0 0
7 0 0 0 0 0 0 0 0
6 0 0 0 0 0 0 0 0
5 0 0 0 1 1 1 0 0
4 0 0 0 1 · 1 0 0
3 0 0 0 1 1 1 0 0
2 0 0 0 0 0 0 0 0
1 0 0 0 0 0 0 0 0
A B C D E F G H
These are all squares the king can move to on an otherwise empty board. In a bitboard engine, each square corresponds to a single bit in a 64-bit integer. Moving a piece therefore becomes equivalent to shifting bits left or right.
The move generator stores a precomputed attack bitboard for every square:
pub fn new() -> Self {
let mut mg = Self {
king_attacks: EverySquare::new(Bitboard::empty()),
// ...
};
// ...
}
This means:
king_attacks[A1]contains all king moves from A1,king_attacks[E4]contains all king moves from E4,- and so on for every square.
The attack tables are initialized once when the engine starts:
pub(super) fn init_king(king_attacks: &mut EverySquare<Bitboard>) {
for sq in Square::range_by_number() {
// create attack bitboard for this square
}
}
The basic idea is simple:
- Start with a bitboard containing only the king square.
- For every possible king direction:
- remove impossible moves,
- shift the remaining bit,
- add the result to the attack bitboard.
- Store the finished attack bitboard in the lookup table.
Here is the full implementation:
pub(super) fn init_king(king_attacks: &mut EverySquare<Bitboard>) {
for sq in Square::range_by_number() {
let bb_from = BB_SQUARES[sq];
let mut bb_moves = Bitboard::empty();
for this_move in KING_MOVES.iter() {
let can_move = bb_from & !this_move.impossible;
let shift = this_move.offset.unsigned_abs() as u64;
bb_moves |= if this_move.offset > 0 {
can_move << shift
} else {
can_move >> shift
};
}
king_attacks[sq.into()] = bb_moves;
}
}
Let’s break this down step by step.
Square Offsets
The engine numbers squares from 0 to 63:
8 56 57 58 59 60 61 62 63
7 48 49 50 51 52 53 54 55
6 40 41 42 43 44 45 46 47
5 32 33 34 35 36 37 38 39
4 24 25 26 27 28 29 30 31
3 16 17 18 19 20 21 22 23
2 8 9 10 11 12 13 14 15
1 0 1 2 3 4 5 6 7
A B C D E F G H
A king on E4 is therefore on square 28.
If the king moves one square north, it lands on E5, which is square 36:
36 - 28 = +8
So moving north corresponds to shifting the bitboard 8 bits left.
Likewise:
- south = -8
- east = +1
- west = -1
Chess engines often use compass directions for these offsets, so Rustic implements them as such as well.
pub(super) struct KingOffset;
impl KingOffset {
pub const NORTH: i8 = 8;
pub const SOUTH: i8 = -8;
pub const EAST: i8 = 1;
pub const WEST: i8 = -1;
pub const NORTH_WEST: i8 = 7;
pub const SOUTH_EAST: i8 = -7;
pub const NORTH_EAST: i8 = 9;
pub const SOUTH_WEST: i8 = -9;
}
Diagonal moves are just combinations of horizontal and vertical movement:
- north-east = +9
- north-west = +7
- south-east = -7
- south-west = -9
Impossible Moves
Not every move is valid from every square.
For example:
- a king on the 8th rank cannot move north,
- a king on the A-file cannot move west,
- a king on H8 cannot move north, nor anywhere east.
These restrictions are encoded with impossible masks.
Each king move stores:
- the offset,
- and a bitboard describing where that move is impossible.
pub(super) struct KingMove {
pub offset: i8,
pub impossible: Bitboard,
}
All king directions are collected into a table:
pub(super) const KING_MOVES: [KingMove; 8] = [
KingMove {
offset: KingOffset::NORTH,
impossible: BB_RANKS[Rank::R8.idx()],
},
KingMove {
offset: KingOffset::SOUTH,
impossible: BB_RANKS[Rank::R1.idx()],
},
KingMove {
offset: KingOffset::EAST,
impossible: BB_FILES[File::H.idx()],
},
KingMove {
offset: KingOffset::WEST,
impossible: BB_FILES[File::A.idx()],
},
KingMove {
offset: KingOffset::NORTH_WEST,
impossible: BB_FILES[File::A.idx()].bitor(BB_RANKS[Rank::R8.idx()]),
},
KingMove {
offset: KingOffset::SOUTH_EAST,
impossible: BB_FILES[File::H.idx()].bitor(BB_RANKS[Rank::R1.idx()]),
},
KingMove {
offset: KingOffset::NORTH_EAST,
impossible: BB_FILES[File::H.idx()].bitor(BB_RANKS[Rank::R8.idx()]),
},
KingMove {
offset: KingOffset::SOUTH_WEST,
impossible: BB_FILES[File::A.idx()].bitor(BB_RANKS[Rank::R1.idx()]),
},
];
For example, moving north is impossible from any square on the 8th rank:
KingMove {
offset: KingOffset::NORTH,
impossible: BB_RANKS[Rank::R8.idx()],
},
That is why the initialization loop does this:
let can_move = bb_from & !this_move.impossible;
The expression removes positions where the move is impossible.
Example
Suppose the king is on E8.
The impossible mask for moving north is the entire 8th rank:
rank 8 : 1111 1111
king E8 : 0000 1000
We negate the impossible mask, to get “not impossible”:
!rank 8 : 0000 0000
Then apply bitwise AND:
!rank 8 : 0000 0000
&
king E8 : 0000 1000
result : 0000 0000
The result is zero, so the move is discarded.
Now suppose the king is on E7 instead:
!rank 8 : 1111 1111 // <-- note the !rank8, which is "true" here
&
king E7 : 0000 1000
result : 0000 1000
Now the result is non-zero, so the move is allowed.
Shifting the Move
Once we know the move is possible, we shift the bitboard according to the move offset:
bb_moves |= if this_move.offset > 0 {
can_move << shift
} else {
can_move >> shift
};
For example:
- north shifts left by 8 bits,
- south shifts right by 8 bits,
- east shifts left by 1 bit,
- and so on.
Each shifted result is OR’ed into “bb_moves”, gradually building the full attack bitboard for the square.
When all moves have been processed, the result is stored:
king_attacks[sq.into()] = bb_moves;
The engine repeats this for all 64 squares while booting up.
Final Result
After initialization, move generation for the king becomes extremely fast:
let king_moves_on_e4 = king_attacks[Square::E4];
No moves need to be generated at runtime. The engine simply performs a table lookup and instantly obtains the king attack bitboard. Later, when we discuss move generation on occupied boards, we will combine these precomputed attacks with board occupancy to obtain pseudo-legal moves.
Non-slider Initialization (Knight)
Same concept as the king
Knight move generation works exactly the same way as king move generation.
Just like the king:
- every knight move is represented by an offset,
- every move has an associated impossible mask,
- and initialization is done by shifting bits and masking impossible moves.
The only difference is the movement pattern.
A knight does not move one square in a direction. Instead, it jumps in an L-shape:
- two squares vertically and one horizontally,
- or two squares horizontally and one vertically.
These jumps are encoded as offsets and an impossible mask, just as with the king moves.
#[derive(Copy, Clone)]
pub(super) struct KnightMove {
pub offset: i8,
pub impossible: Bitboard,
}
pub(super) struct KnightJump;
impl KnightJump {
pub const TWO_UP_ONE_LEFT: i8 = 15;
pub const TWO_UP_ONE_RIGHT: i8 = 17;
pub const TWO_RIGHT_ONE_UP: i8 = 10;
pub const TWO_RIGHT_ONE_DOWN: i8 = -6;
pub const TWO_DOWN_ONE_LEFT: i8 = -17;
pub const TWO_DOWN_ONE_RIGHT: i8 = -15;
pub const TWO_LEFT_ONE_UP: i8 = 6;
pub const TWO_LEFT_ONE_DOWN: i8 = -10;
}
For example:
- moving two squares north and one west corresponds to an offset of
+15, - moving two squares south and one east corresponds to
-15, - and so on.
Just like with the king, some moves become impossible near the edges of the board.
For example:
- a knight on the 8th or 7th rank cannot jump further upward,
- a knight on the A-file cannot jump further left,
- and a knight on A8 has several impossible jumps.
Offset and impossible moves encoding
These restrictions are encoded in the knight move table:
pub(super) const KNIGHT_MOVES: [KnightMove; 8] = [
KnightMove {
offset: KnightJump::TWO_UP_ONE_LEFT,
impossible: BB_RANKS[Rank::R8.idx()]
.bitor(BB_RANKS[Rank::R7.idx()])
.bitor(BB_FILES[File::A.idx()]),
},
KnightMove {
offset: KnightJump::TWO_UP_ONE_RIGHT,
impossible: BB_RANKS[Rank::R8.idx()]
.bitor(BB_RANKS[Rank::R7.idx()])
.bitor(BB_FILES[File::H.idx()]),
},
KnightMove {
offset: KnightJump::TWO_RIGHT_ONE_UP,
impossible: BB_FILES[File::G.idx()]
.bitor(BB_FILES[File::H.idx()])
.bitor(BB_RANKS[Rank::R8.idx()]),
},
KnightMove {
offset: KnightJump::TWO_RIGHT_ONE_DOWN,
impossible: BB_FILES[File::G.idx()]
.bitor(BB_FILES[File::H.idx()])
.bitor(BB_RANKS[Rank::R1.idx()]),
},
KnightMove {
offset: KnightJump::TWO_DOWN_ONE_LEFT,
impossible: BB_RANKS[Rank::R1.idx()]
.bitor(BB_RANKS[Rank::R2.idx()])
.bitor(BB_FILES[File::A.idx()]),
},
KnightMove {
offset: KnightJump::TWO_DOWN_ONE_RIGHT,
impossible: BB_RANKS[Rank::R1.idx()]
.bitor(BB_RANKS[Rank::R2.idx()])
.bitor(BB_FILES[File::H.idx()]),
},
KnightMove {
offset: KnightJump::TWO_LEFT_ONE_UP,
impossible: BB_FILES[File::A.idx()]
.bitor(BB_FILES[File::B.idx()])
.bitor(BB_RANKS[Rank::R8.idx()]),
},
KnightMove {
offset: KnightJump::TWO_LEFT_ONE_DOWN,
impossible: BB_FILES[File::A.idx()]
.bitor(BB_FILES[File::B.idx()])
.bitor(BB_RANKS[Rank::R1.idx()]),
},
];
Initialization
The initialization function itself is almost identical to the king version.
pub(super) fn init_knight(knight_attacks: &mut EverySquare<Bitboard>) {
for sq in Square::range_by_number() {
let bb_from = BB_SQUARES[sq];
let mut bb_moves = Bitboard::empty();
for this_move in KNIGHT_MOVES.iter() {
let can_move = bb_from & !this_move.impossible;
let shift = this_move.offset.unsigned_abs() as u64;
bb_moves |= if this_move.offset > 0 {
can_move << shift
} else {
can_move >> shift
};
}
knight_attacks[sq.into()] = bb_moves;
}
}
So once the king initialization is understood, the knight implementation should feel very familiar.