A Complete Clojure Chess Engine and AI Bot

This chapter walks through a complete chess engine written in idiomatic Clojure. The code runs on the JVM with no external dependencies beyond Clojure itself. The project contains a high-performance move generator, a negamax AI with alpha-beta pruning, and an interactive terminal-based UI. You can play against the bot or watch it play against itself.

We will use the project file to manage both the main game entry point and a perft testing alias:

1 (defproject chess-game "0.1.0-SNAPSHOT"
2   :description "Chess engine and AI bot in Clojure"
3   :url "https://github.com/mark-watson/Clojure-AI-Book"
4   :license {:name "Apache License 2.0"
5             :url "http://www.apache.org/licenses/LICENSE-2.0"}
6   :dependencies [[org.clojure/clojure "1.12.6"]]
7   :main ^:skip-aot chess-game.cli
8   :aliases {"perft" ["run" "-m" "chess-game.perft"]}
9   :profiles {:uberjar {:aot :all}})

The project is organized into four source files:

1 src/
2 └── chess_game/
3     ├── engine.clj   ; board, moves, FEN, Zobrist hashing
4     ├── ai.clj       ; evaluation, search, transposition table
5     ├── cli.clj       ; terminal UI, game loop
6     └── perft.clj     ; move generation verification

Board Representation and Piece Encoding

The engine represents pieces as bytes where the lower three bits encode the piece type and bits 4—5 encode the color. This compact encoding allows us to test piece properties with fast bitwise operations while keeping the board as a single flat vector of 64 integers.

 1 ;; Piece representation
 2 (def EMPTY 0)
 3 (def PAWN 1)
 4 (def KNIGHT 2)
 5 (def BISHOP 3)
 6 (def ROOK 4)
 7 (def QUEEN 5)
 8 (def KING 6)
 9 (def TYPE-MASK 7)
10 
11 (def WHITE 8)
12 (def BLACK 16)
13 (def COLOR-MASK 24)
14 
15 (defn piece-type [p] (bit-and p TYPE-MASK))
16 (defn piece-color [p] (bit-and p COLOR-MASK))
17 (defn opponent [c] (if (= c WHITE) BLACK WHITE))
18 (defn color-idx [c] (if (= c WHITE) 0 1))

On lines 4—11 we define piece types as small integers. Lines 13—15 define color constants as bit flags. The masks on lines 12 and 16 let us extract type and color from the combined value. For example, a white knight is (bit-or WHITE KNIGHT) which evaluates to 10.

The game state is a Clojure map containing:

  • A 64-element persistent vector for the board square contents
  • A two-element vector of persistent sets tracking the locations of white and black pieces
  • A two-element vector of king square indices
  • Turn indicator (WHITE or BLACK)
  • Castling rights bitmask
  • En passant target square (or -1)
  • Halfmove and fullmove clocks for the 50-move rule
  • A precomputed Zobrist hash for fast position identification

Square Indexing and Precomputed Move Tables

Squares are indexed 0—63, where 0 is a1 and 63 is h8, increasing first by file then by rank. Two helper maps convert between algebraic notation and integer indices:

1 (def SQUARE-NAMES
2   (vec (for [row (range 8)
3              col (range 8)]
4          (str (get "abcdefgh" col) (get "12345678" row)))))
5 
6 (def NAME-TO-SQUARE
7   (into {} (map-indexed (fn [idx name] [name idx]) SQUARE-NAMES)))

Rather than computing moves on the fly, we precompute lookup tables for knight moves, king moves, and sliding piece rays. This approach trades memory for speed—-the tables are computed once at load time and then reused across all move generation.

 1 (def KNIGHT-MOVES
 2   (vec (for [sq (range 64)]
 3          (let [r (quot sq 8)
 4                f (mod sq 8)
 5                offsets [[-2 -1] [-2 1] [-1 -2] [-1 2] [1 -2] [1 2] [2 -1] [2 1]]]
 6            (vec (for [[dr df] offsets
 7                       :let [nr (+ r dr)
 8                             nf (+ f df)]
 9                       :when (and (>= nr 0) (< nr 8) (>= nf 0) (< nf 8))]
10                   (+ (* nr 8) nf)))))))

Each of the 64 squares gets a vector of valid knight destinations computed with rank-and-file offset arithmetic. The :when guard on line 9 ensures we only include squares that stay on the board.

