For the past months I have been building an engine for Turkish draughts (Dama), the variant played across Türkiye and the Middle East, where pieces move orthogonally instead of diagonally and captures are compulsory and maximal. It is a beautiful game with real tactical depth, and it has almost no serious engine tradition. No Stockfish, no open tablebases, no training data, no established test suites. If I wanted any of that, I had to build it.
This post is the story of that stack: a C++ engine, a neural network evaluation trained on hundreds of millions of self-play positions, endgame tablebases, an opening book distilled from human games, a desktop analysis GUI, and mobile apps. Mostly, though, it is a story about the things that went wrong along the way, because those turned out to be the interesting parts.
The classical engine
The core is a fairly traditional alpha-beta engine written in C++ from scratch: bitboard move generation, a transposition table, iterative deepening with aspiration windows, late move reductions, and lazy SMP for parallel search. It speaks UCI, so it plugs into standard tooling.
Turkish draughts punishes sloppy move generation more than chess does. Captures are compulsory, capture chains must be maximal, and a flying king can produce long branching capture sequences that revisit squares. My generator had a subtle bug where flying-king chains emitted one move per capture path instead of per distinct outcome, which silently overflowed an internal move limit and dropped legal moves in rare positions. The fix was a deduplication on (origin, destination, captured set), and the perft numbers finally matched a slow reference implementation exactly.
That experience set the tone for the whole project: in a domain with no reference engines to compare against, correctness has to be manufactured. Three things do that work here:
- Perft parity. Node counts for the move generator are pinned at multiple depths and checked in CI-style test runs.
- A second, independent rules implementation. The desktop GUI contains a TypeScript port of the move generator, and a test compares it against the C++ engine as a perft divide: node counts and move texts must match exactly, every run. Two implementations agreeing by accident is much less likely than one implementation being quietly wrong.
- Pinned regression positions. A suite of tactical positions where the engine once blundered in real games, each with a known correct move. Every search change runs against them. Several times a change that looked like a clear improvement on paper was rejected because it reintroduced an old blunder.
There are also endgame tablebases, probed during search with win/draw/loss and distance metrics. Tablebases interact with search in surprisingly sharp ways: at one point a decided-but-distanceless tablebase score formed a flat band in the evaluation, and the search happily sacrificed three pieces to "touch" the winning class as early as possible, then shuffled forever inside it. The rule that fixed it is worth stating generally: a tablebase verdict without a distance metric must not produce a search cutoff. Greed needs proof of progress.
The neural network
The evaluation is an NNUE, the efficiently updatable network architecture that modern chess engines use. Mine is small: 256 input features per perspective (four piece types across the board, mirrored for the side to move), a feature transformer into a 512-wide accumulator, and a short quantized tail with material-based output buckets. Everything runs in int8 and int16 with the standard Stockfish-style quantization scheme.
The speed side of NNUE is where the fun engineering lives. The inference has hand-written SIMD paths for NEON, SSSE3 and AVX-512, plus a scalar fallback, and all of them are verified bit-exact against each other. More importantly, the C++ inference is verified bit-exact against the PyTorch trainer, down to the last integer, over thousands of positions. This parity harness caught real bugs that nothing else would have caught, including one where a build flag mismatch made the engine accept a network file and then feed it the wrong feature layout. Every evaluation was garbage, and every internal self-check still passed, because incremental and full refresh were equally wrong. Only the cross-language parity test noticed. Self-consistency is not correctness.
The training data, and the bug that poisoned everything
The training corpus is roughly 466 million quiet positions from self-play, labeled with search scores and game results, generated on a 360-core server. Before this corpus existed there were earlier attempts at training a network for this engine, and they all failed in a way I could not explain at the time.
The cause turned out to be a single wrong sentence in a docstring. The data dump format stored scores and results from the side to move's perspective, but the documentation claimed white's perspective, so the training pipeline flipped half the labels. A network can survive a lot of noise, but not half its labels being negated. The way I finally proved it was statistical: in clearly decided positions, the correlation between side-to-move material balance and the stored score was +0.983, which is only possible if the labels are side-to-move relative. I now treat label conventions as something to verify empirically, never something to read.
What did not work
This section is the reason I wanted to write the post. The successful parts of the project mostly follow well-known recipes. The failures were educational.
Validation loss does not predict playing strength
This is folklore in the chess engine world, and I got to verify it five separate times. I trained network generations with clearly better validation loss that played measurably worse, and networks with mediocre loss that fixed real tactical blindness. One wide model reached the best validation loss of the entire project and could not beat its predecessor at all.
The only measurement that ever settled a question was a match: hundreds of games, both fixed-node and timed, with confidence intervals, and the discipline to not interpret anything before roughly 300 games. Early match results are a slot machine. I watched a +80 Elo signal decay to +26 and a +28 decay to +8 as the games accumulated.
A pure neural evaluation loses
The first trained network, evaluated head to head as a pure replacement for the handcrafted evaluation, lost by about 217 Elo despite being clearly more accurate on static test positions. The search exploits the network's tactical overconfidence: alpha-beta is an adversarial process that actively seeks out the regions where the evaluation is wrong.
The fix was pedestrian and effective: blend the network with the handcrafted evaluation. The blended engine beat the classical one by over +130 Elo. I retested the pure network with every later generation, and the pathology never went away, only shrank. The classical evaluation is not dead weight; it is an anchor.
The 13 plies that were not real
At one point I was worried about search depth. The engine reported depth 17 to 21 in the middlegame while an older build had shown 30+, and a rival program displayed deeper numbers too. I measured carefully and found that tactical safety guards in the pruning (exemptions for quiet moves that threaten captures) were costing about 13 plies of nominal depth.
So I built dials to relax those guards, recovered the depth, and ran matches. Four different configurations, about 290 games each, against the baseline: -12, +8, +8, -5. All zero within noise. The recovered depth was hollow. It was obtained by pruning away real moves, and the number in the UI went up while the strength stayed flat.
Two lessons came out of this. First, nominal depth is not comparable between engines, or even between configurations of the same engine, because it is mostly a function of pruning aggressiveness. Selective depth is more honest, and matches are the only truth. Second, when an evaluation cannot see a class of tactics statically, the search must pay a real cost to compensate, and that cost is structural. Which leads to the most interesting technical result of the project.
A linear accumulator cannot represent a threat
The engine had a family of recurring blunders: quiet moves that lose material to a forced capture sequence two or three plies later. More data did not fix it. Bigger networks did not fix it. A 1024-wide accumulator with twice the parameters reached beautiful validation loss and made the same blunders.
The reason is architectural. The NNUE accumulator is a linear sum of per-feature weights. "My piece is on square A" contributes a vector, "an enemy man is on square B" contributes a vector, and they add. But "my piece on A is capturable" is a product of conditions: it depends on the enemy piece, the empty landing square, and the capture geometry all being true at once. A linear layer cannot represent a product of inputs, and one hidden layer past the bottleneck is apparently not enough to reconstruct it reliably from 466 million examples. This is, I later learned, exactly why top chess engines feed explicit threat features into their networks rather than hoping the network derives them.
So I computed the threats outside the network and fed them in as inputs: for each square, whether the piece on it can be captured by an enemy man, whether by an enemy king, whether it is locked nose to nose with an opponent, plus a few hanging-piece counters. The capture geometry is computed with Kogge-Stone style fills in the training pipeline and ray walks in the engine, proven equivalent. These features cost almost nothing at inference time, and they cured the blunder family that no amount of data had touched. A pinned position where every previous network confidently played a losing move was passed for the first time.
The general principle: when a model architecturally cannot represent something, do not scale, reparameterize. The cheap axis in NNUE is input features, and the expensive axis is accumulator width. Spend on the cheap axis.
An honest list of measurement traps
I keep a checklist of ways I fooled myself during this project, all of them field-tested, some more than once in a single night:
- The silent fallback. If the engine rejects a network file (architecture mismatch), it falls back to the handcrafted evaluation and keeps running. Node rates look two to three times "faster" and you conclude your optimization worked. I now grep for the "network loaded" log line before believing any benchmark.
- Benchmarking a loaded machine. The same binary measured between 505K and 890K nodes per second on a server that was also running training jobs. Any comparison made that way is garbage.
go depth Nthat never gets there. A slow binary that times out mid-iteration reports a best move from an unfinished search, and you conclude the network is tactically blind when the binary is just slow. Verify the last reported depth actually reached the target.- Best-of-three timing. Time-to-depth variance in this engine is around ±50 percent. Taking the best of three runs is noise mining, and I once reported a 1.7x speedup that evaporated under twelve repetitions. Twelve-run medians or full matches, nothing less.
- Comparing validation losses across datasets. A network trained on better labels can look flat on a validation set built from the old labels, because what you are measuring is agreement with the old teacher, not quality.
What hundreds of cores actually buy you
The 360-core server made data generation fast, and it also taught me things about parallel search that are invisible on a laptop.
The most striking one: the engine got weaker as threads increased. At 96+ threads the final move choice would occasionally flip from a depth-19 anchor move to a depth-15 move backed by a crowd of shallow helper threads. The voting formula that combines results across threads weighted votes by score but not by depth, so a shallow majority could outvote a deep minority, and adding cores amplified the mob. The fix was one multiplication, weighting each vote by the depth that produced it, and the instability disappeared.
At 350 threads, a global mutex in the tablebase prober collapsed the whole machine into kernel futex contention: 70 to 90 percent system time, node rate down to a seventh. Thread-local file handle caches fixed it. And even with all of that solved, the effective branching factor of a well-pruned engine (about 1.75 here) means each additional ply costs 1.75x the nodes, so 350 cores buy perhaps five or six extra plies over one core, not the twenty you might hope for.
The counterpoint, and my favorite measurement of the project: strength no longer lives in the hardware. A mate in 12 that decided a serious game was found by the engine on my laptop in 432 thousand nodes, 33 milliseconds. The network's pruning guidance found it, not brute force. That single data point reshaped the roadmap: the goal now is a phone playing at server strength, because a few million nodes per second is already beyond what any human can punish.
Openings, draws, and a 138-year-old book
Two side quests deserve a mention.
First, draws. I scanned 145 thousand human games with the engine's own move generator, weighting by rating, to find out where draws come from. The answer was unambiguous: draws are a long-game phenomenon driven by closed, contactless openings. Games with no capture in the first 20 plies drew at roughly double the rate of games with early exchanges. Some popular opening lines drag both players into 120-ply king shuffles that end at nearly 39 percent draws. So I reweighted the engine's opening book by measured decisiveness, teaching it to prefer lines that seek contact. One implementation note that may save someone pain: opening books must be keyed by position hash, not by move sequence, because transpositions break prefix matching in draughts constantly.
Second, the classics. I built a pipeline that converts scanned Turkish draughts puzzle books into machine-checkable PDN, then had the engine verify every combination. Across three books, roughly 20 percent of the published combinations are unsound: at some node the defender has a materially better reply than the one the book plays, meaning the combination only works with the defender's cooperation. The charming result is that the cleanest of the three books, at 83 percent sound, was published in 1888 in Ottoman Turkish, beating both modern books. Someone was doing very careful analysis by hand 138 years ago.
Shipping it
The engine now lives in three places. A desktop analysis GUI (Electron and React, with the TypeScript rules engine kept in strict parity with the C++ core, in Turkish, English and Arabic, which meant a proper education in right-to-left typography and bidirectional text). Mobile apps with the network embedded directly in the binary, running at millions of nodes per second on a phone. And the bare UCI engine that ties the whole test infrastructure together.
None of this used a framework, because none existed for this game. That was the appeal. A niche with no prior art means every convention has to be verified rather than assumed, every number has to be earned rather than looked up, and every bug is yours alone. I can recommend it.