On this page

Design

The C++ engine is the stable research baseline. It keeps the same 49-cell, row-major board as the TypeScript rules and translates those operations into plain native loops. The goal is a shared meaning for every move, with enough throughput for full-game cohorts and search experiments.

Implementation

A close port of the readable rules

The state, scoring constants and wave record mirror the TypeScript types. During a cascade, the engine records every popper before it changes any gray disc, consumes reveals in row-major order, and applies gravity after the complete wave. The comments call out that ordering because later chain waves can observe it.

engine.hppResolve one chain with observable ordering intact
C++lines 213–267
inline void resolveCascade(Board& board, Mulberry32& random, int starting_depth,
                           std::int64_t& score,
                           std::vector<Wave>& waves) {
  for (int depth = starting_depth;; ++depth) {
    int popper_count = 0;
    const auto poppers = findPoppers(board, popper_count);
    if (popper_count == 0) return;

    std::array<bool, kCellCount> popping{};
    Board cleared = board;
    for (int offset = 0; offset < popper_count; ++offset) {
      const int index = poppers[offset];
      popping[index] = true;
      cleared[index] = kEmpty;
    }

    std::array<int, kCellCount> reveals{};
    int reveal_count = 0;
    constexpr std::array<std::array<int, 2>, 4> directions{{
        {{-1, 0}}, {{1, 0}}, {{0, -1}}, {{0, 1}},
    }};
    for (int row = 0; row < kBoardSize; ++row) {
      for (int column = 0; column < kBoardSize; ++column) {
        const int index = indexOf(row, column);
        const std::uint8_t cell = board[index];
        if (cell != kSolid && cell != kCracked) continue;
        int hits = 0;
        for (const auto& direction : directions) {
          const int neighbor_row = row + direction[0];
          const int neighbor_column = column + direction[1];
          if (inside(neighbor_row, neighbor_column) &&
              popping[indexOf(neighbor_row, neighbor_column)]) {
            ++hits;
          }
        }
        if (hits == 0) continue;
        const int hits_needed = cell == kSolid ? 2 : 1;
        if (hits >= hits_needed) {
          reveals[reveal_count++] = index;
        } else {
          cleared[index] = kCracked;
        }
      }
    }

    // engine.ts scans the board in row-major order and consumes reveal values
    // before gravity. The ordering is observable through subsequent chains.
    for (int offset = 0; offset < reveal_count; ++offset) {
      cleared[reveals[offset]] = random.nextDisc();
    }
    const std::int64_t points = popper_count * scoreForWave(depth);
    score += points;
    waves.push_back({depth, popper_count, reveal_count, points});
    board = applyGravity(cleared);
  }
The C++ loop keeps the pre-clear board for gray-disc hits, consumes queued reveals in reading order, then settles the result.

Reproducible games across languages

A headless game derives the next visible disc and each move's reveal stream from separate domains of the same seed. That lets the TypeScript and C++ drivers receive the same randomness without depending on how many random calls a policy makes between moves.

engine.hppDerive one move's reveal stream from the game seed
C++lines 341–354
inline bool playHeadlessMove(State& state, std::uint32_t game_seed, int column,
                             MoveResult& result) {
  const std::uint32_t reveal_seed =
      mix32(game_seed ^
            (static_cast<std::uint32_t>(state.moves_played + 1) *
             0x85eb'ca6bu) ^
            kRevealDomain);
  Mulberry32 random(reveal_seed);
  if (!playMove(state, column, random, result)) return false;
  state = result.state;
  if (!state.game_over) {
    state.next_disc = headlessDisc(game_seed, state.moves_played);
  }
  return true;
The next disc and gray reveals use named domains, so a parity replay can reconstruct the same game on either side.

A separate sampled move loop

Research search does not draw one random future. The policy layer supplies stratified chance samples and a templated random source. It carries a second copy of the cascade and move loop so the sample order and floating-point accumulation remain fixed.

public-behavior.hppStratify each chance event deterministically
C++lines 579–607
inline double stratifiedUnit(std::uint32_t seed, int sample, int count,
                             std::uint32_t domain, int event) {
  const std::uint32_t event_seed = mix32(
      seed ^ domain ^
      (static_cast<std::uint32_t>(event + 1) * kDepthMultiplier));
  const int rotation = static_cast<int>(event_seed %
                                        static_cast<std::uint32_t>(count));
  const int stratum = (sample + rotation) % count;
  const double jitter = static_cast<double>(
      mix32(event_seed ^
            (static_cast<std::uint32_t>(sample + 1) * kSampleMultiplier))) /
      4'294'967'296.0;
  return (static_cast<double>(stratum) + jitter) /
         static_cast<double>(count);
}

struct StratifiedRandom {
  std::uint32_t seed = 0;
  int sample = 0;
  int count = 1;
  int event = 0;

  std::uint8_t nextDisc() {
    const double unit = stratifiedUnit(seed, sample, count,
                                       kRevealSampleDomain, event++);
    return static_cast<std::uint8_t>(
        std::floor(unit * static_cast<double>(kBoardSize)) + 1.0);
  }
};
Every sample visits one rotated stratum with deterministic jitter, giving the search repeatable chance coverage.

Uses

The fair depth-3 and depth-4 policy is built directly on this engine. The native suite, neural training environments and most C++ experiment entry points share it through the common whole-game harness. It is also the anchor for every alternative engine's trajectory gate.

Verification

The TypeScript parity run matched 256 games and 6,852 moves exactly (reproducibility guide). The scenario, fast C++ and Rust engines each add independent move-for-move replays against this implementation.

Technical recordThe independent trajectory gates anchored to the C++ reference
  • Scenario engine: 8,192 game-plays and 218,470 moves, with no mismatch (finding-02).
  • Fast C++ engine: 8,288 games, 438,020 moves and 548,263 waves, with no mismatch (finding-13).
  • Rust engine: three trajectory arms covering 36,427 moves and 40,286 waves, with no mismatch (RS-20260824T075451Z-e89ea128).

Limits

The sampled policy loop duplicates the move rules and has indirect search-parity coverage, while no focused gate compares it with playMove directly (docs/exploratory/audit-01-engine-fidelity.md). Compiler settings are also part of the scientific result: the efficiency audit found leaf-value changes from floating-point contraction, then restored bit identity by disabling contraction (docs/exploratory/audit-06-engine-efficiency.md).

Agent contextBuild flags, parity entry points and the duplicated-loop risk

The pinned source is src/core/native/engine.hpp; the sampled policy path is in src/core/native/public-behavior.hpp. Preserve reveal order, column order and floating-point accumulation. Native comparison builds use clang++ with -ffp-contract=off where bit parity is required.

Run make test-native and make parity before accepting a semantic change. A future direct gate should drive identical explicit random values through playMove and playMoveSampled and compare the complete move record.