King moves follow the same pattern with the eight adjacent directions. For sliding pieces—-rooks, bishops, and queens—-we compute rays:

 1 (defn- compute-rays [sq dirs]
 2   (let [r (quot sq 8)
 3         f (mod sq 8)]
 4     (vec (for [[dr df] dirs]
 5            (loop [nr (+ r dr)
 6                   nf (+ f df)
 7                   ray []]
 8              (if (and (>= nr 0) (< nr 8) (>= nf 0) (< nf 8))
 9                (recur (+ nr dr) (+ nf df) (conj ray (+ (* nr 8) nf)))
10                ray))))))
11 
12 (def ROOK-RAYS
13   (vec (for [sq (range 64)]
14          (compute-rays sq [[1 0] [-1 0] [0 1] [0 -1]]))))

Each ray is a vector of squares extending outward from the source square in one direction. The function on line 1 uses loop/recur to walk outward until it hits the board edge. Rook rays compute the four cardinal directions, bishop rays use the four diagonals, and queen rays concatenate both.

Zobrist Hashing

Zobrist hashing assigns a random 64-bit integer to each possible piece-on-square combination, plus an extra key for side-to-move, sixteen keys for castling rights, and sixty-four keys for en passant squares. The hash of a position is the XOR of all applicable keys. This allows us to incrementally update the hash during move execution by XOR-ing out old keys and XOR-ing in new ones, rather than recomputing from scratch.

 1 (defn- create-rng [seed]
 2   (let [state (atom (bit-or (long seed) 1))]
 3     (fn []
 4       (swap! state (fn [^long curr]
 5                      (unchecked-add (unchecked-multiply curr 6364136223846793005)
 6                                     1442695040888963407))))))
 7 
 8 (def rng (create-rng 1337))
 9 
10 (def ZOBRIST_PIECES
11   (vec (for [_ (range 64)]
12          (vec (for [_ (range 32)] (rng))))))
13 
14 (def ZOBRIST_SIDE (rng))
15 
16 (def ZOBRIST_CASTLING
17   (vec (for [_ (range 16)] (rng))))
18 
19 (def ZOBRIST_EP
20   (vec (for [_ (range 64)] (rng))))

We generate the random keys with a deterministic seeded PRNG (line 1) so that hashes are reproducible across runs. The piece table on line 12 is a 64-by-32 matrix: each of the 64 squares has its own random key for each of the 32 possible piece values (six types times two colors, with unused slots).

The move generator iterates over every piece owned by the side to move and produces candidate moves without checking whether the resulting position leaves the king in check. That legality filter is applied later in get-legal-moves.

1 (defrecord Move [from to piece-moved piece-captured promotion
2                  en-passant? castling? double-push?])

Each move is a defrecord carrying the source and destination squares, the piece being moved, any captured piece, a promotion piece type if applicable, and three boolean flags for en passant, castling, and double pawn push. The boolean flags allow make-move to handle the side effects of these special moves without inspecting the board.

The move generator handles six piece types. Pawns account for the most logic: single pushes, double pushes from the starting rank, captures (including en passant), and promotions:

 1 (= ptype PAWN)
 2 (let [push-sq (+ sq forward)]
 3   (when (= (get board-vec push-sq) EMPTY)
 4     (if (= (quot push-sq 8) promo-rank)
 5       (doseq [pp promo-pieces]
 6         (conj! moves (->Move sq push-sq piece EMPTY pp false false false)))
 7       (do
 8         (conj! moves (->Move sq push-sq piece EMPTY EMPTY false false false))
 9         (when (= r start-rank)
10           (let [double-sq (+ push-sq forward)]
11             (when (= (get board-vec double-sq) EMPTY)
12               (conj! moves (->Move sq double-sq piece EMPTY EMPTY
13                                    false false true))))))))
14   ;; Captures & EP
15   (doseq [df [-1 1]
16           :let [nf (+ f df)]
17           :when (and (>= nf 0) (<= nf 7))
18           :let [cap-sq (+ sq forward df)]]
19     (let [target (get board-vec cap-sq)]
20       (cond
21         (and (not= target EMPTY) (= (piece-color target) opp))
22         (if (= (quot cap-sq 8) promo-rank)
23           (doseq [pp promo-pieces]
24             (conj! moves (->Move sq cap-sq piece target pp
25                                  false false false)))
26           (conj! moves (->Move sq cap-sq piece target EMPTY false false false)))
27         (= cap-sq (:en-passant-square board))
28         (let [ep-captured (get board-vec (- cap-sq forward))]
29           (conj! moves (->Move sq cap-sq piece ep-captured
30                                EMPTY true false false)))))))

When a pawn reaches the promotion rank (line 5) we generate four moves, one for each promotion piece (QUEEN, ROOK, BISHOP, KNIGHT). On line 12 the double pawn push sets the double-push? flag, which make-move uses to set the en passant target square for the opponent’s next turn.

Knight and king moves are straightforward: each destination in the precomputed table becomes a move if the target square is empty or occupied by an opponent piece. Kings also generate castling moves when the castling rights are still set and the intermediate squares are empty and unattacked:

 1 ;; Castling
 2 (if (= color WHITE)
 3   (when (= sq 4)
 4     (when (and (not= (bit-and (:castling-rights board) WK) 0)
 5                (= (get board-vec 5) EMPTY)
 6                (= (get board-vec 6) EMPTY))
 7       (when (not (or (square-attacked? board 4 opp)
 8                      (square-attacked? board 5 opp)
 9                      (square-attacked? board 6 opp)))
10         (conj! moves (->Move 4 6 piece EMPTY EMPTY false true false))))
11     ...))
12   ;; Black castling
13   ...)

The castling rights bitmask (WK = 1, WQ = 2, BK = 4, BQ = 8) is checked on line 4 with bit-and. The three intermediate squares must be empty, and the king must not pass through or land on an attacked square. The castling? flag on line 9 tells make-move to also move the rook.

Sliding pieces follow rays outward, stopping at the first occupied square:

 1 :else ; sliding pieces
 2 (let [rays (condp = ptype
 3              BISHOP (get BISHOP-RAYS sq)
 4              ROOK   (get ROOK-RAYS sq)
 5              QUEEN  (get QUEEN-RAYS sq))]
 6   (doseq [ray rays]
 7     (loop [r-sqs ray]
 8       (when-let [t (first r-sqs)]
 9         (let [target (get board-vec t)]
10           (cond
11             (= target EMPTY)
12             (do
13               (conj! moves (->Move sq t piece EMPTY EMPTY
14                                    false false false))
15               (recur (rest r-sqs)))
16             (= (piece-color target) opp)
17             (conj! moves (->Move sq t piece target EMPTY
18                                  false false false))
19             :else nil))))))

On line 15, when the ray encounters an empty square we add a move and continue along the ray. On line 18, when we encounter an enemy piece we add the capture move but stop. On line 20, a friendly piece blocks the ray with no move added. The nil on line 20 signals recur to terminate the loop because when-let will see nil from the next first call.

Executing Moves

The make-move function takes a board map and a move record, and returns a new board map representing the position after the move. It uses mutable atoms internally for performance during assembly but returns an immutable persistent map:

 1 (defn make-move [board move]
 2   (let [color (piece-color (:piece-moved move))
 3         opp (opponent color)
 4         ci (color-idx color)
 5         oi (color-idx opp)
 6         board-vec (:board board)
 7 
 8         new-board-vec (atom (assoc board-vec (:from move) EMPTY))
 9         new-pieces-color (atom (disj (get-in board [:pieces ci]) (:from move)))
10         new-pieces-opp (atom (get-in board [:pieces oi]))
11         new-king-squares (atom (:king-squares board))
12 
13         ep? (:en-passant? move)
14         captured-sq (if ep?
15                       (- (:to move) (if (= color WHITE) 8 -8))
16                       (:to move))
17 
18         h-atom (atom (long (:zobrist-hash board)))]
19     ...))

The moving piece is first removed from its origin square on line 9. En passant captures on line 17 compute the actual square of the captured pawn, which is one rank behind the destination square. The Zobrist hash is updated incrementally at every step: we XOR out the old side-to-move key, old castling rights, and old en passant square, then XOR them back in with their new values.

