Bitboards
What Are Bitboards?
A bitboard is a 64-bit integer that represents a chess board. Each bit corresponds to one square on the board. If a bit is 1, it means something exists on that square (like a piece, or that the square is attacked). If it’s 0, the square is empty or doesn’t have that property.
For example, to represent all white pawns on the board, you use one bitboard where each bit tells you whether there’s a white pawn on that square.
Why Use Bitboards?
Performance Tip: Bitboards let you check all 64 squares in a single CPU instruction!
Speed. Bitboards let you operate on all 64 squares simultaneously using a single CPU instruction. Instead of looping through squares one by one, you can:
- Find all pieces of a type in one operation
- Calculate all attacked squares using bitwise operations
- Check if squares are empty or occupied instantly
Elo Impact: ~100-200 Elo compared to array-based representations. The speed gain allows you to search deeper in the same amount of time.
How They Work
Basic Structure
A chess board (a1 = bit 0, h8 = bit 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
Key Operations
Setting a bit (place a piece):
bitboard ||| (1L <<< square_index)
Clearing a bit (remove a piece):
bitboard &&& ~~~(1L <<< square_index)
Testing a bit (is there a piece?):
(bitboard &&& (1L <<< square_index)) <> 0L
Finding pieces (iterate set bits):
(* Find the least significant bit *)
let lsb = bitboard &&& (-bitboard)
Shifts for Move Generation
Bitboards shine when generating moves. For example, to find all squares white pawns can move to:
(* Single push: shift north by 8 *)
let single_push = (white_pawns <<< 8) &&& empty_squares
(* Double push: shift north by 16, only from rank 2 *)
let double_push =
((white_pawns &&& rank_2) <<< 16) &&& empty_squares &&& (empty_squares <<< 8)
Attacks are similar:
(* Pawn attacks northeast: shift by 9, but not from h-file *)
let attacks_ne = (white_pawns <<< 9) &&& not_file_a
(* Pawn attacks northwest: shift by 7, but not from a-file *)
let attacks_nw = (white_pawns <<< 7) &&& not_file_h
Implementation Tips
1. Create Constant Bitboards
Pre-compute commonly used patterns:
let rank_1 = 0x00000000000000FFL
let rank_8 = 0xFF00000000000000L
let file_a = 0x0101010101010101L
let file_h = 0x8080808080808080L
2. Use Multiple Bitboards
A complete position needs ~12 bitboards:
- 6 for white pieces (pawn, knight, bishop, rook, queen, king)
- 6 for black pieces
- Often helpful: composite bitboards for “all white pieces”, “all pieces”, “empty squares”
3. Population Count
Modern CPUs have a popcnt instruction to count set bits instantly:
(* In OCaml, often exposed via C bindings *)
external popcount : int64 -> int = "popcount_stub"
4. Bit Scanning
Find the index of the first/last set bit:
(* Find least significant bit index *)
external ctz : int64 -> int = "count_trailing_zeros"
(* Find most significant bit index *)
external clz : int64 -> int = "count_leading_zeros"
Common Patterns
Intersection (AND)
(* Find white pawns that can capture black pieces *)
let capturable = white_pawn_attacks &&& black_pieces
Union (OR)
(* All pieces *)
let occupied = white_pieces ||| black_pieces
Complement (NOT)
(* Empty squares *)
let empty = ~~~occupied
Subtraction (AND NOT)
(* White pieces that aren't pawns *)
let white_non_pawns = white_pieces &&& ~~~white_pawns
Real-World Example: Knight Moves
Knights are perfect for bitboards because their moves follow a fixed pattern:
(* Pre-computed knight attack table *)
let knight_attacks = Array.init 64 (fun sq ->
let bb = 1L <<< sq in
let l1 = (bb >>> 1) &&& not_file_h in
let l2 = (bb >>> 2) &&& (not_file_h &&& (not_file_h <<< 1)) in
let r1 = (bb <<< 1) &&& not_file_a in
let r2 = (bb <<< 2) &&& (not_file_a &&& (not_file_a >>> 1)) in
let h1 = l1 ||| r1 in
let h2 = l2 ||| r2 in
(h1 <<< 16) ||| (h1 >>> 16) ||| (h2 <<< 8) ||| (h2 >>> 8)
)
(* Generate knight moves *)
let generate_knight_moves pos knight_square =
let attacks = knight_attacks.(knight_square) in
let targets = attacks &&& (~~~friendly_pieces) in
(* targets now contains all legal knight destinations *)
targets
Performance Benefits
- Parallel operations: Check 64 squares in one instruction
- Cache-friendly: Small memory footprint (12-16 bytes for piece positions vs 64+ bytes for arrays)
- Branch-free: Many operations avoid conditionals, helping CPU pipelining
- Hardware support: Modern CPUs have specialized instructions (popcnt, bsf, bsr) for bit operations
Further Reading
- Chess Programming Wiki - Bitboards
- Magic Bitboards - Advanced technique for sliding pieces
lib/core/bitboard.ml- See the implementation in action