Skip to content

About

A chess engine from‑scratch written in C++. It speaks the UCI protocol, searches with an alpha‑beta negamax tree, and evaluates positions with a small quantised NNUE (Efficiently Updatable Neural Network) that is trained offline from the engine's own self‑play games via a PyTorch pipeline.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

3 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

FishBot — a UCI chess engine

Author: Ashish Kumar Nanda

A from‑scratch chess engine written in modern C++20. It speaks the UCI protocol, searches with an alpha‑beta negamax tree, and evaluates positions with a small quantised NNUE (Efficiently Updatable Neural Network) that is trained offline from the engine's own self‑play games via a PyTorch pipeline.


Table of contents


Feature summary

Area What is implemented
Protocol UCI (uci, isready, ucinewgame, position, go, setoption, quit) plus custom perft, bench, eval, datagen commands
Board 12 piece bitboards + 3 occupancy bitboards + a 64‑entry mailbox, 64‑bit Zobrist hash
Attacks Fully populated magic bitboard tables for rooks and bishops; constexpr tables for knights, kings, pawns
Move gen Staged pseudo‑legal generation, legality by "is my king attacked after the move"
Search Iterative deepening, negamax alpha‑beta, quiescence, null‑move pruning, late move reductions (LMR), principal‑variation style root
Ordering TT move → MVV‑LVA + SEE for captures → killers → history heuristic
Table 64 MB always‑replace transposition table with mate‑score adjustment
Eval (768→512)×2 → 1 perspective NNUE, int16‑quantised, incrementally updated; PeSTO piece‑square tables as fallback when no net is loaded
Training C++ self‑play data generator + PyTorch trainer + NumPy quantiser, with a Python reference forward pass for bit‑exact parity checks

Technology stack

Engine (*.cpp / *.h)

  • C++20. Leans on <bit> intrinsics (std::countr_zero, std::popcount, std::countr_zero for bit iteration), constexpr immediately‑invoked lambdas to build attack tables at compile time, std::array, structured bindings, std::clamp/std::ranges.
  • CMake ≥ 4.3 as the build system. Release builds enable interprocedural optimisation (LTO), -funroll-loops, and -mcpu=apple-m1 when the compiler supports it (the engine was developed on Apple Silicon).
  • UCI as the only external interface — it runs under any UCI GUI (Cute Chess, BanksiaGUI, Arena, en‑croissant, …) and any match runner.
  • No third‑party libraries. Everything (bitboards, magics, hashing, search, the NNUE inference kernel) is hand‑written.