After placing the piece at its destination, the function handles promotion (using the promoted piece type instead of the original pawn), castling (moving the rook from its corner to its post-castle position), and updates to castling rights if the moved piece is a king or rook:

 1 ;; Update castling rights
 2 (let [old-rights (:castling-rights board)
 3       r1 (if (= (piece-type (:piece-moved move)) KING)
 4            (if (= color WHITE)
 5              (bit-and old-rights (bit-not (bit-or WK WQ)))
 6              (bit-and old-rights (bit-not (bit-or BK BQ))))
 7            old-rights)
 8       r2 (cond-> r1
 9            (or (= (:from move) 7) (= (:to move) 7)) (bit-and (bit-not WK))
10            (or (= (:from move) 0) (= (:to move) 0)) (bit-and (bit-not WQ))
11            (or (= (:from move) 63) (= (:to move) 63)) (bit-and (bit-not BK))
12            (or (= (:from move) 56) (= (:to move) 56)) (bit-and (bit-not BQ)))
13       new-rights r2
14 
15       new-ep (if (:double-push? move)
16                (- (:to move) (if (= color WHITE) 8 -8))
17                -1)
18 
19       pawn? (= (piece-type (:piece-moved move)) PAWN)
20       cap? (and (:piece-captured move) (not= (:piece-captured move) EMPTY))
21       new-halfmove (if (or pawn? cap?) 0 (inc (:halfmove-clock board)))
22       new-fullmove (if (= color BLACK)
23                      (inc (:fullmove-number board))
24                      (:fullmove-number board))]
25   ...)

Lines 6—10 use Clojure’s cond-> threading macro to revoke castling rights when a rook moves from or is captured on its starting square. The halfmove clock resets to zero on pawn moves or captures (line 23) and increments otherwise, enabling the 50-move draw rule. The fullmove number increments after Black’s move.

The square-attacked? function checks whether a given square is under attack by any piece of the specified color. It queries the precomputed move tables in reverse—-instead of asking “where can this knight go?”, it asks “is there an enemy knight that can reach this square?”

1 (defn square-attacked? [board sq attacker-color]
2   ...)

Pawn attacks are checked by looking at the two diagonal squares behind the target (relative to the attacker’s forward direction). Knight and king attacks look up the precomputed destination tables for sq and check if an enemy knight or king occupies any of those squares. Sliding piece attacks iterate outward along rays—-just like the move generator, but stopping at the first piece whether it’s the attacker or a blocker.

Legal move filtering wraps get-pseudo-legal-moves with a check that the king is not left in check:

1 (defn get-legal-moves [board]
2   (let [color (:turn board)
3         ci (color-idx color)
4         opp (opponent color)]
5     (filter (fn [move]
6               (let [new-board (make-move board move)]
7                 (not (square-attacked? new-board
8                       (get-in new-board [:king-squares ci]) opp))))
9             (get-pseudo-legal-moves board))))

For each candidate move we apply the move and test whether the moving side’s king is under attack in the resulting position. This catches moves that are illegal because the king would be exposed to check, which includes pinned pieces moving off a pinning line.

FEN Parsing and Serialization

