SlopFish is a fork of Stockfish (GPL-3.0) for a chess variant with one extra rule: if a capture is legal, you must capture. The rule sounds small. It turned out to be a good stress test, because a strong engine is a pile of assumptions about chess, and a forced-capture rule quietly breaks several of them.
Where it started
SlopFish began as my project for CO 456, the game theory course at Waterloo. Forced-capture chess is a natural fit for the course: it is a finite, perfect-information game whose rules differ from chess by exactly one constraint, so you can ask precisely which parts of a chess engine's reasoning survive the change. The project placed top 4 in the class.
Before touching the code I read the three-part "Dissecting Stockfish" series, which walks through how the engine works from the inside. Links are at the end of this post.
The code is at https://github.com/rick-mingyu-liu/Slopfish, and the network training lives in a separate repo at https://github.com/rick-mingyu-liu/NNUE_Train, based on nnue-pytorch.
Where the rule has to live
The first instinct is to enforce the rule at the root: when the engine picks a move to play, filter out non-captures if any capture exists. That is not enough. The search explores a tree, and every node in that tree is a position where the side to move is also bound by the rule. If the search lets a side decline a capture three plies deep, it evaluates lines that can never happen in a real game.
So the constraint has to hold at every node. In practice that reached into four places: move generation, move ordering, check evasions, and the quiescence search. Each of them had its own idea of what a legal move list looks like, and each needed to agree on one definition.
Quiescence and stand-pat
Quiescence search is where the rule hurt most. In standard chess, the quiescence search lets the side to move "stand pat": it can take the static evaluation as a lower bound, on the grounds that it could always decline to capture and just play some quiet move. Alpha-beta pruning leans on that bound.
With forced captures, that reasoning fails. If a capture is available, the side to move cannot decline it, so the static evaluation is no longer a valid lower bound. I disabled stand-pat pruning entirely. That makes the quiescence search more expensive, but anything cheaper was unsound.
Training a network for a different game
Stockfish's default NNUE was trained on regular chess, and its evaluation is tuned for a game where captures are optional. For SlopFish I trained a variant-specific network on 17.2M self-play positions. I measured it against Stockfish's default network and shipped it as the default.
Running on one core
The course ran every engine on a single CPU core, and Stockfish is built for the opposite: its Lazy SMP search spawns one thread per core and lets them race through the tree sharing a transposition table. Telling it to use one thread was the obvious move, and it was the first thing I did. Then I undid it.
Instead, the engine now pins the whole process to core 0 at startup with sched_setaffinity on Linux, so the rule is enforced by the operating system rather than by trusting a UCI option. With the core fixed, the thread count became a free parameter, so I wrote a script that sweeps it from 1 to 1024 and records nodes per second at each step, plus a second script that watches the live process to confirm every thread really is on core 0. The sweep settled on 22 threads as the default. Reading the Stockfish threading code to get there was the part of the project where I learned the most about how the engine actually works.
The other speed change was in evaluation. Every node now has to answer "does the side to move have a legal capture?", which is expensive if done naively. The fast path checks for pseudo-legal captures first, skips the legality check entirely when the king has no pinned pieces, and only walks the capture list when pins exist. A second helper uses static exchange evaluation to tell a forced capture that loses material apart from one that does not.
Trusting the arbiter, not the engine
To test the engine I built a self-play harness. The key design choice is that the arbiter enforces the capture rule itself instead of trusting whatever move the engine reports. If the engine plays a non-capture while a capture was available, the arbiter flags the game.
It found a violation in 2.5% of games. The root cause was that promotions were being counted as captures in the engine's own bookkeeping, so in some positions it believed it was making a capture when it was not. I fixed it across six call sites, then reran the harness and verified zero violations in 400 games.
An engine checking its own work would likely have missed this, since the same wrong definition of a capture sat on both sides of the check. An independent arbiter is what exposed it.
Try it
Build from the source directory:
cd src && make build ARCH=nativecd src && make build ARCH=nativeThe engine runs in XBoard-compatible GUIs via polyglot.
More measurements against the default network to follow.
Further reading
The "Dissecting Stockfish" series on Medium is the best plain-language tour of the original engine I found, and it is where I started: