Concepts

A byte per cell

The Ataraxos simulator was written in CUDA to step 1,536 games in lock step at about 10 million state updates per second on one H100. The design goal here was to match that rate on a few CPU cores without a GPU, keep the state small enough to copy freely during search, and still enforce every competitive rule.

A cellbit 7occupiedbit 6ownerbit 5idbit 4idbit 3idbit 2idbit 1idbit 0id0x00empty0x40lake0x80 | idRed piece0xC0 | idBlue pieceThe id (0 to 39) fixes the type for every game: 0 Flag, 1 Spy, 2 to 9 Scouts, 10 to 14 Miners, ... 33 Marshal, 34 to 39 Bombs.The state, 440 bytes, copied whole for every search rolloutcells 100squares 80flags 80oscillation 28chase 136other 16squares: where each piece stands; flags: revealed and moved bits; chase: the last 32 after-threat position keys.
Each square is one byte: an occupied bit, an owner bit and a six-bit piece identity, with lakes and empty squares as fixed codes.

Identities, not types

The cell byte holds a piece identity from 0 to 39 rather than a type. The identity layout is fixed for every game: 0 is the Flag, 1 the Spy, 2 to 9 the Scouts, 10 to 14 the Miners, and so on up to the six Bombs at 34 to 39. A constant table maps identity to type, so battle resolution is one lookup into a 13 by 13 table built at compile time. Storing identities means the engine can keep per-piece history in flat arrays of forty entries per army: where each piece stands, whether it has moved, whether it has been revealed. The features the paper computes over piece history (threats, evades, protections, causes of death) attach naturally to identities that persist as pieces move.

The state

The whole arbiter state is 440 bytes and Copy: 100 cells, 80 piece squares, 80 flag bytes, a Zobrist key, the counters, the two-square trackers (each player's last three moves and a same-piece run count) and the chase tracker with its 32 position keys. No pointers, no heap. A search that wants to try a move copies the state in about 8 ns and applies the move in place.

Move generation

Generation walks the 33 movable identities of the side to move instead of scanning the hundred cells, looks up neighbours in a constant table that already has lake squares cut out, and writes into a fixed 256-slot buffer (the most any position can have is 244 moves: eight Scouts with eighteen each plus twenty-five pieces with four). The two-square filter only runs for the piece that just moved; the chase filter only runs when a chase is live and only for quiet moves that end next to an opponent piece.

The end-of-game check after every move needs to know whether the opponent has any legal move at all. It exits on the first move that no anti-chase rule can forbid, so the check costs a handful of comparisons in ordinary positions and only falls back to full generation in the rare positions where every candidate is restricted.

Measured on this machine

The release-mode benchmark on September 18, 2026 (Ryzen AI Max, 32 threads):

OperationTime
Apply a move, including the end-of-game check17 ns, 60 million per second
Generate legal moves (mean 28 moves)71 ns, 14 million per second
Random playout under the competitive rules, one core4.5 million plies per second
Random playouts on all 32 threads84 million plies per second
Copy the state8 ns

The paper's 10 million updates per second is three cores' worth here, and their number includes building the observation planes on the GPU, which this engine does separately in the Python bindings. Measuring the observation encoder is part of the training pipeline rather than the engine.

What the engine leaves out

The engine has no networking, clock, storage or policy code. Observations for a seat are produced by a redaction function that keeps the byte-per-cell layout and replaces hidden opponent identities with a single unknown code, plus the public anti-chase trackers so that a searcher can rebuild a rule-exact state from a sampled assignment. Everything else, from the wire protocol to the players, lives in the sibling crates.