Forsyth-Edwards Notation (FEN) is the standard text format for describing chess positions. The from-fen parser splits the FEN string into its six components and rebuilds the full board state:

 1 (defn from-fen [fen]
 2   (let [parts (str/split fen #" ")
 3         [placement active-color castling ep halfmove fullmove] parts
 4         ...]
 5     (let [turn (if (= active-color "w") WHITE BLACK)
 6           castling-rights (reduce (fn [rights ch]
 7                                     (condp = ch
 8                                       \K (bit-or rights WK)
 9                                       \Q (bit-or rights WQ)
10                                       \k (bit-or rights BK)
11                                       \q (bit-or rights BQ)
12                                       rights))
13                                   0 castling)
14           ep-square (if (= ep "-") -1 (get NAME-TO-SQUARE ep))
15           ...
16           initial-board {:board @board-vec
17                          :pieces [@pieces-w @pieces-b]
18                          :king-squares [wk-sq bk-sq]
19                          :turn turn
20                          :castling-rights castling-rights
21                          :en-passant-square ep-square
22                          :halfmove-clock halfmove-clock
23                          :fullmove-number fullmove-number}]
24       (assoc initial-board :zobrist-hash
25              (compute-zobrist-hash initial-board)))))

The placement string is eight rank descriptions separated by slashes, processed from the eighth rank down to the first. Digits represent empty squares; letters map through type-map to piece constants. After building the board vector and piece sets, we find the king squares by linear search (line 20) and compute the initial Zobrist hash from scratch on line 30.

The to-fen function reverses this process by iterating over ranks from top to bottom, counting empty squares and emitting piece characters.

AI: Position Evaluation

The AI evaluates positions using material scores plus Piece-Square Tables (PSTs). The PSTs encode positional knowledge: a knight on d4 is worth more than a knight on a3, a rook on an open seventh rank is worth more than a rook on its starting square, and so on.

1 (def PIECE-VALUES
2   {PAWN 100, KNIGHT 320, BISHOP 330, ROOK 500, QUEEN 900, KING 20000})

The king is given an extremely high value so that checkmate is always the worst possible outcome. Material is counted in centipawns.

The PST for knights illustrates the pattern:

1 (def KNIGHT-PST
2   [-50,-40,-30,-30,-30,-30,-40,-50,
3    -40,-20,  0,  0,  0,  0,-20,-40,
4    -30,  0, 10, 15, 15, 10,  0,-30,
5    -30,  5, 15, 20, 20, 15,  5,-30,
6    -30,  0, 15, 20, 20, 15,  0,-30,
7    -30,  5, 10, 15, 15, 10,  5,-30,
8    -40,-20,  0,  5,  5,  0,-20,-40,
9    -50,-40,-30,-30,-30,-30,-40,-50])

The table is arranged from a1 (index 0) to h8 (index 63), from White’s perspective. For Black pieces, we XOR the square index with 56 to mirror the board vertically. Central squares for knights are the most valuable (rows 3—4, columns 3—4), while edge squares are penalized.

The evaluation function also distinguishes between midgame and endgame using a material threshold:

 1 (defn is-endgame? [board]
 2   (let [board-vec (:board board)
 3         material (reduce (fn [sum sq]
 4                            (let [pt (piece-type (get board-vec sq))]
 5                              (if (and (not= pt PAWN) (not= pt KING))
 6                                (+ sum (get PIECE-VALUES pt))
 7                                sum)))
 8                          0
 9                          (concat (get-in board [:pieces 0])
10                                  (get-in board [:pieces 1])))]
11     (<= material 3000)))

When the total non-pawn, non-king material drops below 3000 centipawns (roughly three minor pieces), the engine switches from the midgame king PST to the endgame king PST. In the midgame, kings are safer near the corner behind pawns; in the endgame, kings should be active and centralized.

The top-level evaluate function sums White’s material plus PST bonuses, subtracts Black’s, adds the king PST for each side, and returns the score from the perspective of the side to move:

1 (defn evaluate [board]
2   ...
3   (if (= (:turn board) WHITE)
4     score
5     (- score)))

If it’s Black’s turn, we negate the score so that the search can always maximize from the current side’s perspective.

AI: Negamax Search with Alpha-Beta Pruning

The search is a standard negamax formulation. At each node we generate all legal moves, order them by a heuristic that prioritizes captures, promotions, and TT best-moves, and recurse with negated alpha and beta bounds:

1 (defn search [board depth alpha beta]
2   (swap! nodes-visited inc)
3   (if (>= (:halfmove-clock board) 100)
4     0
5     (let [hash-val (:zobrist-hash board)
6           tt-entry (get @transposition-table hash-val)
7           ...]
8       ...)))

The 50-move rule check on line 4 returns a draw score of 0. The transposition table probe on line 7 looks up the current position by its Zobrist hash. If a stored entry has sufficient depth, we retrieve its score and best move—-possibly enabling an immediate cutoff if the stored score is outside our alpha-beta window.

Move ordering is critical for alpha-beta efficiency:

 1 (defn move-value [board move tt-move]
 2   (cond
 3     (and tt-move (move-equals? move tt-move)) 1000000
 4     (and (:piece-captured move) (not= (:piece-captured move) EMPTY))
 5     (+ 10000 (get PIECE-VALUES (piece-type (:piece-captured move)))
 6        (- (quot (get PIECE-VALUES (piece-type (:piece-moved move))) 100)))
 7     (and (:promotion move) (not= (:promotion move) EMPTY))
 8     (+ 8000 (get PIECE-VALUES (:promotion move)))
 9     (:castling? move) 1000
10     :else
11     (let [pt (piece-type (:piece-moved move))]
12       (if (not= pt KING)
13         (- (get (get PST-TABLES pt) (:to move))
14            (get (get PST-TABLES pt) (:from move)))
15         0))))

The highest priority (1,000,000) goes to the TT best-move from a previous iteration. Captures are scored by victim value minus attacker value / 100—-the MVV-LVA (Most Valuable Victim, Least Valuable Attacker) heuristic. Promotions are worth the promoted piece value plus a large bonus. Quiet moves get a small PST-change score, and king moves get zero.

The negamax loop itself is a classic implementation:

 1 (let [legal-moves (get-legal-moves board)]
 2   (cond
 3     (empty? legal-moves)
 4     (if (is-in-check? board)
 5       (+ -30000 (- @max-depth depth)) ; Checkmate
 6       0) ; Stalemate
 7 
 8     (= depth 0)
 9     (quiescence-search board alpha beta)
10 
11     :else
12     (let [sorted-moves (sort-by (fn [m] (move-value board m tt-best-move))
13                                 > legal-moves)]
14       (loop [moves sorted-moves
15              best-score -1000000000
16              best-move nil
17              curr-alpha alpha]
18         (if-let [move (first moves)]
19           (let [new-board (make-move board move)
20                 score (- (search new-board (dec depth) (- beta) (- curr-alpha)))]
21             (let [new-best-score (max best-score score)
22                   new-best-move (if (> score best-score) move best-move)
23                   next-alpha (max curr-alpha score)]
24               (if (>= next-alpha beta)
25                 new-best-score ; Beta cutoff
26                 (recur (rest moves) new-best-score new-best-move next-alpha))))
27           ...))))))

