On this page

Design

This is the readable version of the five-move Hardcore rules. The board is a flat, 49-cell array and every public operation says what it does with ordinary loops. A move returns a new state, so a test can inspect the board before and after any step without reconstructing an in-place mutation. The same source is imported by the browser game, the board figures, the scripted-round tools and the cross-language parity harness.

Implementation

A literal pop test

A numbered disc clears when its number equals the occupied run through it across or down. The implementation scans the board in reading order and calls the same small line-count function for both axes. It favors an auditable rule over a compressed representation.

engine.tsCount a run, then find every matching disc
TypeScriptlines 265–307
export function contiguousLineLength(
  board: Board,
  row: number,
  column: number,
  axis: "row" | "column",
): number {
  assertBoard(board);
  if (!isInside(row, column) || board[indexOf(row, column)] === EMPTY) return 0;

  const [rowStep, columnStep] = axis === "row" ? [0, 1] : [1, 0];
  let count = 1;
  for (const direction of [-1, 1]) {
    let nextRow = row + rowStep * direction;
    let nextColumn = column + columnStep * direction;
    while (
      isInside(nextRow, nextColumn) &&
      board[indexOf(nextRow, nextColumn)] !== EMPTY
    ) {
      count += 1;
      nextRow += rowStep * direction;
      nextColumn += columnStep * direction;
    }
  }
  return count;
}

export function findPoppers(board: Board): number[] {
  assertBoard(board);
  const poppers: number[] = [];
  for (let row = 0; row < BOARD_SIZE; row += 1) {
    for (let column = 0; column < BOARD_SIZE; column += 1) {
      const index = indexOf(row, column);
      const cell = board[index];
      if (!isNumbered(cell)) continue;
      if (
        contiguousLineLength(board, row, column, "row") === cell ||
        contiguousLineLength(board, row, column, "column") === cell
      ) {
        poppers.push(index);
      }
    }
  }
  return poppers;
The implementation reads like the rule: count contiguous occupied cells in each direction, then compare that length with the disc.

One simultaneous wave

Every disc selected for a wave disappears together. Gray discs count hits against the board from before the clear, reveals are consumed in row-major order, and gravity runs only after those values have been written. That order is observable because a reveal can start the next wave.

engine.tsApply gray-disc hits before gravity
TypeScriptlines 322–356
/** Clear one simultaneous wave and apply every adjacent hit before gravity. */
function clearWave(board: Board, poppers: readonly number[]): ClearedWave {
  const next = board.slice();
  const popping = new Set(poppers);
  for (const index of poppers) next[index] = EMPTY;

  const revealIndexes: number[] = [];
  for (let row = 0; row < BOARD_SIZE; row += 1) {
    for (let column = 0; column < BOARD_SIZE; column += 1) {
      const index = indexOf(row, column);
      const cell = board[index];
      if (cell !== SOLID && cell !== CRACKED) continue;

      let hits = 0;
      for (const [rowDelta, columnDelta] of DIRECTIONS) {
        const neighborRow = row + rowDelta;
        const neighborColumn = column + columnDelta;
        if (
          isInside(neighborRow, neighborColumn) &&
          popping.has(indexOf(neighborRow, neighborColumn))
        ) {
          hits += 1;
        }
      }
      if (hits === 0) continue;

      const hitsNeeded = cell === SOLID ? 2 : 1;
      if (hits >= hitsNeeded) {
        revealIndexes.push(index);
      } else {
        next[index] = CRACKED;
      }
    }
  }
  return { board: next, revealIndexes };
The original board supplies the gray-disc state and the popping set, while a copied board receives cracks and queued reveals.

Chance as a stream

Search has to consider every value a gray disc can reveal. The engine visits those branches one at a time and hands each settled board to a callback. A large cascade can therefore be explored without first building a large array of every possible future.

engine.tsEnumerate reveal values without retaining the tree
TypeScriptlines 523–546
    const assignReveals = (revealIndex: number, branchProbability: number) => {
      if (shouldStop()) throw new SearchAbortedError();
      if (revealIndex === cleared.revealIndexes.length) {
        visit(
          applyGravity(cleared.board),
          depth + 1,
          score + points,
          branchProbability,
          nextWaves,
        );
        return;
      }

      const boardIndex = cleared.revealIndexes[revealIndex];
      for (let value = 1; value <= BOARD_SIZE; value += 1) {
        cleared.board[boardIndex] = value as DiscValue;
        assignReveals(revealIndex + 1, branchProbability / BOARD_SIZE);
      }
    };

    assignReveals(0, probability);
  };

  visit(board, startingDepth, 0, 1, []);
Each hidden value extends the current branch with one seventh of its probability, then the settled result is visited immediately.

Uses

The ordinary move function powers the playable game. Its optional latent board gives every covered disc a fixed hidden value for scripted rounds and recorded-game replay. The solver uses the chance-streaming functions to evaluate columns by expectation. On this site, that solver runs in a Web Worker, which keeps the main thread available for drawing, input and cascade animation.

solver.worker.tsRun expectimax away from the main thread

This source excerpt is unavailable in this checkout.

The worker receives one position, posts each completed search depth as progress, and posts the final result when the time budget ends.

The browser uses a value-identical, allocation-conscious port of the same search. Its move generator, cache and worker lifecycle are explained on the browser fast-search page.

Verification

The retained parity snapshot replayed 256 seeded games and 6,852 moves against the C++ engine with identical move records (reproducibility guide). A later Rust gate drove another 256 games from this implementation and found no trajectory mismatch (RS-20260824T075451Z-e89ea128).

Technical recordThe rules and latent-board checks that guard this implementation

The rule tests cover these observable behaviors:

  • scoring constants match the original game
  • gravity preserves the order of discs in every column
  • line counts stop at gaps and include covered discs
  • a wave clears all matching discs simultaneously
  • two hits in one wave fully reveal a solid disc
  • gravity, cracks, reveals, and scoring compose across chain waves
  • exact gray-disc outcomes retain their full probability mass
  • the Hardcore game starts above a solid row and cracks it on a 1
  • clearing the board awards the original screen-clear bonus
  • every fifth move raises a solid row and awards the level bonus
  • a level-up explosion continues the fifth move's chain depth
  • a rising row ends the game instead of discarding an occupied top cell
  • the exact move model includes all seven next discs
  • streamed move outcomes preserve exact probability and expected score
  • seeded games remain settled and gravity-packed through game over

The latent-board tests cover these additional behaviors:

  • a reveal uses the covered cell's predetermined latent value
  • a reveal without a latent value is an error, not a draw
  • latent values follow their covered cell through gravity
  • a row rise shifts latent values up and draws the new row from the source
  • scripted latent games are exactly reproducible
  • moves without a latent board keep random reveals and report no latent state

Limits

Array copies and repeated line scans make the rules easy to inspect, while deeper search needs a faster path. The C++ fast engine and the browser solver preserve the same ordering with cheaper storage. The fidelity audit also records rule divergences from the cited original game and an uncovered board-clear branch in the parity sweep (docs/exploratory/audit-01-engine-fidelity.md). Agreement between ports preserves those semantics; it does not settle the historical rules question.

Agent contextExtension points, required tests and the browser-worker contract

Keep rule changes in src/core/typescript/engine.ts readable and update the focused tests before porting them elsewhere. Run npm test at the project root for the engine and native parity suite, then run cd web && npm test for browser-search parity.

The worker protocol lives in web/lib/play/solver.protocol.ts. A position change must terminate its existing worker, completed-depth progress may update the display, and only a final result may trigger an automatic move. Do not turn browser-solver output into research evidence.