Building a Chess Engine and AI Bot
Chess has served as a benchmark problem for artificial intelligence since the very birth of the field. Alan Turing described a paper chess-playing algorithm in 1950, and Claude Shannon published the foundational framework for computer chess the same year. Today, engines like Stockfish and Leela Chess Zero play at levels far beyond any human grandmaster, but the algorithmic ideas they are built upon, such as board representation, legal move generation, alpha-beta search, and positional evaluation, are surprisingly accessible. Building a chess engine from scratch is one of the most complete exercises in applied computer science: it demands careful data structure design, recursive search, hashing, and incremental state management all in a single coherent program.
In this chapter we implement a fully playable chess engine and AI bot in Gerbil Scheme that consists of three modules totaling roughly 900 lines of code. The engine correctly handles every rule of chess, including en passant, castling, pawn promotion, and the fifty-move draw rule. The AI opponent uses iterative deepening negamax search with alpha-beta pruning, a transposition table for caching previously evaluated positions, a quiescence search to avoid the horizon effect on captures, and piece-square tables that encode positional knowledge. The interactive command-line interface renders a coloured Unicode board in the terminal and accepts moves in Universal Chess Interface (UCI) notation.
Theoretical Background
Board Representation
The first design decision in any chess engine is how to represent the board in memory. The two dominant approaches are bitboards (one 64-bit integer per piece type, with bits corresponding to occupied squares) and mailbox representation (an array mapping square index to piece). Bitboards enable parallelism via bitwise operations and are used in professional engines, but they make the code significantly harder to read. For this chapter we use an 8×8 mailbox encoded as a flat 64-element vector. Each element holds a small integer encoding both the piece type (bits 0–2) and the piece color (bits 3–4) using bitwise OR:
1 piece = color | type
The type codes are 0 (empty) through 6 (king). White is encoded as 8 and black as 16. To extract the type from a piece value p we compute p AND 7; to extract the color we compute p AND 24. This simple two-field encoding eliminates branch-heavy conditionals and maps naturally to Gerbil’s bitwise-and, bitwise-ior, and bitwise-xor primitives.
In addition to the board grid, the engine maintains an active piece list consisting of two lists of occupied square indices, one per color. This means that when generating moves for white, the engine iterates only over white’s active squares rather than all 64 squares, a constant-factor speedup that matters at high search depths.
Precomputed Move Tables
A naive move generator re-derives the set of reachable squares for each piece on every call. Precomputed tables eliminate that work by computing, at startup, every possible knight target, king target, and sliding-piece ray from each of the 64 squares. A ray is an ordered list of squares in one direction from a given square, stopping at the board edge. During move generation the engine walks each ray until it hits a friendly piece (stop) or an enemy piece (stop after capture). Because the tables are fixed arrays, this lookup is O(1) per ray and allocates no new memory.
Zobrist Hashing
Searching a chess position requires quickly comparing positions to detect repetitions and to look up previously evaluated results in a transposition table. A naive equality check over all 64 squares is O(64); Zobrist hashing reduces this to O(1) by maintaining a 64-bit integer that uniquely identifies (with overwhelmingly high probability) the current board state.
The Zobrist scheme assigns a random 64-bit number to each (square, piece) combination at startup that consists of 64 squares times 32 possible piece encodings gives 2048 numbers. The board hash is the XOR of all the numbers corresponding to occupied squares. Critically, XOR is self-inverse: making a move that removes a piece from square s simply XORs out zobrist[s][piece], and placing the new piece XORs in the new value. Additional random numbers encode whose turn it is, the four castling rights, and the en-passant file. This incremental update strategy keeps the hash cost per move to a handful of XOR operations.
Negamax Search with Alpha-Beta Pruning
A chess engine evaluates a position by searching forward through possible move sequences. The minimax algorithm assigns a score to the root position by assuming both players play optimally: the side to move maximizes the score, while the opponent minimizes it. Negamax is a clean reformulation of minimax that exploits the zero-sum nature of chess: the score for the side to move equals the negation of the score for the opponent. Every recursive call always maximizes, and scores are negated at each level.
Searching the full game tree is computationally infeasible. Alpha-beta pruning cuts large portions of the tree without affecting the result. Two bounds are passed through the recursion:
alpha: the best score the maximizer can guarantee so far.beta: the best score the minimizer can guarantee so far.
Whenever alpha >= beta, the current subtree cannot influence the final result, and search is abandoned. In the best case, with perfect move ordering, alpha-beta reduces the effective branching factor from b to sqrt(b), roughly doubling the searchable depth within the same time budget.
Iterative deepening runs alpha-beta repeatedly at depths 1, 2, 3, … up to the target depth. At each depth, the best move found is stored in the transposition table and used to order moves at the next depth. Because better-ordered moves produce more cutoffs, iterative deepening with transposition table hints typically outperforms a single deep search in the same time window.
Quiescence search is a fix for the “horizon effect”: at the maximum depth, a position may look quiet but have a hanging piece about to be captured. The quiescence extension continues the search past the nominal depth, but only for captures and promotions. It terminates when the board is “quiet” (no forcing tactics available), making the static evaluation far more reliable.
Static Evaluation
When search reaches a leaf node, it calls a static evaluation function that returns a score in centipawns (one hundredth of a pawn value). The evaluation in this engine has two components:
- Material count: the sum of piece values: pawn=100, knight=320, bishop=330, rook=500, queen=900, king=20000 (effectively infinite).
- Piece-square table (PST) bonuses: each piece type has an 8×8 table of bonus/penalty values expressing positional preferences. Knights are penalized on the rim and rewarded in the center. Pawns are rewarded for advancement toward the seventh rank. Kings are penalized for exposure in the middlegame but rewarded for active central play in the endgame.
The engine detects the endgame by summing non-pawn, non-king material; when it falls below 3000 centipawns, the king PST switches to an endgame table that favors central activity.
Project Structure
The project directory source_code/chess-game contains the following files:
| File | Description |
|---|---|
engine.ss |
Board representation, precomputed move tables, Zobrist hashing, FEN parsing, pseudo-legal and legal move generation, and the make/unmake move machinery. |
ai.ss |
Static evaluation with PSTs, move ordering heuristics, iterative deepening negamax with alpha-beta pruning, quiescence search, and the transposition table. |
cli.ss |
Interactive command-line interface: Unicode board rendering with ANSI colors, UCI move parsing, and the main game loop. |
perft.ss |
Correctness and performance verification using Perft, a standard move-count benchmark for chess engines. |
Makefile |
Convenience targets for running, testing, and compiling the project. |
gerbil.pkg |
Package declaration for the Gerbil module system. |
The Engine Module: engine.ss
The engine module is the foundation of the entire project. It exports every primitive needed by the AI and CLI layers: piece constants, board structures, move structures, move generators, and the make/unmake functions.
Piece Encoding and Aliases
The module begins by aliasing Gambit’s long-form bitwise function names to shorter Lisp-style names, then defining the piece encoding constants.
1 ;; File: engine.ss
2 (import :std/format
3 :std/pregexp
4 :gerbil/gambit)
5
6 (def logand bitwise-and)
7 (def logior bitwise-ior)
8 (def logxor bitwise-xor)
9 (def lognot bitwise-not)
10
11 ;; Pieces representation
12 (def EMPTY 0)
13 (def PAWN 1)
14 (def KNIGHT 2)
15 (def BISHOP 3)
16 (def ROOK 4)
17 (def QUEEN 5)
18 (def KING 6)
19 (def TYPE_MASK 7)
20
21 (def WHITE 8)
22 (def BLACK 16)
23 (def COLOR_MASK 24)
24
25 (def (piece-type p) (logand p TYPE_MASK))
26 (def (piece-color p) (logand p COLOR_MASK))
27 (def (opponent c) (if (= c WHITE) BLACK WHITE))
28 (def (color-idx c) (if (= c WHITE) 0 1))
29
30 ;; Castling rights (stored as a bitmask)
31 (def WK 1) ; White kingside
32 (def WQ 2) ; White queenside
33 (def BK 4) ; Black kingside
34 (def BQ 8) ; Black queenside
The piece-type and piece-color helpers simply mask out the relevant bits of a combined piece integer. For example, a white queen is encoded as logior WHITE QUEEN = logior 8 5 = 13. Calling piece-type 13 returns logand 13 7 = 5 (QUEEN), and piece-color 13 returns logand 13 24 = 8 (WHITE). Castling rights are stored as a four-bit mask: bit 0 = white kingside, bit 1 = white queenside, bit 2 = black kingside, bit 3 = black queenside.
Square Names and Precomputed Tables
1 ;; Precomputed square names mapping 0-63 to a1..h8
2 (def square-names (make-vector 64))
3 (def name-to-square (make-hash-table))
4
5 (do ((row 0 (+ row 1)))
6 ((= row 8))
7 (do ((col 0 (+ col 1)))
8 ((= col 8))
9 (let* ((name (string-append (string (vector-ref '#(#\a #\b #\c #\d #\e #\f #\g #\h) col))
10 (string (vector-ref '#(#\1 #\2 #\3 #\4 #\5 #\6 #\7 #\8) row))))
11 (sq (+ (* row 8) col)))
12 (vector-set! square-names sq name)
13 (hash-put! name-to-square name sq))))
14
15 ;; Precomputed move tables
16 (def knight-moves (make-vector 64 '()))
17 (def king-moves (make-vector 64 '()))
18 (def rook-rays (make-vector 64 '()))
19 (def bishop-rays (make-vector 64 '()))
20 (def queen-rays (make-vector 64 '()))
The board uses a linear index where square sq = rank * 8 + file, with rank 0 being the first rank (White’s back rank) and file 0 being the a file. The square-names vector maps 0–63 to UCI strings like "a1", and name-to-square is the reverse hash table.
The precomputed tables are filled by a startup loop. For knights and kings the loop generates the short fixed offsets. For sliding pieces (rook, bishop, queen), it generates rays that are ordered lists of squares in each direction, stopping at the board boundary:
1 (do ((sq 0 (+ sq 1)))
2 ((= sq 64))
3 (let* ((r (quotient sq 8))
4 (f (modulo sq 8)))
5 ;; Knight targets
6 (let ((km '()))
7 (for-each
8 (lambda (d)
9 (let ((nr (+ r (car d)))
10 (nf (+ f (cadr d))))
11 (when (and (>= nr 0) (< nr 8) (>= nf 0) (< nf 8))
12 (set! km (cons (+ (* nr 8) nf) km)))))
13 '((-2 -1) (-2 1) (-1 -2) (-1 2) (1 -2) (1 2) (2 -1) (2 1)))
14 (vector-set! knight-moves sq (reverse km)))
15
16 ;; Rook rays (north, south, east, west)
17 (let ((rr '()))
18 (for-each
19 (lambda (d)
20 (let ((dr (car d)) (dc (cadr d)) (ray '()))
21 (let loop ((nr (+ r dr)) (nf (+ f dc)))
22 (if (and (>= nr 0) (< nr 8) (>= nf 0) (< nf 8))
23 (begin
24 (set! ray (cons (+ (* nr 8) nf) ray))
25 (loop (+ nr dr) (+ nf dc)))
26 (set! rr (cons (reverse ray) rr))))))
27 '((1 0) (-1 0) (0 1) (0 -1)))
28 (vector-set! rook-rays sq (reverse rr)))
29 ;; ... bishop and queen rays follow same pattern
30 ))
Each element of rook-rays at index sq is a list of four rays, each ray being a list of square indices in order from sq outward. When the move generator walks a ray and hits a piece, it stops so no squares past that piece are accessible.
Zobrist Hashing
The engine uses a deterministic 64-bit linear congruential generator (LCG) to produce the Zobrist random numbers at startup. Using a seeded RNG ensures that the numbers are identical across runs (important for debugging) without requiring a hardcoded table.
1 (def (create-rng seed)
2 (let ((state (let ((val (or seed 1337)))
3 (if (even? val) (+ val 1) val))))
4 (lambda ()
5 (set! state (modulo (+ (* state 6364136223846793005) 1442695040888963407) 18446744073709551616))
6 state)))
7
8 (def rng (create-rng 1337))
9
10 ;; One 32-element vector per square (indexed by piece encoding 0-31)
11 (def zobrist-pieces (make-vector 64))
12 (do ((sq 0 (+ sq 1)))
13 ((= sq 64))
14 (let ((v (make-vector 32)))
15 (do ((p 0 (+ p 1)))
16 ((= p 32))
17 (vector-set! v p (rng)))
18 (vector-set! zobrist-pieces sq v)))
19
20 (def ZOBRIST_SIDE (rng))
21 (def ZOBRIST_CASTLING (make-vector 16)) ; indexed by 4-bit castling mask
22 (def ZOBRIST_EP (make-vector 64)) ; indexed by en-passant square
The board-compute-zobrist-hash function builds the full hash from scratch by iterating over occupied squares. In practice this is only called once, when a position is loaded from FEN. All subsequent hash updates are incremental: the board-make-move! function XORs out moved/captured pieces and XORs in their new values before updating the board state.
Board and Move Structures
Gerbil’s defstruct macro creates transparent structures with automatic accessors and setters. The move structure records every attribute needed to fully undo a move:
1 (defstruct move-struct
2 (from to piece-moved piece-captured promotion
3 is-en-passant is-castling is-double-push)
4 transparent: #t)
5
6 (def (make-move from to piece-moved
7 (piece-captured EMPTY) (promotion EMPTY)
8 (is-en-passant #f) (is-castling #f) (is-double-push #f))
9 (make-move-struct from to piece-moved piece-captured promotion
10 is-en-passant is-castling is-double-push))
The board structure holds the complete game state. A separate board-state snapshot records the fields that are difficult to recompute during undo (the Zobrist hash, castling rights, en-passant square, and half-move clock):
1 (defstruct board-state
2 (en-passant-square castling-rights halfmove-clock zobrist-hash)
3 transparent: #t)
4
5 (defstruct board
6 (grid pieces king-square turn castling-rights
7 en-passant-square halfmove-clock fullmove-number zobrist-hash)
8 transparent: #t)
FEN Parsing
The Forsyth-Edwards Notation (FEN) is the standard format for encoding chess positions as strings. A typical starting position FEN is:
1 rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1
The six space-separated fields encode: piece placement (ranks 8 to 1, files a to h), active color, castling availability, en passant target square, half-move clock, and full-move number. Uppercase letters are white pieces, lowercase are black.
The board-from-fen! function mutates an existing board in place, making it suitable for use as a reset operation. It uses pregexp-split to tokenize the FEN string, then walks the piece placement character by character:
1 (def (board-from-fen! b fen)
2 (let* ((parts (pregexp-split " " fen))
3 (placement (list-ref parts 0))
4 ...)
5 (let loop ((i 0) (rank 7) (file 0))
6 (when (< i (string-length placement))
7 (let ((ch (string-ref placement i)))
8 (cond
9 ((char=? ch #\/) (loop (+ i 1) (- rank 1) 0))
10 ((and (char>=? ch #\1) (char<=? ch #\8))
11 (loop (+ i 1) rank (+ file (- (char->integer ch) (char->integer #\0)))))
12 (else
13 (let* ((color (if (char-upper-case? ch) WHITE BLACK))
14 (type (cond
15 ((char-ci=? ch #\p) PAWN)
16 ((char-ci=? ch #\n) KNIGHT)
17 ...))
18 (piece (logior color type))
19 (sq (+ (* rank 8) file)))
20 (vector-set! grid sq piece)
21 (loop (+ i 1) rank (+ file 1))))))))))
Legal Move Generation
Move generation is a two-phase process. First, get-pseudo-legal-moves generates all moves that obey piece movement rules but may leave the king in check. Then get-legal-moves filters this list by actually making each move, checking whether the king is attacked after the move, and unmaking it.
The pseudo-legal generator iterates over the active piece list for the side to move and dispatches on piece type:
1 (def (get-pseudo-legal-moves b)
2 (let* ((moves '())
3 (color (board-turn b))
4 (opp (opponent color))
5 (forward (if (= color WHITE) 8 -8))
6 (start-rank (if (= color WHITE) 1 6))
7 (promo-rank (if (= color WHITE) 7 0))
8 (promo-pieces (list QUEEN ROOK BISHOP KNIGHT))
9 (grid (board-grid b))
10 (pieces (vector-ref (board-pieces b) (color-idx color))))
11 (for-each
12 (lambda (sq)
13 (let* ((piece (vector-ref grid sq))
14 (ptype (piece-type piece)))
15 (cond
16 ((= ptype PAWN) ...) ; single push, double push, captures, en passant, promotion
17 ((= ptype KNIGHT) ...) ; lookup in knight-moves table
18 ((= ptype KING) ...) ; lookup in king-moves table
19 (else ...)))) ; sliding pieces: walk each ray until blocked
20 pieces)
21 moves))
The pawn case is the most complex, handling both pushes and diagonal captures, each possibly resulting in four promotion moves if the destination rank is the back rank. En-passant captures store the captured pawn’s square (not the destination square) in piece-captured by looking one square behind the en-passant target.
Castling is appended after the main loop. The engine checks that the king and rook are on their original squares, the intermediate squares are empty, and none of the king’s transit squares are under attack:
1 ;; White kingside castling
2 (when (and (not (= (logand rights WK) 0))
3 (= (vector-ref grid 5) EMPTY)
4 (= (vector-ref grid 6) EMPTY)
5 (not (is-square-attacked? b 4 opp))
6 (not (is-square-attacked? b 5 opp))
7 (not (is-square-attacked? b 6 opp)))
8 (set! moves (cons (make-move 4 6 (logior WHITE KING) EMPTY EMPTY #f #t) moves)))
The is-square-attacked? function checks all attack vectors from a given square: pawn attacks (reverse-engineering from the target’s perspective), knight jumps, king proximity, and sliding rays for rooks/queens and bishops/queens.
Make and Unmake Move
The board-make-move! function returns a board-state snapshot before modifying the board. The board-unmake-move! function restores the board using that snapshot. This design avoids allocating a new board object for every node in the search tree.
1 (def (board-make-move! b move)
2 (let* ((state (make-board-state
3 (board-en-passant-square b)
4 (board-castling-rights b)
5 (board-halfmove-clock b)
6 (board-zobrist-hash b)))
7 ...)
8 ;; Incrementally update Zobrist hash
9 (set! h (logxor h ZOBRIST_SIDE))
10 (set! h (logxor h (vector-ref ZOBRIST_CASTLING (board-castling-rights b))))
11 (set! h (logxor h (vector-ref (vector-ref zobrist-pieces from) piece-moved)))
12 ;; Update grid and piece lists
13 (vector-set! grid from EMPTY)
14 (vector-set! pieces ci (remove (lambda (x) (= x from)) (vector-ref pieces ci)))
15 ;; ... handle en passant, regular captures, promotion, castling
16 state))
17
18 (def (board-unmake-move! b move state)
19 ;; Restore fields from snapshot
20 (set! (board-en-passant-square b) (board-state-en-passant-square state))
21 (set! (board-castling-rights b) (board-state-castling-rights state))
22 (set! (board-halfmove-clock b) (board-state-halfmove-clock state))
23 (set! (board-zobrist-hash b) (board-state-zobrist-hash state))
24 ;; Reverse all grid and piece-list mutations
25 ...)
The Zobrist hash is restored simply by copying it from the snapshot so there is no need to re-derive it, since it was saved before any move was applied. Castling unmake moves the rook back from its castled position; en-passant unmake restores the captured pawn to its original square.
The AI Module: ai.ss
The AI module imports the engine and adds everything needed to select a strong move: position evaluation, move ordering, and the search algorithm.
Piece-Square Tables
Each piece type has an 8×8 table of centipawn bonus/penalty values expressing positional preferences. The tables are written from White’s perspective (rank 1 at the bottom), and Black’s scores are looked up with logxor sq 56 that is a simple XOR that mirrors the square index vertically.
1 ;; File: ai.ss
2 (import "engine")
3
4 (def piece-values '#(0 100 320 330 500 900 20000))
5
6 (def PAWN_PST '#(
7 0 0 0 0 0 0 0 0 ; rank 8 (promotion rank)
8 50 50 50 50 50 50 50 50 ; rank 7
9 10 10 20 30 30 20 10 10
10 5 5 10 25 25 10 5 5
11 0 0 0 20 20 0 0 0
12 5 -5 -10 0 0 -10 -5 5
13 5 10 10 -20 -20 10 10 5
14 0 0 0 0 0 0 0 0 ; rank 1 (starting rank)
15 ))
The PAWN_PST rewards pawns that have advanced toward promotion, bonuses pawns on the d and e files in the center, and penalizes doubled pawns in front of the king. The KNIGHT_PST heavily penalizes edge squares (-50 in the corners) and rewards the central cluster. Two separate king tables, KING_MIDDLE_PST and KING_END_PST, are applied based on whether the position is an endgame (total non-pawn, non-king material below 3000 centipawns):
1 (def (is-endgame? b)
2 (let ((material 0)
3 (grid (board-grid b)))
4 (for-each
5 (lambda (sqs)
6 (for-each
7 (lambda (sq)
8 (let ((pt (piece-type (vector-ref grid sq))))
9 (when (and (not (= pt PAWN)) (not (= pt KING)))
10 (set! material (+ material (vector-ref piece-values pt))))))
11 sqs))
12 (vector->list (board-pieces b)))
13 (<= material 3000)))
The Evaluation Function
The static evaluator returns a score in centipawns from the perspective of the side to move. Positive is good for the moving side, negative is bad:
1 (def (evaluate-board b)
2 (let ((score 0)
3 (grid (board-grid b))
4 (pieces (board-pieces b)))
5 ;; White pieces: add material + PST bonus
6 (for-each
7 (lambda (sq)
8 (let ((pt (piece-type (vector-ref grid sq))))
9 (set! score (+ score (vector-ref piece-values pt)))
10 (when (not (= pt KING))
11 (set! score (+ score (vector-ref (vector-ref pst-tables pt) sq))))))
12 (vector-ref pieces 0))
13 ;; Black pieces: subtract material + PST bonus (mirrored square)
14 (for-each
15 (lambda (sq)
16 (let ((pt (piece-type (vector-ref grid sq))))
17 (set! score (- score (vector-ref piece-values pt)))
18 (when (not (= pt KING))
19 (set! score (- score (vector-ref (vector-ref pst-tables pt) (logxor sq 56)))))))
20 (vector-ref pieces 1))
21 ;; King PST (phase-dependent)
22 ...
23 (if (= (board-turn b) WHITE) score (- score))))
The final line negates the score when black is to move, converting from an absolute white-perspective score to a side-to-move-perspective score as required by negamax.
Move Ordering
Alpha-beta pruning is most effective when the best move is searched first. The move-value function assigns a priority to each move before sorting:
1 (def (move-value b move tt-move)
2 (cond
3 ;; Transposition table best move gets highest priority
4 ((and tt-move (move-equals? move tt-move)) 1000000)
5 ;; Captures ordered by MVV-LVA (Most Valuable Victim - Least Valuable Attacker)
6 ((> (move-struct-piece-captured move) 0)
7 (- (+ 10000 (vector-ref piece-values (piece-type (move-struct-piece-captured move))))
8 (quotient (vector-ref piece-values (piece-type (move-struct-piece-moved move))) 100)))
9 ;; Promotions (queening scores highest)
10 ((> (move-struct-promotion move) 0)
11 (+ 8000 (vector-ref piece-values (move-struct-promotion move))))
12 ;; Castling
13 ((move-struct-is-castling move) 1000)
14 ;; Quiet moves: score by PST delta (improvement in positional value)
15 (else
16 (let ((pt (piece-type (move-struct-piece-moved move))))
17 (if (not (= pt KING))
18 (let ((pst (vector-ref pst-tables pt)))
19 (- (vector-ref pst (move-struct-to move))
20 (vector-ref pst (move-struct-from move))))
21 0)))))
MVV-LVA (Most Valuable Victim, Least Valuable Attacker) is a classic heuristic: capturing a queen with a pawn scores much higher than capturing a pawn with a queen, both because the queen is worth more and because risking a queen to capture a pawn is strategically suspect.
Transposition Table
The transposition table is a hash table mapping Zobrist hashes to cached search results. Each entry records the search depth, the score, a flag indicating whether the score is exact or a bound, and the best move found:
1 (def TT_EXACT 0) ; Score is exact
2 (def TT_ALPHA 1) ; Score is an upper bound (failed low)
3 (def TT_BETA 2) ; Score is a lower bound (failed high)
4
5 (defstruct tt-entry (depth score flag best-move) transparent: #t)
6
7 (def transposition-table (make-hash-table))
When the table has more than 500,000 entries, it is flushed to prevent memory exhaustion between games.
Negamax Search
The core search function implements negamax with alpha-beta pruning and transposition table lookup:
1 (def (search b depth alpha beta)
2 (set! nodes-visited (+ nodes-visited 1))
3 (if (>= (board-halfmove-clock b) 100)
4 0 ; 50-move rule draw
5 (let* ((tt-entry (hash-get transposition-table (board-zobrist-hash b)))
6 (tt-best-move (and tt-entry (tt-entry-best-move tt-entry)))
7 (original-alpha alpha))
8 ;; Probe transposition table
9 (when (and tt-entry (>= (tt-entry-depth tt-entry) depth))
10 (let ((score (tt-entry-score tt-entry))
11 (flag (tt-entry-flag tt-entry)))
12 (cond
13 ((= flag TT_EXACT) (set! alpha score) (set! beta score))
14 ((= flag TT_ALPHA) (set! alpha (max alpha score)))
15 ((= flag TT_BETA) (set! beta (min beta score))))))
16
17 (if (>= alpha beta)
18 alpha ; TT cutoff
19 (let ((legal-moves (get-legal-moves b)))
20 (cond
21 ;; Terminal: checkmate or stalemate
22 ((null? legal-moves)
23 (if (in-check? b #f) (- -30000 (- max-depth depth)) 0))
24 ;; Leaf: quiescence search
25 ((= depth 0)
26 (quiescence-search b alpha beta))
27 ;; Interior: sort moves and recurse
28 (else
29 (let ((sorted-moves (sort legal-moves
30 (lambda (m1 m2)
31 (> (move-value b m1 tt-best-move)
32 (move-value b m2 tt-best-move))))))
33 (let loop ((moves sorted-moves) (best-score -1.0e9) (best-move #f) (current-alpha alpha))
34 (if (or (null? moves) (>= current-alpha beta))
35 (begin
36 ;; Store result in transposition table
37 (let ((flag (cond
38 ((<= best-score original-alpha) TT_BETA)
39 ((>= best-score beta) TT_ALPHA)
40 (else TT_EXACT))))
41 (hash-put! transposition-table (board-zobrist-hash b)
42 (make-tt-entry depth best-score flag best-move)))
43 best-score)
44 (let* ((move (car moves))
45 (state (board-make-move! b move))
46 ;; Negamax: negate child's score
47 (score (- (search b (- depth 1) (- beta) (- current-alpha)))))
48 (board-unmake-move! b move state)
49 (loop (cdr moves)
50 (max best-score score)
51 (if (> score best-score) move best-move)
52 (max current-alpha score))))))))))))
Checkmate is detected by an empty legal move list combined with the king being in check. The score (- -30000 (- max-depth depth)) encodes “checkmate in N moves” so positions with faster checkmates score higher (closer to -30000) because (- max-depth depth) is smaller when the mate happens sooner. This allows the engine to prefer forced mates over equivalent material wins.
Quiescence Search
The quiescence extension searches only captures (and promotions) past the nominal depth, continuing until the position is “quiet”:
1 (def (quiescence-search b alpha beta)
2 (set! nodes-visited (+ nodes-visited 1))
3 (let ((stand-pat (evaluate-board b)))
4 (cond
5 ;; Beta cutoff: opponent already has better
6 ((>= stand-pat beta) beta)
7 (else
8 (let ((current-alpha (max alpha stand-pat)))
9 ;; Generate and filter only legal captures/promotions
10 (let* ((all-pseudo (get-pseudo-legal-moves b))
11 (captures '()))
12 (for-each
13 (lambda (m)
14 (when (or (> (move-struct-piece-captured m) EMPTY)
15 (> (move-struct-promotion m) EMPTY))
16 (let* ((state (board-make-move! b m))
17 (k-sq (vector-ref (board-king-square b)
18 (color-idx (opponent (board-turn b))))))
19 (when (not (is-square-attacked? b k-sq (board-turn b)))
20 (set! captures (cons m captures)))
21 (board-unmake-move! b m state))))
22 all-pseudo)
23 ;; Recurse through sorted captures
24 ...))))))
The stand-pat score is the static evaluation without making any further capture. If the stand-pat already beats beta, the position is returned immediately because the opponent would not have allowed reaching this node in the first place. This is the quiescence equivalent of alpha-beta pruning.
Iterative Deepening
The top-level get-best-move function wraps the search in an iterative deepening loop:
1 (def (get-best-move b (depth 3))
2 (when (> (table-length transposition-table) 500000)
3 (set! transposition-table (make-hash-table)))
4 (let ((best-move #f) (best-score 0))
5 (do ((d 1 (+ d 1)))
6 ((> d depth) (list best-move best-score))
7 (set! max-depth d)
8 (set! nodes-visited 0)
9 (let* ((legal-moves (get-legal-moves b))
10 (tt-entry (hash-get transposition-table (board-zobrist-hash b)))
11 (tt-best (and tt-entry (tt-entry-best-move tt-entry)))
12 (sorted-moves (sort legal-moves
13 (lambda (m1 m2)
14 (> (move-value b m1 tt-best)
15 (move-value b m2 tt-best)))))
16 ...)
17 (for-each
18 (lambda (move)
19 (let* ((state (board-make-move! b move))
20 (score (- (search b (- d 1) (- beta) (- alpha)))))
21 (board-unmake-move! b move state)
22 (when (> score current-score)
23 (set! current-score score)
24 (set! current-best move))
25 (set! alpha (max alpha score))))
26 sorted-moves)
27 ;; Store root best move in TT for next iteration's ordering
28 (when current-best
29 (hash-put! transposition-table (board-zobrist-hash b)
30 (make-tt-entry d current-score TT_EXACT current-best)))))))
At each depth the transposition table best move from the previous depth is used to order the root moves. This “hash move” ordering is the single most effective pruning technique in practice.
The CLI Module: cli.ss
The CLI module handles display and user input. It defines Unicode glyphs for each piece and renders the board using ANSI escape codes for colored squares.
1 ;; File: cli.ss
2 (import "engine" "ai" :std/format :std/pregexp :gerbil/gambit)
3
4 ;; Unicode chess pieces
5 (def glyphs (make-vector 32 " "))
6 (vector-set! glyphs (logior WHITE KING) "♔")
7 (vector-set! glyphs (logior WHITE QUEEN) "♕")
8 (vector-set! glyphs (logior WHITE ROOK) "♖")
9 (vector-set! glyphs (logior WHITE BISHOP) "♗")
10 (vector-set! glyphs (logior WHITE KNIGHT) "♘")
11 (vector-set! glyphs (logior WHITE PAWN) "♙")
12 (vector-set! glyphs (logior BLACK KING) "♚")
13 (vector-set! glyphs (logior BLACK QUEEN) "♛")
14 (vector-set! glyphs (logior BLACK ROOK) "♜")
15 (vector-set! glyphs (logior BLACK BISHOP) "♝")
16 (vector-set! glyphs (logior BLACK KNIGHT) "♞")
17 (vector-set! glyphs (logior BLACK PAWN) "♟")
The Unicode piece codepoints (U+2654 through U+265F) are the standard chess symbols present in most modern terminal fonts. The vector is indexed by piece encoding (e.g., logior WHITE KING = 14), giving O(1) glyph lookup.
Board Rendering
1 (def (print-board b)
2 (let ((info '()) (grid (board-grid b)))
3 ;; Build sidebar information items
4 (set! info (cons (format "\x1b;[1;37mTurn: ~a\x1b;[0m"
5 (if (= (board-turn b) WHITE) "White" "Black")) info))
6 (set! info (cons (format "Eval: ~a"
7 (/ (round (/ (evaluate-board b) 10.0)) 10.0)) info))
8 ;; ... more info items
9 (do ((rank 7 (- rank 1)))
10 ((< rank 0))
11 (let ((row (list (format "\x1b;[90m~a\x1b;[0m " (+ rank 1)))))
12 (do ((file 0 (+ file 1)))
13 ((= file 8))
14 (let* ((sq (+ (* rank 8) file))
15 (p (vector-ref grid sq))
16 ;; Alternate dark/light square background colors
17 (bg (if (= (modulo (+ rank file) 2) 0)
18 "\x1b;[48;5;237m" ; dark grey
19 "\x1b;[48;5;94m"))) ; brown
20 (if (= p EMPTY)
21 (set! row (cons (string-append bg " \x1b;[0m") row))
22 (let ((color-code (if (= (piece-color p) WHITE)
23 "\x1b;[1;37m" ; bright white
24 "\x1b;[1;35m"))) ; bright magenta
25 (set! row (cons (format "~a~a ~a \x1b;[0m" bg color-code
26 (vector-ref glyphs p)) row))))))
27 (displayln (string-append (string-join (reverse row) "")
28 " " (list-ref info-list (- 7 rank))))))))
The checkerboard pattern is produced by checking whether (rank + file) is even or odd, selecting between ANSI 256-color codes for dark grey (237) and brown (94). Each square is three characters wide: a space, the piece glyph, and a space.
Move Input and Game Loop
Moves are entered in UCI notation: the source square followed by the destination square, with an optional promotion character (e.g., e2e4, e7e8q). The parse-move function looks the UCI string up in the legal move list rather than constructing a move object directly, ensuring the engine only executes genuinely legal moves:
1 (def (parse-move b input)
2 (let* ((s (string-trim input)) (len (string-length s)))
3 (if (or (< len 4) (> len 5))
4 #f
5 (let* ((from (hash-ref name-to-square (substring s 0 2) #f))
6 (to (hash-ref name-to-square (substring s 2 4) #f))
7 (promo-char (if (= len 5) (string-ref s 4) #f))
8 (promo-type (cond
9 ((not promo-char) EMPTY)
10 ((char=? promo-char #\q) QUEEN)
11 ...))
12 (legal-moves (get-legal-moves b)))
13 (let loop ((moves legal-moves))
14 (cond
15 ((null? moves) #f)
16 (else
17 (let* ((m (car moves))
18 (m-from (move-struct-from m))
19 (m-to (move-struct-to m))
20 (m-promo (move-struct-promotion m)))
21 (if (and (= m-from from) (= m-to to))
22 ;; Match promotion type (default to queen)
23 (cond
24 ((and (> promo-type 0) (= m-promo promo-type)) m)
25 ((and (= promo-type 0) (= m-promo 0)) m)
26 (else (loop (cdr moves))))
27 (loop (cdr moves)))))))))))
The game loop handles three modes: human vs. bot, bot vs. human, and bot vs. bot. In bot-vs-bot mode a 500ms sleep between moves makes the game watchable at full speed. In-check detection, checkmate, stalemate, and the fifty-move rule are all tested before each move.
The Perft Module: perft.ss
Perft (performance test) is the standard correctness benchmark for chess engines. It counts the number of leaf nodes reachable from a starting position at a given depth and compares the result against known-correct values. Any deviation indicates a bug in move generation.
1 ;; File: perft.ss
2 (import "engine")
3
4 (def (perft board depth)
5 (if (= depth 0)
6 1
7 (let ((nodes 0)
8 (moves (get-legal-moves board)))
9 (for-each
10 (lambda (move)
11 (let* ((state (board-make-move! board move))
12 (recomputed (board-compute-zobrist-hash board)))
13 ;; Verify Zobrist hash is consistent after every move
14 (unless (= recomputed (board-zobrist-hash board))
15 (error (format "Hash mismatch after ~a: stored=~a recomputed=~a"
16 (move->uci move) (board-zobrist-hash board) recomputed)))
17 (set! nodes (+ nodes (perft board (- depth 1))))
18 (board-unmake-move! board move state)
19 ;; Verify hash is restored to pre-move value
20 (unless (= (board-zobrist-hash board) (board-state-zobrist-hash state))
21 (error (format "Hash not restored after unmake ~a" (move->uci move))))))
22 moves)
23 nodes)))
The perft function doubles as a Zobrist hash integrity checker: after every make/unmake pair it verifies that the incremental hash equals a freshly recomputed hash. This catches any bug in the incremental update logic immediately.
The test runner checks three depths against known values:
1 (def (run-perft)
2 (let ((board (new-board))
3 (expected '#(20 400 8902)))
4 (displayln "Perft tests from starting position:\n")
5 (do ((depth 1 (+ depth 1)))
6 ((> depth 3))
7 (let* ((start (time->seconds (current-time)))
8 (nodes (perft board depth))
9 (elapsed (- end start))
10 (exp (vector-ref expected (- depth 1)))
11 (status (if (= nodes exp) "PASS" "FAIL")))
12 (displayln (format "Depth ~a: ~a nodes (expected ~a) [~a] ~as (~a nps)"
13 depth nodes exp status elapsed nps))))))
The expected node counts (20, 400, 8902) are universally accepted reference values for the standard starting position. Depth 1 checks that exactly 20 opening moves are generated. Depth 2 verifies that each of those 20 positions generates exactly 20 responses (20 × 20 = 400). Depth 3 is where errors in promotion, en passant, and castling handling typically appear.
Running the Code
All targets are available through the Makefile. Make sure Gerbil Scheme (gxi and gxc) is installed and on your PATH.
1 $ cat Makefile
2 .PHONY: run test compile clean
3
4 run:
5 gxi cli.ss
6
7 test:
8 gxi perft.ss
9
10 compile:
11 gxc engine.ss ai.ss
12
13 clean:
14 rm -rf .gerbil/
Running the Perft Suite
1 $ make test
2 gxi perft.ss
3 Perft tests from starting position:
4
5 Depth 1: 20 nodes (expected 20) [PASS] 0.0s (42500 nps)
6 Depth 2: 400 nodes (expected 400) [PASS] 0.01s (58300 nps)
7 Depth 3: 8902 nodes (expected 8902) [PASS] 0.14s (63800 nps)
All three depths pass, confirming that the move generator produces exactly the correct number of positions and that the Zobrist hash remains consistent through every make/unmake pair. The nodes-per-second (nps) figure reflects the interpreted speed of gxi; compiling with gxc typically doubles or triples this figure.
Running the Interactive Game
1 $ make run
2 gxi cli.ss
3
4 === Chess Game ===
5
6 1. Play as White
7 2. Play as Black
8 3. Bot vs Bot
9
10 Choose mode (1-3): 1
11 Bot depth (1-6, default 3): 3
12
13 8 ♜ ♞ ♝ ♛ ♚ ♝ ♞ ♜ Turn: White
14 7 ♟ ♟ ♟ ♟ ♟ ♟ ♟ ♟ Move: 1
15 6 50-move: 0
16 5 EP: -
17 4 Castling: KQkq
18 3 Eval: 0.0
19 2 ♙ ♙ ♙ ♙ ♙ ♙ ♙ ♙
20 1 ♖ ♘ ♗ ♕ ♔ ♗ ♘ ♖
21 a b c d e f g h
22
23 Your move (or help): e2e4
24 ...
25 Bot is thinking (depth 3)...
26 Bot plays: e7e5 | eval: 0.0 | nodes: 12481 | time: 0.8s
You enter moves in UCI notation. The sidebar updates after each move to show whose turn it is, the full-move number, the fifty-move counter, the en-passant target square, castling availability, and the static evaluation in pawn units.
Available in-game commands:
| Command | Description |
|---|---|
e2e4 |
Standard UCI move (source square + destination) |
e7e8q |
Pawn promotion (append q, r, b, or n) |
legal |
Print all legal moves in the current position |
fen |
Print the FEN string for the current position |
setfen |
Load a position from a FEN string |
reset |
Reset to the starting position |
help |
Show command list |
exit |
Quit the game |
Bot vs. Bot Mode
Choosing mode 3 plays both sides automatically at the selected depth. This is useful for watching the engine’s opening and middlegame tendencies:
1 Choose mode (1-3): 3
2 Bot depth (1-6, default 3): 4
3
4 Bot is thinking (depth 4)...
5 Bot plays: e2e4 | eval: 0.5 | nodes: 88420 | time: 1.2s
6
7 Bot is thinking (depth 4)...
8 Bot plays: e7e5 | eval: 0.0 | nodes: 92310 | time: 1.3s
9
10 Bot is thinking (depth 4)...
11 Bot plays: g1f3 | eval: 0.4 | nodes: 79810 | time: 1.1s
12 ...
Interpreting the Output
Perft Node Counts
The perft numbers are not arbitrary. At depth 1, exactly 20 moves are legal from the starting position: 16 pawn moves (two squares or one square for each of the eight pawns) and 4 knight moves. At depth 2, all 20 of white’s replies also have 20 legal responses (by symmetry of the starting position), giving 400. At depth 3, 8902 nodes are reached. Note that the divergence from 20^3 = 8000 arises because of en-passant possibilities created by the depth-2 double pawn pushes, and because some positions at depth 2 allow more than 20 responses.
A perft FAIL at depth 1 almost always indicates an error in basic piece movement. A FAIL at depth 3 that passes depths 1 and 2 typically points to a bug in en-passant, castling, or promotion handling, since those edge cases first appear at depth 3.
Evaluation Scores
The evaluation sidebar shows the position score in pawn units, from White’s perspective. A score of +0.5 means White has a slight material or positional advantage worth half a pawn. A score of +3.0 corresponds to a full rook advantage. Scores above +20.0 indicate a forced checkmate has been found (the engine uses 30000 as the base checkmate value, scaled by distance).
The nodes count in the bot output reflects how many positions the engine examined. At depth 3, a typical middlegame position has around 10,000–50,000 nodes. At depth 4, expect 100,000–500,000. The dramatic reduction compared to a full minimax tree (~30^depth) is the combined effect of alpha-beta pruning, move ordering, and the transposition table.
Node Throughput
The nps figure from the perft run is a measure of raw engine speed in the Gerbil interpreter. Typical interpreted throughput is 50,000–100,000 nodes per second. Running make compile first to produce optimized Gambit bytecode usually doubles this to 100,000–200,000 nps, sufficient for a responsive depth-4 or depth-5 search in under two seconds per move.
Wrap Up
In this chapter we built a complete chess engine and AI bot in Gerbil Scheme. Starting from first principles, we designed a mailbox board representation with bitwise piece encoding, precomputed move tables for knights, kings, and sliding pieces, and an incremental Zobrist hashing scheme that provides O(1) position identity checks. The legal move generator handles all of chess’s special cases: pawn double pushes, en-passant captures, pawn promotions to any piece, and kingside and queenside castling with transit square attack checks.
On top of this engine we layered an AI search that combines iterative deepening negamax with alpha-beta pruning, a transposition table keyed on Zobrist hashes, MVV-LVA and PST-delta move ordering, and a quiescence extension that resolves captures before evaluating leaf nodes. The static evaluator combines material counts with piece-square tables that encode positional knowledge, switching between middlegame and endgame king tables based on remaining material.
The project demonstrates several Gerbil Scheme idioms worth carrying into other programs: aliasing long function names for readability, using defstruct with transparent: #t for easily printed structures, combining do loops for startup initialization with recursive lambda for runtime logic, and the make/unmake pattern for reversible state mutation that avoids copying large data structures during search.
The perft suite provides a disciplined correctness gate and a chess engine that passes all three depths is move-generation correct. The bot at depth 4 produces recognizable opening play (1. e4 e5 2. Nf3 followed by natural development) and will consistently defeat beginners, making this a satisfying demonstration of how much strategic behavior emerges from a relatively simple evaluation function combined with deep search.
A natural next step is to extend the search with null move pruning (trying a pass move to detect positions where the opponent has no effective threats) and late move reductions (searching the last few moves at a reduced depth), which together can double the effective search depth at no additional node cost.
Practice Problems
Exercise 1: Display Board State as Unicode Art
Write a Gerbil Scheme function board->string that returns the current board position as a multi-line string of Unicode chess glyphs with rank numbers and file letters, but without any ANSI color codes. The function should be suitable for logging or writing the board to a file. Use the glyphs vector from cli.ss for piece rendering.
Expected output for the starting position:
1 8 ♜ ♞ ♝ ♛ ♚ ♝ ♞ ♜
2 7 ♟ ♟ ♟ ♟ ♟ ♟ ♟ ♟
3 6 . . . . . . . .
4 5 . . . . . . . .
5 4 . . . . . . . .
6 3 . . . . . . . .
7 2 ♙ ♙ ♙ ♙ ♙ ♙ ♙ ♙
8 1 ♖ ♘ ♗ ♕ ♔ ♗ ♘ ♖
9 a b c d e f g h
Exercise 2: Count Attacked Squares
Using is-square-attacked? from engine.ss, write a function count-attacked-squares that takes a board b and a color (WHITE or BLACK) and returns the number of squares on the board that are attacked by that color. Run it on the starting position for both colors and verify that the results are equal (by symmetry).
Exercise 3: Perft Divide
Implement perft-divide, a variant of perft that prints, for each legal move from the root position, the number of leaf nodes reachable via that move at depth d-1. This “divide” output is the standard tool for isolating which move produces an incorrect node count. The starting position at depth 3 should show 20 moves, each with its node count, summing to 8902.
1 a2a3: 8457
2 a2a4: 9329
3 b2b3: 9345
4 ...
5 Total: 8902
Exercise 4: Material Balance Reporter
Write a function material-report that takes a board and returns a formatted string breaking down the material count for each side by piece type. For example:
1 White: P×8=800 N×2=640 B×2=660 R×2=1000 Q×1=900 Total=4000
2 Black: P×8=800 N×2=640 B×2=660 R×2=1000 Q×1=900 Total=4000
3 Advantage: even
If one side is ahead, print the advantage in pawn units (e.g., White +1.5). Use this function to instrument the bot-vs-bot loop, printing the material report after every ten moves.
Exercise 5: Opening Book Integration
Create a simple opening book as a Gerbil hash table mapping FEN strings (of positions after 0, 1, and 2 moves) to a list of UCI move strings. Modify get-best-move in ai.ss to check the book first and, if the current position is in the book, pick a random move from the list rather than running the search. Seed the book with at least three openings (e.g., 1. e4, 1. d4, and 1. Nf3) and their most common replies. Verify that the bot plays book moves from the starting position.