Mate scores on line 6 include the distance from the root so that the engine prefers checkmating in fewer moves. When a beta cutoff occurs on line 22, the move is stored in the TT with a lower-bound flag (TT-ALPHA in our naming) and we return immediately without examining the remaining moves.

Quiescence search extends the search beyond the nominal depth on capture and promotion moves only, evaluating quiet positions with the static evaluation function. This prevents the engine from seeing a position where it captures a queen on the last ply but doesn’t notice that the capture initiates a series of exchanges that loses material:

 1 (defn quiescence-search [board alpha beta]
 2   (swap! nodes-visited inc)
 3   (let [stand-pat (evaluate board)]
 4     (if (>= stand-pat beta)
 5       beta
 6       (let [curr-alpha (max alpha stand-pat)
 7             captures (filter (fn [m] (or (and (:piece-captured m)
 8                                               (not= (:piece-captured m) EMPTY))
 9                                          (and (:promotion m)
10                                               (not= (:promotion m) EMPTY))))
11                              (get-pseudo-legal-moves board))
12             sorted-captures (sort-by (fn [m] (move-value board m nil))
13                                      > captures)]
14         (loop [moves sorted-captures
15                c-alpha curr-alpha]
16           ...)))))

The “stand pat” score on line 3 is the static evaluation of the current position. If it already exceeds beta, we return immediately—-the opponent would not have allowed us to reach this position if they could avoid it. Otherwise we consider only capture and promotion moves, filtering pseudo-legal moves to those that don’t leave our king in check on line 10.

Iterative Deepening

The top-level get-best-move function performs iterative deepening: it searches to depth 1, then depth 2, and so on up to the requested maximum. Each iteration’s best move seeds the move ordering for the next iteration:

 1 (defn get-best-move [board depth]
 2   (when (> (count @transposition-table) 500000)
 3     (reset! transposition-table {}))
 4 
 5   (let [best-move (atom nil)
 6         best-score (atom 0)]
 7     (doseq [d (range 1 (inc depth))]
 8       (reset! max-depth d)
 9       (reset! nodes-visited 0)
10       ...)))

The TT is cleared when it exceeds 500,000 entries (line 2) to prevent unbounded memory growth. For each iteration depth we perform a root-level search over all legal moves, using the full alpha-beta window. After each iteration, the best move and score are stored in atoms. The final iteration’s best move is the move the engine plays.

Interactive CLI

The command-line interface renders the board using Unicode chess pieces on a colored checkerboard and accepts moves in UCI format:

1 (def PIECE-GLYPHS
2   {(bit-or WHITE KING) "♔" (bit-or WHITE QUEEN) "♕" (bit-or WHITE ROOK) "♖"
3    (bit-or WHITE BISHOP) "♗" (bit-or WHITE KNIGHT) "♘" (bit-or WHITE PAWN) "♙"
4    (bit-or BLACK KING) "♚" (bit-or BLACK QUEEN) "♛" (bit-or BLACK ROOK) "♜"
5    (bit-or BLACK BISHOP) "♝" (bit-or BLACK KNIGHT) "♞" (bit-or BLACK PAWN) "♟"})

