TypeScript engine
The readable statement of the rules: the engine behind the browser game, every board drawn on this site, the scripted rounds and the parity harness.
On this page
- Every TypeScript policy under src/core/typescript
- The scripted-round playground and the D7P server
- The browser game and every board on this site
- Mobile submission validation (web/lib/submissions)
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.
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;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.
/** 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 };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.
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, []);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.
This source excerpt is unavailable in this checkout.
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.