Experion: Training an NNUE Chess Engine in Crystal
September 30, 2026

Experion is my third chess engine. The first, Cerulean, was written in C back in 2009, when I was writing about bitboards on this blog. The second, CeruleanJS, was JavaScript. Experion is written in Crystal and evaluates positions with a neural network (NNUE) that I trained from scratch. You can play it, or watch it play other engines, in the browser at joeyrobert.github.io/experion/play.

Why Another Engine

Chess engines changed a lot after I stopped writing them. Since 2020 essentially every strong engine has moved from hand-written evaluation functions to NNUE: a small neural network whose first layer is updated incrementally as pieces move, so it's cheap enough to run at millions of nodes per second on a CPU. I'd never built one, and I wanted to understand the whole pipeline end to end: data, training, quantization, inference and search.

Crystal was the obvious choice. It compiles to native code with LLVM and is fast enough for an engine's hot loops, but it reads like Ruby, so the engine stays legible. Experion is about 5,600 lines. There aren't many serious Crystal engines out there, and I didn't know of any with a homegrown NNUE. That's the niche: a readable, compact NNUE engine in a language nobody writes chess engines in, with the training pipeline included rather than borrowed.

The Process

Like Eagle, Experion was built with coding agents, mostly Claude Code with some Cursor. I set the goals and the measuring rules, and the agents did a lot of the implementation and experiment grinding. The single most important lesson was about measurement. Tactical test suites like WAC and STS move by plus or minus five positions from noise alone. Training loss doesn't tell you whether one net plays better than another. The only thing that counts is games: hundreds of them at fast time controls with fastchess, against fixed opponents or against another configuration of the same binary. Every time the project trusted anything else, it went sideways.

The Timeline

August 22nd: the core. The first day produced the parts that never needed rewriting: magic bitboards with constants baked in by a code generator, a fully legal move generator, a copy-make position model (positions are value structs, so there are no undo stacks) and a UCI interface. Move generation is verified against the full TalkChess perft suite I'd originally collected for CeruleanJS and runs at hundreds of millions of nodes per second. The first games against Crafty 25.2 were played that same afternoon. Experion lost all of them.

Late August to early September: classical search. The next two weeks were classic engine work. I added iterative-deepening PVS with a transposition table, null move pruning, late move reductions, futility pruning and razoring, SEE-pruned quiescence and the usual move-ordering heuristics. The biggest win was a bug fix: the tempo bonus was negated the wrong way, so every evaluation with black to move came back from white's point of view. By September 5th Experion had gone from 0 out of 32 against Crafty to about 12%. That was real progress, but still around 340 Elo short.

September 5th to 9th: NNUE, the hard way. The first nets were trained on self-play, which was mostly draws and taught the network nothing. Next came 200k positions from human games on FICS, then more data and bigger nets, about a dozen versions in all. None of them beat the classical evaluator. Along the way we found real bugs. The export code was overflowing int16 output weights by up to 70x, which silently corrupted every net trained before the fix. The trainer was applying both loss terms to every row of a mixed dataset. And there was a training run that collapsed to near-constant output once the dataset passed about 10 million rows, invisible to validation loss the whole time. The only thing that caught it was a sanity check I added that evaluated simple positions like king and two rooks against a bare king every epoch. On the Strategic Test Suite the classical evaluator solved 32% of positions and the best net solved 7%.

September 12th to 18th: a false summit. A width-192 net trained on a mix of CCRL, FICS and engine-labelled data scored 50% against Crafty over 40 games, and it felt like the goal was met. A week later it turned out Crafty wasn't getting fair clocks through the XBoard bridge. With that fixed, the honest numbers were 5 to 18% against Crafty and 21% against Fruit 2.1. That's exactly the kind of mistake a solid measuring harness exists to catch, and the bridge now handles each engine's protocol quirks explicitly.

September 28th and 29th: the breakthrough. The rewrite that worked was ENN5: 768 piece-square inputs times 8 mirrored king buckets, 256 SCReLU hidden units per perspective, 8 output buckets chosen by piece count, and a learned material lane, all trained on positions extracted from CCRL 40/15 games with their engine evaluations as labels. The key insight was embarrassing in hindsight. CCRL's PGN scores aren't consistently from one point of view. Early games are scored from white's side, later games from the side to move, so the old extractors had been feeding the net labels with scrambled signs. The new extractor decides per game (white-POV scores are smooth from ply to ply, while mover-POV scores alternate) and skips games it can't classify. Once the labels were right, the net learned material immediately.

A few more measured results fell out of A/B testing:

  • Weighting the label mostly toward the engine score and only 10% toward the game result beat a 25% result weight by about 90 Elo, and beat 50% by much more.
  • A 256-wide net beat a 512-wide one by about 90 Elo at equal time. Speed mattered more than capacity.
  • Singular extensions, check extensions, recapture extensions and late move pruning each measured as a loss. Turning them all off was worth about 190 Elo in self-play. Strong engines get big gains from these, so Experion's versions are probably wrong, but you ship what measures.
  • Making the NNUE inner loops use Crystal's wrapping operators (&+, &*) let LLVM auto-vectorize them, which was worth 2 to 6x on inference speed.

The result scored 76% against Fruit 2.1 (+200 Elo) and 63% against Crafty with fair clocks (+92 Elo) over 100 games each, with Experion on 4 threads against single-threaded opponents. The net is embedded in the binary, and v0.1.1 ships with builds for Linux, macOS and Windows.

Playing in the Browser

Experion also has a website and a browser player. The engine compiles to WebAssembly, and so does Fruit 2.1. CeruleanJS runs as-is. Each side of the board can be a human or any engine, so you can play them or watch them fight. A small local bridge can also connect the page to native engines on your own machine, including the multi-threaded native Experion and Crafty, which can't be bundled because of its license.

What's Next

Beating Fruit and Crafty was the goal I set at the start, and it's done. There's still obvious Elo on the table. The search extensions that measured as losses are probably buggy rather than useless, and the net is trained on CCRL's mix of engines and time controls rather than on Experion's own search. Both are the standard next steps, and the match harness is ready to tell me whether they actually help.

The source, training pipeline and match tooling are all on GitHub under the MIT license, and the player is at joeyrobert.github.io/experion/play.

chess chess programming crystal experion