The board display alternates dark and light backgrounds using ANSI escape codes, with white pieces in bold white and black pieces in magenta. Each rank has a label on the left, and a file label line is printed at the bottom. Extra information—-turn, move number, evaluation score, castling rights, check indicator, and en passant target—-appears to the right of the board.

The game loop supports three modes: human as White, human as Black, or bot vs bot. When it’s the bot’s turn, get-best-move is called with the chosen depth and the resulting move is applied. Console commands (help, fen, setfen, legal, reset, exit) allow inspection and manipulation of the game state:

 1 (loop [board (from-fen "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1")]
 2   (print-board board)
 3   (let [legal-moves (get-legal-moves board)]
 4     (cond
 5       (empty? legal-moves)
 6       (if (is-in-check? board)
 7         (let [winner (if (= (:turn board) WHITE) "Black" "White")]
 8           (println (str "... Checkmate! " winner " wins.")))
 9         (println "... Stalemate! Draw."))
10 
11       (>= (:halfmove-clock board) 100)
12       (println "... 50-move rule! Draw.")
13 
14       ;; Bot turn
15       (or (nil? human-color) (not= (:turn board) human-color))
16       (do ...)
17 
18       ;; Human turn
19       :else
20       (let [input (ask "Your move (or help): ")]
21         ...))))

The game loop checks for terminal conditions first: checkmate (no legal moves and the king is in check), stalemate (no legal moves but not in check), and the 50-move draw.

Perft Testing

Perft (performance test) is a standard technique for verifying chess move generators. It recursively enumerates all legal moves to a given depth and counts the leaf nodes. The counts are compared against known correct values. Our perft module also validates the incremental Zobrist hash against a full recomputation after every move:

 1 (defn perft [board depth]
 2   (if (= depth 0)
 3     1
 4     (reduce (fn [nodes move]
 5               (let [new-board (make-move board move)
 6                     recomputed (compute-zobrist-hash new-board)]
 7                 (when (not= recomputed (:zobrist-hash new-board))
 8                   (println (str "Hash mismatch after " (move->uci move)
 9                                 ": stored=" (:zobrist-hash new-board)
10                                 " recomputed=" recomputed))
11                   (System/exit 1))
12                 (+ nodes (perft new-board (dec depth)))))
13             0
14             (get-legal-moves board))))

On line 6 we recompute the full Zobrist hash from scratch and compare it against the incrementally updated hash stored in the board map. Any mismatch triggers an error and program exit.

The test harness runs depths 1 through 3 from the starting position:

1 $ lein perft
2 Depth 1: 20 nodes (expected 20) [PASS] 0.00s (200,000 nps)
3 Depth 2: 400 nodes (expected 400) [PASS] 0.00s (800,000 nps)
4 Depth 3: 8902 nodes (expected 8902) [PASS] 0.02s (445,100 nps)

All three depths must pass for confidence in move generation correctness. The perft test also serves as a rough performance benchmark, reporting nodes-per-second throughput.

Running the Game

Start the game with Leiningen or the Clojure CLI tools:

1 $ lein run
2 
3 === Chess Game ===
4 
5 1. Play as White
6 2. Play as Black
7 3. Bot vs Bot
8 
9 Choose mode (1-3):

Select a mode and bot depth (1—6), then enter moves in UCI format (e.g., e2e4, g1f3, e7e8q for promotion). The bot’s thinking output shows the move, evaluation score in centipawns, nodes searched, and elapsed time.

Summary

This chess engine demonstrates how Clojure’s persistent data structures and functional approach can be applied to a high-performance game engine. Key design decisions include:

  • Bit-packed piece encoding for compact, fast piece property tests
  • Precomputed move tables to avoid recomputing move geometry
  • Incremental Zobrist hashing for O(1) position identification
  • Transposition table caching to avoid redundant search of previously visited positions
  • Iterative deepening with alpha-beta for efficient tree traversal with optimal move ordering
  • Quiescence search to prevent horizon-effect blunders in tactical positions

The complete implementation is approximately 1,100 lines of Clojure, demonstrating that you don’t need a large codebase to build a competent chess-playing program.