Training (train/*.py)

  • Python 3 with PyTorch (nn.EmbeddingBag for the feature transformer, AdamW, cosine LR schedule) and NumPy (memmap streaming of the dataset, vectorised feature decoding, quantisation).
  • MPS / CUDA / CPU device auto‑selection.

Interchange formats

  • data*.bin — packed 32‑byte DataEntry records written by the engine, read back with a matching np.dtype.
  • net.bin — a flat little‑endian dump of the quantised network (789,508 bytes: 768×512 + 512 + 2×512 int16 weights + one int32 bias).

Build

cmake -S . -B build-release -DCMAKE_BUILD_TYPE=Release
cmake --build build-release -j

The binary is build-release/chessengine.

Optional CMake switch:

  • -DNNUE_VERIFY=ON — after every incremental accumulator update, recompute the accumulator from scratch and assert they match. Very slow; used only to debug acc_push.

Running the engine

Point a UCI GUI at the chessengine binary, or drive it by hand:

uci
position startpos moves e2e4 e7e5
go movetime 2000

The engine also understands a few non‑UCI helpers:

Command Effect
perft Prints perft node counts for depths 0–5 from the current position (move‑generator correctness / speed check)
bench [depth] Searches a fixed 8‑position suite to a fixed depth and prints total nodes + NPS. The node count is deterministic and is the standard "did I change search behaviour?" signal
eval Refreshes the accumulator and prints the static evaluation of the current position
datagen [games] [nodes] [out] [seed] Runs self‑play and writes a training dataset (see below)

setoption name EvalFile value <path> loads a different net.bin at runtime; net.bin is loaded automatically at startup if present.


Web frontend

frontend/ is a self‑contained dashboard for the whole project — play the engine on a real board, watch the search live, and run bench / perft / datagen from buttons. It needs Node ≥ 18 and no npm install (Node standard library only; the board widget and rules library load from a CDN on first visit).

cd frontend
npm start           # or: node server.js   — then open http://localhost:5173

server.js is a ~200‑line bridge: it spawns the compiled chessengine (with the repo root as its working directory, so net.bin is found), streams the engine's stdout to the browser over Server‑Sent Events, and forwards browser commands back as POST /api/send. It can also invoke cmake to (re)build the engine — the Build engine button in the UI — so a fresh checkout can go from nothing to playing without touching the terminal again.

Panel What it does
Board Drag‑to‑move (chess.js enforces legality), pick your colour, adjustable engine think time / depth / nodes, eval bar, take‑back, FEN load/copy, board flip
Search Live depth / score / nodes / pv, plus an Analyze toggle that runs go infinite on the current position
Console The raw UCI conversation, an input box for arbitrary commands, and one‑click uci / isready / ucinewgame / eval / stop
Bench Runs bench <depth> and shows the deterministic node total + NPS
Perft Runs perft (depths 0–5) from the position on the board
Datagen Kicks off self‑play data generation and streams progress; training itself stays a documented CLI step

The binary is auto‑located at build-release/chessengine (and a few other common paths); override with ENGINE=/path/to/chessengine node server.js, or the port with PORT=8080.


How it works under the hood

main.cpp initialises the subsystems in order — init_magics() (fill the sliding‑attack tables), init_zobrist() (seed the hash keys), tt_init() (allocate the transposition table), nnue_init_random() then nnue_load("net.bin") — and then runs a line‑oriented UCI loop over stdin.

1. Board representation

struct Board (board.h) is a hybrid:

  • Bitboard piece_bitboards[12] — one 64‑bit word per (colour, piece‑type), bit i set means "a piece of that kind stands on square i" with a1 = 0 … h8 = 63.
  • Bitboard occupancy_bitboards[3] — white, black, and both, kept as the union of the piece boards so attack lookups have an "all blockers" mask ready.
  • Colored_Piece mailbox[64] — the reverse map, square → piece, so "what is on this square?" is O(1) (needed constantly by make/unmake, SEE, and move ordering).
  • Scalars for side to move, castling rights (4 bits), en‑passant square, half‑move (fifty‑move) clock, full‑move number, and the running Zobrist hash.

put_piece / remove_piece update all four structures and the Zobrist hash together, so the three views never drift apart.

FEN parsing (Board::set_from_fen) fills the mailbox and bitboards and then calls compute_hash_from_scratch() once to seed the hash.

2. Attack generation — magic bitboards

Leaper attacks (knight, king, pawn) are plain 64‑entry lookup tables built at compile time by constexpr lambdas in attacks.h.

Slider attacks (rook, bishop, queen) use magic bitboards — perfect hashing of blocker configurations:

  1. For each square, relevant_occupancy_*_mask computes the "inner" ray mask (the squares whose occupancy actually changes the attack set — board edges excluded).
  2. init_magics() enumerates every subset of that mask (the classic subset = (subset - mask) & mask carry trick), computes the true attack set with the slow ray‑walker (slow_rook_attacks / slow_bishop_attacks), and stores it at index (blockers * magic) >> shift in rook_table / bishop_table. The pre‑computed rook_magics / bishop_magics constants are chosen so this mapping is collision‑free; init_magics asserts that (prints BIG CRASH and exits if a magic is bad).
  3. At runtime rook_attacks(sq, occ) is just occ &= mask; occ *= magic; occ >>= shift; return table[sq][occ]; — a few ALU ops and one cache line.

Queen attacks are rook_attacks | bishop_attacks.

Board::attackers_to(sq, occ) and Board::is_square_attacked(sq, by) build on these to answer "which pieces hit this square" — the core primitive for check detection, legality, and SEE.

3. Move generation

generate_moves (movegen.cpp) is staged and pseudo‑legal: separate generate_*_moves functions push knight, king, rook, bishop, queen, pawn, and castling moves into a fixed MoveList (Move moves[256]).

A Move is a packed uint16_t (moves.h): bits 0–5 from‑square, 6–11 to‑square, 12–15 a flag nibble encoding quiet / double‑push / castle / capture / en‑passant / the four promotion types (and their capturing variants). Helpers from_square, to_square, flags, is_capture, is_promotion, promotion_of, and move_to_string (for UCI output) unpack it.

Legality is not enforced during generation. Instead a move is played and then Board::last_move_was_legal() checks whether the side that just moved left its own king attacked (is_square_attacked(enemy_king_square(), color_to_move) — "enemy" because the side already flipped inside make_move). Illegal moves are simply unmade and skipped. Castling generation additionally checks the in‑between squares are empty and not attacked.

generate_captures is a captures/promotions‑only variant used by the quiescence search.

perft(board, depth) is the standard recursive move‑count used to validate the generator against known reference numbers.

4. Make / unmake and Zobrist hashing

Board::make_move(m, u) mutates the board in place and records everything it destroyed into an Undo u (captured piece, castling rights, en‑passant square, fifty‑move clock, full‑move number, previous hash). It handles the awkward cases: en‑passant removes a pawn behind the target, castling moves the rook, promotion swaps the piece type, double‑push sets the new en‑passant square, and a rights_mask table clears castling rights whenever a king/rook square is touched. unmake_move(m, u) reverses it exactly.

make_null_move / unmake_null_move flip only the side to move and clear en‑passant — used for null‑move pruning.

Zobrist hashing (zobrist.cpp): a fixed‑seed std::mt19937_64 fills random 64‑bit keys for every (piece, square), for side‑to‑move, for each castling‑rights combination, and for each en‑passant file. Every put_piece / remove_piece and every state change in make_move XORs the relevant keys in and out, so the 64‑bit hash is maintained incrementally. It is the transposition‑table key and the repetition‑detection key. compute_hash_from_scratch() exists to seed it after FEN parsing (and to verify it in debug builds).

5. Search

search() in search.cpp runs iterative deepening from depth 1 up to the limit, calling search_root for each depth and keeping the best move from the last completed iteration (a partially searched deeper iteration is discarded if it was aborted by the clock).

The recursion is negamax alpha‑beta (minimax()), i.e. one function that always maximises from the side‑to‑move's point of view with score = -minimax(..., -beta, -alpha, ...). On top of plain alpha‑beta:

  • Draw detection — fifty‑move rule, and repetition by scanning the g_history hash stack back to the last irreversible move.
  • TT probe — an exact/>=beta/<=alpha cutoff, or at least a best‑move hint for ordering (see §7).
  • Quiescence at the horizon — depth <= 0 drops into quiescence(), which stands pat on the static eval and then searches only captures/promotions (all evasions when in check), pruning captures with negative SEE. This resolves tactical noise so the leaf eval is of a "quiet" position.
  • Null‑move pruning — if not in check and the side has non‑pawn material, play a null move and search to depth - 3 with a null window; a >= beta result prunes the node.
  • Late move reductions — moves after the 4th, at depth >= 3, that are quiet, not checks, and not check evasions are first searched one ply shallower; only if that scrapes above alpha are they re‑searched at full depth.
  • Mate scores are stored as CHECKMATE_SCORE - ply so shorter mates outrank longer ones, and are translated back to UCI mate N at the root.

g_nodes counts nodes; check_time() polls the clock every 2048 nodes and also enforces an optional hard node limit (used by datagen and bench).

6. Move ordering

Good ordering is what makes alpha‑beta fast. score_moves() assigns each move a sort key (then a selection sort pulls the best move to the front on each iteration — cheaper than a full sort when a cutoff is likely):

  1. TT move — the best move from a previous search of this position, if any.
  2. Captures / promotions — scored by MVV‑LVA (mvv_score_of: most‑valuable‑victim, least‑valuable‑attacker) and filtered by SEE (see() — static exchange evaluation: simulate the full capture sequence on the target square, always recapturing with the cheapest attacker, to decide whether the capture actually wins material). Losing captures are pushed below quiets.
  3. Killer moves — two quiet moves per ply that recently caused a beta cutoff.
  4. History heuristic — a [colour][from][to] table incremented by depth² on every quiet cutoff, halved when it overflows.

7. Transposition table

transposition.cpp — a flat std::vector<TTEntry> of 2²² entries (~64 MB), indexed by hash & (size - 1), always‑replace. Each entry stores the key, best move, score, depth, and a bound flag (EXACT / LOWER / UPPER).

tt_probe returns a usable score only if the stored depth is sufficient and the bound is compatible with the current (alpha, beta) window; otherwise it still hands back the stored move for ordering. Mate scores are stored relative to the entry (score ± ply) and rebased on probe so they stay correct at any depth.

8. Evaluation — NNUE

When a network is loaded, eval() calls nnue_eval(); otherwise it falls back to the classical evaluation.

The architecture (nnue.h) is a standard perspective network:

768 binary features ──┐
                      ├─(per side)─▶ 512 accumulator ─clamp[0,255]─┐
768 binary features ──┘                                            ├─▶ Σ ─▶ 1 (centipawns)
                                    (us ‖ them) → 1024 → dot(out_weights) + out_bias
  • Feature space — 768 = 2 colours × 6 piece types × 64 squares (feature_index). Features are always computed from the mover's perspective: for Black the board is mirrored vertically (square ^ 56) and the colours are swapped, so a single weight set covers both sides.
  • Feature transformer — ft_weights[768][512] + ft_bias[512]. The accumulator for one side is ft_bias + Σ ft_weights[active feature]. Two accumulators are kept, one per perspective.
  • Output layer — concatenate [us, them] (1024), clamp each element to [0, QA] (this is the "clipped ReLU" activation, folded into the read), dot with out_weights[1024], add out_bias.
  • Quantisation — QA = 255 scales the feature‑transformer domain, QB = 64 scales the output weights, NNUE_SCALE = 400 converts the final integer back to centipawns: (Σ + out_bias) * 400 / (255 * 64). All inference is integer (int16 weights, int32 accumulation) — no floats in the hot path.
  • Loading — nnue_load reads the flat net.bin, checks the size is exactly the expected byte count, and only then swaps it in.

9. Incremental accumulator updates

Recomputing 512 sums over ~32 pieces at every node would dominate the search. Instead the engine keeps a stack of accumulators (acc_stack, indexed by acc_idx) that mirrors the search's current path:

  • acc_refresh builds an accumulator from scratch — done once per search root (and by the eval command).
  • acc_push(board, move, undo) copies the parent accumulator and applies just the delta for the move: acc_sub the moved piece from its origin, acc_add it at its destination, acc_sub any captured piece, and move the rook for castling. Promotions subtract a pawn and add the promoted piece. Each acc_add / acc_sub touches 512 × 2 int16 lanes — a handful of SIMD‑width loops.
  • acc_push_null / acc_pop handle null moves and unwinding.

eval() always reads acc_stack[acc_idx], which the search is responsible for keeping in lock‑step with the board. Building with -DNNUE_VERIFY=ON asserts this invariant against a full refresh at every node.

10. Classical fallback evaluation

If no net is loaded (nnue_enabled() == false), pst_eval (eval.cpp) is a tapered PeSTO evaluation: material plus piece‑square‑table bonuses, with separate middlegame and endgame tables (Ronald Friederich's RofChade/PeSTO values in eval.h) interpolated by a "game phase" count derived from the non‑pawn material on the board. It is side‑relative and returned in centipawns. This keeps the engine playable (and testable) before a network exists — the first data‑generation run uses it.

11. Time management

search() translates the UCI go limits into a soft and a hard millisecond budget:

  • movetime → both budgets are movetime - 30 ms.
  • wtime/btime/winc/binc → soft ≈ time/25 + inc/2, hard min(time/4, time-50).
  • depth / nodes / infinite are honoured directly.

check_time() enforces the hard limit mid‑search (every 2048 nodes); after each completed depth, if more than half the soft budget is already gone, iterative deepening stops rather than start a depth it cannot finish.


The training pipeline

The network is not hand‑tuned — it is learned from the engine's own games. The full loop is: play games → dump positions → train in PyTorch → quantise to net.bin → load back into the engine → repeat with the stronger net. The repo ships several checkpoints of that loop: net_gen1.bin, net_gen2.bin, net_all.bin, and the active net.bin.

Data generation (datagen.cpp)

datagen <games> <nodes> <out> <seed> plays self‑play games with a tiny fixed node budget per move (so it is fast and deterministic):

  • Each game starts from the initial position, plays RANDOM_PLIES = 8 random legal moves for opening variety, then rejects the game if a quick search says the position is already lopsided (|score| > 1000).
  • The engine then plays itself. After each move it records the position unless the side to move is in check, the best move is a capture/promotion, or the score is near‑mate — i.e. it only keeps quiet positions where a static eval is meaningful.
  • Games are adjudicated: a |score| > 2000 sustained for 4 plies is a resignation; the fifty‑move rule, bare kings, and threefold end the game as a draw.
  • Every stored position from a game is stamped with that game's result from the side‑to‑move's point of view (0 loss / 1 draw / 2 win), buffered, and flushed to disk in 64 K‑record chunks.

The binary data format

struct DataEntry (datagen.h) is exactly 32 bytes, little‑endian:

Field Bytes Meaning
occ 8 occupancy bitboard (which squares hold a piece)
pieces 16 one nibble per occupied square, LSB‑first in occ order, piece_type | (color << 3)
score 2 engine search score (centipawns, side‑to‑move relative)
result 1 game result from side‑to‑move POV: 0 / 1 / 2
stm 1 side to move
pad 4 zero

train/data.py declares a matching np.dtype and np.memmaps the file, so gigabytes of positions stream without being loaded into RAM. decode() vectorises the unpack: np.unpackbits the occupancy, gather the nibbles, turn each (colour, type, square) into the same feature_index the engine uses, and emit us / them feature arrays padded to 32 entries with PAD = 768 (the EmbeddingBag padding index).

Training the network (train/train.py)

cd train
python train.py path/to/data.bin --out ../net.bin --epochs 30 --batch 16384
  • The model mirrors the engine exactly: nn.EmbeddingBag(769, 512, mode="sum") is the feature transformer (index 768 is the zero‑weight pad), a shared ft_bias, clamp(cat[us, them], 0, 1) activation, nn.Linear(1024, 1) output.
  • Target — a blend of evaluation and game result: lam * sigmoid(score / 400) + (1 - lam) * wdl, with wdl = result / 2. Loss is MSE on the sigmoid of the model output (i.e. train in win‑probability space). --lam trades off "predict the search score" vs "predict who won".
  • Optimiser — AdamW + cosine LR annealing, device is MPS → CUDA → CPU.
  • After every optimiser step the feature‑transformer weights are clamped to ±1.98 — because at inference up to ~32 of them are summed into one int16 accumulator lane and must not overflow once scaled by QA = 255.
  • The net is re‑exported to net.bin after every epoch.

Quantisation and export (train/quant.py)

write_net converts the float tensors to the engine's fixed‑point layout:

  • ft, ft_bias → round(w * 255) as int16
  • out_weights → round(w * 64) as int16
  • out_bias → round(b * 255 * 64) as int32
  • asserts nothing saturates int16 (a diverged run would), then writes the four blocks back‑to‑back, little‑endian — the 789,508‑byte file the engine reads.

Verifying parity (train/check.py)

quant.py::evaluate is a pure‑NumPy re‑implementation of nnue_eval, including the C‑style truncating division (handled by taking abs then re‑signing). Run

python train/check.py net.bin "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"

and it should print the same number as the engine's eval command for that FEN — a bit‑exact check that the exported net and the C++ inference kernel agree.


Source map

File Responsibility
main.cpp Startup, UCI command loop
types.h Squares, pieces, colours, castling flags, START_FEN, MAX_PLY
bitboard.h File/rank constants, print_bitboard, shift helpers
board.{h,cpp} Board struct, FEN parsing, make/unmake, null moves, attack queries, hashing
moves.h Packed Move encoding + accessors, MoveList, move_to_string
attacks.{h,cpp} Leaper tables, magic‑bitboard generation and lookups, SEE‑support attack sets
movegen.{h,cpp} Staged pseudo‑legal generation, generate_captures, perft
zobrist.{h,cpp} Zobrist key tables and seeding
eval.{h,cpp} eval() dispatch, PeSTO tapered fallback, MVV‑LVA, SEE
nnue.{h,cpp} Network struct, feature indexing, accumulator add/sub/refresh/push, integer inference, net.bin loader
search.{h,cpp} Iterative deepening, negamax alpha‑beta, quiescence, pruning, ordering, time control
transposition.{h,cpp} Transposition table probe/store
uci.{h,cpp} position / go / setoption parsing, bench
datagen.{h,cpp} Self‑play dataset generation, DataEntry packing
train/train.py PyTorch NNUE trainer
train/data.py memmap dataset reader + vectorised feature decode
train/quant.py Quantise/export/read net.bin, NumPy reference forward pass
train/check.py FEN → eval parity checker
frontend/server.js Zero‑dependency Node bridge: spawns the engine, SSE stream, POST /api/send, CMake build
frontend/public/ The dashboard — index.html, style.css, app.js (board + UCI wiring)
net*.bin Quantised network checkpoints (net.bin is the one loaded by default)
CMakeLists.txt Build configuration (C++20, LTO in Release, optional NNUE_VERIFY)

ChessEngine

About

A chess engine from‑scratch written in C++. It speaks the UCI protocol, searches with an alpha‑beta negamax tree, and evaluates positions with a small quantised NNUE (Efficiently Updatable Neural Network) that is trained offline from the engine's own self‑play games via a PyTorch pipeline.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages