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.
- Feature summary
- Technology stack
- Build
- Running the engine
- Web frontend
- How it works under the hood
- The training pipeline
- Source map
| 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 |
Engine (*.cpp / *.h)
- C++20. Leans on
<bit>intrinsics (std::countr_zero,std::popcount,std::countr_zerofor bit iteration),constexprimmediately‑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-m1when 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.EmbeddingBagfor the feature transformer,AdamW, cosine LR schedule) and NumPy (memmapstreaming of the dataset, vectorised feature decoding, quantisation). - MPS / CUDA / CPU device auto‑selection.
Interchange formats
data*.bin— packed 32‑byteDataEntryrecords written by the engine, read back with a matchingnp.dtype.net.bin— a flat little‑endian dump of the quantised network (789,508 bytes:768×512+512+2×512int16 weights + one int32 bias).
cmake -S . -B build-release -DCMAKE_BUILD_TYPE=Release
cmake --build build-release -jThe binary is build-release/chessengine.
Optional CMake switch:
-DNNUE_VERIFY=ON— after every incremental accumulator update, recompute the accumulator from scratch andassertthey match. Very slow; used only to debugacc_push.
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.
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:5173server.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.
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.
struct Board (board.h) is a hybrid:
Bitboard piece_bitboards[12]— one 64‑bit word per (colour, piece‑type), bitiset means "a piece of that kind stands on squarei" witha1 = 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.
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:
- For each square,
relevant_occupancy_*_maskcomputes the "inner" ray mask (the squares whose occupancy actually changes the attack set — board edges excluded). init_magics()enumerates every subset of that mask (the classicsubset = (subset - mask) & maskcarry trick), computes the true attack set with the slow ray‑walker (slow_rook_attacks/slow_bishop_attacks), and stores it at index(blockers * magic) >> shiftinrook_table/bishop_table. The pre‑computedrook_magics/bishop_magicsconstants are chosen so this mapping is collision‑free;init_magicsasserts that (printsBIG CRASHand exits if a magic is bad).- At runtime
rook_attacks(sq, occ)is justocc &= 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.
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.
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).
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_historyhash stack back to the last irreversible move. - TT probe — an exact/
>=beta/<=alphacutoff, or at least a best‑move hint for ordering (see §7). - Quiescence at the horizon —
depth <= 0drops intoquiescence(), 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 - 3with a null window; a>= betaresult 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 - plyso shorter mates outrank longer ones, and are translated back to UCImate Nat 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).
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):
- TT move — the best move from a previous search of this position, if any.
- 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. - Killer moves — two quiet moves per ply that recently caused a beta cutoff.
- History heuristic — a
[colour][from][to]table incremented bydepth²on every quiet cutoff, halved when it overflows.
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.
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 isft_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 without_weights[1024], addout_bias. - Quantisation —
QA = 255scales the feature‑transformer domain,QB = 64scales the output weights,NNUE_SCALE = 400converts the final integer back to centipawns:(Σ + out_bias) * 400 / (255 * 64). All inference is integer (int16weights,int32accumulation) — no floats in the hot path. - Loading —
nnue_loadreads the flatnet.bin, checks the size is exactly the expected byte count, and only then swaps it in.
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_refreshbuilds an accumulator from scratch — done once per search root (and by theevalcommand).acc_push(board, move, undo)copies the parent accumulator and applies just the delta for the move:acc_subthe moved piece from its origin,acc_addit at its destination,acc_subany captured piece, and move the rook for castling. Promotions subtract a pawn and add the promoted piece. Eachacc_add/acc_subtouches512 × 2int16 lanes — a handful of SIMD‑width loops.acc_push_null/acc_pophandle 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.
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.
search() translates the UCI go limits into a soft and a hard millisecond
budget:
movetime→ both budgets aremovetime - 30 ms.wtime/btime/winc/binc→ soft≈ time/25 + inc/2, hardmin(time/4, time-50).depth/nodes/infiniteare 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 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.
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 = 8random 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| > 2000sustained 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 (
0loss /1draw /2win), buffered, and flushed to disk in 64 K‑record chunks.
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).
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 sharedft_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, withwdl = result / 2. Loss is MSE on the sigmoid of the model output (i.e. train in win‑probability space).--lamtrades 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 oneint16accumulator lane and must not overflow once scaled byQA = 255. - The net is re‑exported to
net.binafter every epoch.
write_net converts the float tensors to the engine's fixed‑point layout:
ft,ft_bias→round(w * 255)asint16out_weights→round(w * 64)asint16out_bias→round(b * 255 * 64)asint32- 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.
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.
| 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) |