C++ fast engine
A semantics-preserving reimplementation of the Drop7 move engine and the fair-D4 leaf, proven bit-identical to the frozen reference and measured at about 3× end to end.
On this page
- Depth x chance-exactness factorial: the fifth ply at five and at seven strata, with an end-to-end reproduction control (2026-08-21)
- Depth x chance-exactness factorial: the fifth ply at five and at seven strata, with an end-to-end reproduction control (2026-08-21)
- Rust bitboard engine: parity gates and throughput benchmark vs TypeScript and C++ engines
Design
The fast C++ engine changes storage and allocation while preserving the C++ reference's observable order. It was built for search, where one decision applies the rules and evaluates a leaf many times. Integer mechanics could be reorganized; chance order and floating-point expressions stayed fixed.
Implementation
One scan per wave
The reference counts a numbered disc's row and column by walking outward from that disc. The fast engine builds seven row masks, seven column masks and one numbered-cell bitboard in a single pass. A lookup table then answers the run length at every occupied position.
inline const std::array<RunInfo8, 128> kRunLengthTable = [] {
std::array<RunInfo8, 128> table{};
for (int mask = 0; mask < 128; ++mask) {
RunInfo8& info = table[static_cast<std::size_t>(mask)];
for (int position = 0; position < kBoardSize; ++position) {
info.length[position] = 0;
}
int cursor = 0;
while (cursor < kBoardSize) {
if (((mask >> cursor) & 1) == 0) {
++cursor;
continue;
}
const int start = cursor;
while (cursor < kBoardSize && ((mask >> cursor) & 1) != 0) ++cursor;
for (int position = start; position < cursor; ++position) {
info.length[position] = static_cast<std::uint8_t>(cursor - start);
}
}
}
return table;
}();
// One pass over the board produces the seven row occupancy masks, the seven
// column occupancy masks and the bitboard of numbered cells. The cover
// bitboard is deliberately NOT produced here: better than half of all move
// applications resolve with no wave at all, and those never need it.
inline void scanBoard(const Board& board, BoardScan& scan) {
unsigned rows[kBoardSize];
std::uint64_t numbered = 0;
for (int row = 0; row < kBoardSize; ++row) {
const std::uint8_t* cells = board.data() + row * kBoardSize;
unsigned occupied = 0;
unsigned numbers = 0;
for (int column = 0; column < kBoardSize; ++column) {
const unsigned cell = cells[column];
occupied |= static_cast<unsigned>(cell != 0u) << column;
numbers |= static_cast<unsigned>(cell - 1u < 7u) << column;
}
rows[row] = occupied;
scan.row_mask[row] = static_cast<std::uint8_t>(occupied);
numbered |= static_cast<std::uint64_t>(numbers) << (row * kBoardSize);
}
for (int column = 0; column < kBoardSize; ++column) {
unsigned mask = 0;
for (int row = 0; row < kBoardSize; ++row) {
mask |= ((rows[row] >> column) & 1u) << row;
}
scan.column_mask[column] = static_cast<std::uint8_t>(mask);
}
scan.numbered = numbered;
}Gravity only where a hole can exist
A wave can leave holes only in columns where a disc popped. Reveals replace gray discs in place, so they do not make a new gap. The cascade records the touched columns as a bit mask and compacts those columns in place.
// Only a column that lost a disc can have a hole; reveals overwrite a cover in
// place and leave the column contiguous. Compacting just the affected columns
// is therefore identical to compacting all seven.
inline void applyGravityInPlace(Board& board, unsigned columns) {
while (columns != 0) {
const int column = __builtin_ctz(columns);
columns &= columns - 1;
int destination = kBoardSize - 1;
for (int row = kBoardSize - 1; row >= 0; --row) {
const std::uint8_t cell =
board[static_cast<std::size_t>(row * kBoardSize + column)];
if (cell == kEmpty) continue;
board[static_cast<std::size_t>(destination * kBoardSize + column)] = cell;
--destination;
}
for (int row = destination; row >= 0; --row) {
board[static_cast<std::size_t>(row * kBoardSize + column)] = kEmpty;
}
}
}No wave allocation inside search
Full-game replays need every wave. Search needs only the count and the last chain depth. A templated sink lets the same move loop write either representation, so the hot path avoids constructing a vector it never reads.
// ---------------------------------------------------------------------------
// O5. Wave sinks. The search discards MoveResult::waves entirely; only
// `empty()` and `back().depth` are consulted inside playMove. A vector is
// therefore a per-node heap allocation for data nobody reads. Trajectory
// consumers get the full list through FullWaveSink, which stores it inline.
// ---------------------------------------------------------------------------
struct MinimalWaveSink {
int count = 0;
int last_depth = 0;
void push(const Wave& wave) {
++count;
last_depth = wave.depth;
}
bool empty() const { return count == 0; }
int backDepth() const { return last_depth; }
};
// A move can pop at most 49 discs, and every wave clears at least one disc, so
// a single move produces at most 49 waves before the rise and at most 49 after
// it. 128 is a hard upper bound with margin; overflow throws rather than
// silently truncating.
inline constexpr int kMaximumWavesPerMove = 128;
struct FullWaveSink {
std::array<Wave, kMaximumWavesPerMove> waves;
int count = 0;
void push(const Wave& wave) {
if (count >= kMaximumWavesPerMove) {
throw std::runtime_error("fast engine: wave capacity exceeded");
}
waves[static_cast<std::size_t>(count++)] = wave;
}
bool empty() const { return count == 0; }
int backDepth() const { return waves[static_cast<std::size_t>(count - 1)].depth; }
};A packed search key
The search cache stores the board, next disc, moves to the next rise and depth in four machine words. Its open-addressed table and intrusive least-recently-used list keep the reference eviction behavior without allocating strings and list nodes at interior search states.
struct PackedKey {
std::uint64_t words[4] = {0, 0, 0, 0};
bool operator==(const PackedKey& other) const {
return words[0] == other.words[0] && words[1] == other.words[1] &&
words[2] == other.words[2] && words[3] == other.words[3];
}
};
// Injective on the reachable domain: cells are 0..9 (4 bits), next_disc 1..7,
// moves_remaining 1..5, depth 1..8. gate-search asserts the domain bounds on
// every key it builds.
inline PackedKey packKey(const State& state, int depth) {
PackedKey key;
const std::uint8_t* cells = state.board.data();
for (int group = 0; group < 3; ++group) {
std::uint64_t word = 0;
for (int offset = 0; offset < 16; ++offset) {
word |= static_cast<std::uint64_t>(cells[group * 16 + offset] & 0x0fu)
<< (4 * offset);
}
key.words[group] = word;
}
key.words[3] = static_cast<std::uint64_t>(cells[48] & 0x0fu) |
(static_cast<std::uint64_t>(state.next_disc) << 8) |
(static_cast<std::uint64_t>(
static_cast<std::uint32_t>(state.moves_remaining))
<< 16) |
(static_cast<std::uint64_t>(static_cast<std::uint32_t>(depth))
<< 24);
return key;
}Uses
This is the main CPU engine for fair-search cohorts under lifetime-objective. It also supports leaf evolution, learned-leaf experiments and the native fair policy in the scripted-round playground. The reference engine remains the semantic anchor; this implementation is the high-throughput execution path.
Verification
On 24 real depth-4, five-strata decisions with three interleaved repeats, the complete engine, cache and leaf path measured 3.08 times the reference on the shared host (finding-13). That is an engineering result. It does not measure policy strength.
Technical recordTrajectory, search and leaf gates behind the speed result
- The trajectory gate compared 438,020 moves and 548,263 waves across 8,288 games with no mismatch (finding-13).
- Search parity covered 306 moves across nine depth and strata configurations, with matching actions, work and completed depth (finding-13).
- The leaf gate compared 225,183 real states by floating-point bit pattern, with no mismatch (finding-13).
Limits
The measurements came from a heavily shared machine, so the retained record treats back-to-back ratios as the useful quantity and leaves absolute timing unpromoted. The implementation has no latent-board mode, and its native builds and gates were validated with clang++ only.
Agent contextEquivalence contract, build entry points and required fast-engine gates
The source contract at the top of fast-engine.hpp permits integer, layout and allocation changes. Do not reorder chance draws or floating-point expressions. The reference order includes row-major poppers, row-major reveals, complete wave order and strict cache eviction behavior.
Build with approaches/lifetime-objective/fast-engine/build.sh. Before using a change, run gate-leaf, gate-search and gate-trajectory from the generated build directory. The retained methodology and measurement details are in finding-13.