The pack
match3
function match3(): Pack<{ match3: Match3Api }>
The match3 pack. Pure logic, so it needs no backend and runs headless.
Helpers
classifyGroup
function classifyGroup(group: MatchGroup, pivotHint?: XY): { shape: MatchShape; orientation: 'h' | 'v' | 'both' | 'none'; pivot: XY }
Classify a group's shape by the standard priority: 5-line > L/T/cross > square > 4 > 3.
createBoard
function createBoard(cfg: BoardConfig): Board
Create a board. See BoardConfig for every option.
findMatchGroups
function findMatchGroups(grid: MatchScanGrid, squareMatch = false): MatchGroup[]
Scan the whole grid: all horizontal + vertical runs of 3+, merged into groups. Optionally also standalone 2×2 squares (cells not already in any run).
floodGroup
function floodGroup(grid: MatchScanGrid, x: number, y: number, adj8 = false): XY[]
Flood-fill the connected same-colour group containing (x, y).
Orthogonal by default; adj8 includes diagonal neighbours (8-way chains).
Classes
Board
class Board
The headless match-3 board model: grid, moves, cascades, score and goals. Create one with createBoard().
readonly cols: number— The number of columns, from the config.readonly rows: number— The number of rows, from the config.readonly preset: 'match3' | 'collapse' | 'chain'— The rule preset the board plays: 'match3' (swap), 'collapse' (tap groups) or 'chain' (drag lines).readonly colors: number— How many block colors the board deals from.score: number— Cumulative score.moveCount: number— Valid moves made so far.movesLeft: number— Remaining move budget whenmoveLimitwas configured, elseInfinity.goals: GoalState[]— Live goal counters (empty when no goals configured).constructor(cfg: BoardConfig)snapshot(): BoardSnapshot— The board's whole state as plain data: cells, blocks, overlays, goals, score, the move budget and the seeded stream.restoreit into a board built from the same config, for undo, a save or a search that tries a move and takes it back. Presentation in progress (beginPresent) is not part of it.restore(snap: BoardSnapshot): void— Return to asnapshot()of a board built from the same config.cell(x: number, y: number): Cell— The cell at (x, y). Mutating it mutates the board's substrate.block(x: number, y: number): Block | null— The block at (x, y), or null.overlay(x: number, y: number): OverlayEntry | null— The overlay at (x, y), or null.setBlock(x: number, y: number, init: BlockInit | null): Block | null— Place (or clear, with null) a block during level setup.setOverlay(x: number, y: number, entry: OverlayEntry | null): void— Attach an overlay (chain/vine/cage…) during level setup.addLayer(x: number, y: number, layer: CellLayer): void— Add a damageable cell layer (jelly…) during level setup.forEach(fn: (x: number, y: number, cell: Cell, block: Block | null, overlay: OverlayEntry | null) => void): void— Visit every cell.debugGrid(): string— Rows of colour digits (.empty,#blocked,~conduit,*colourless) — handy in tests.swap(a: XY, b: XY): MoveResult— Swap two adjacent blocks. Instant full resolution; play backresult.steps.rotate(at: XY, dir: 'cw' | 'ccw' = 'cw'): MoveResult— Rotate the 2×2 block whose top-left isat, 'cw' or 'ccw' (Bejeweled Twist). Reverts if it makes no match, unlessoperatorFreeMove.shiftRow(row: number, by: number): MoveResult— Slide a whole row bybycells with wraparound (Chuzzle / 10000000).shiftColumn(col: number, by: number): MoveResult— Slide a whole column bybycells with wraparound.tap(at: XY): MoveResult— Tap a cell: collapse preset clears the flood group; a tapped special fires.pathAt(cells: XY[]): { valid: boolean; loop: boolean; color: number; cells: XY[] }— Chain preset: validate a drawn path without playing it. A path is valid when every cell holds a matchable block of ONE colour(+tier), consecutive cells are adjacent (seechain.adjacency), no cell repeats — except that the FINAL cell may revisit an earlier one, closing aloop— and the unique length reacheschain.minLength. Use per-step while the player drags.playPath(cells: XY[]): MoveResult— Chain preset: commit a drawn path. A closed loop clears EVERY block of the path's colour (whenchain.loopClearsColorisn't false).applyAction(action: Action): MoveResult— Apply an inventory power-up / tool action. Cascades follow; does not consume a move.endTurn(): MoveResult— Run the turn systems (conveyors, spreaders, bomb fuses) manually whenautoTick: false.findMoves(): PredictedMove[]— Every legal move, with its predicted primary outcome, best first.hint(): PredictedMove | null— The single best move, or null.autoplay(opts: AutoplayOptions = {}): MoveResult[]— Play the board by itself: pick a move perpolicy, apply it, repeat. Stops onmaxMoves, no moves left, all goals done, or a lose trigger.beginPresent(result: MoveResult): void— Mark a result's region as visually busy while the game plays it back.endPresent(result: MoveResult): void— Release a region when its playback finishes.busy(): number[]— Cell indices currently visually busy.canMove(a: XY, b?: XY): boolean— Conservative admission for parallel play: false when the COLUMNS of the candidate cells intersect any busy region (cascades pull whole columns).
Types
Action
type Action = | { type: 'clearRow'; row: number } | { type: 'clearColumn'; col: number } /** Square blast centred at `at`; `radius: 1` = 3×3. */ | { type: 'blast'; at: XY; radius: number } | { type: 'clearColor'; color: number } | { type: 'paint'; at: XY; color: number } /** Clear one block (or one overlay/blocker layer) at a chosen cell. */ | { type: 'hammer'; at: XY } /** Hit `count` random block cells within `radius` of `at`. */ | { type: 'splat'; at: XY; radius: number; count: number } | { type: 'randomClear'; count: number } | { type: 'shuffle' } /** Swap two adjacent cells with no match requirement (the "glove"). */ | { type: 'freeSwap'; a: XY; b: XY } /** Convert every block of `color` into `special`, then fire them all. */ | { type: 'convertAndFire'; color: number; special: SpecialDef } | { type: 'custom'; cells: (ctx: { cols: number; rows: number }) => XY[] }
A grid mutation. Applied via board.applyAction, special blocks, or combos.
AutoplayOptions
interface AutoplayOptions
Options for Board.autoplay.
policy?: 'best' | 'random' | 'worst' | ((moves: PredictedMove[], board: Board) => PredictedMove)— Move policy. Default'best'. A function receives the sorted move list.maxMoves?: number— Stop after this many moves. Default 100.onMove?: (result: MoveResult, move: PredictedMove) => void— Called after each move with its result.
Block
interface Block extends Required<Pick<BlockInit, 'color' | 'kind'>>
A live block on the board. Identity (id) is stable across its whole life.
id: number— Stable id — key your sprites off this, never off grid position.special?: SpecialDef | undefined— The special payload, when this is a special block.hp: number— Matches remaining before the block clears.matchable: boolean— True when the block can take part in a match.static: boolean— True when the block never falls.hitFilter?: HitType[] | undefined— Which hit types damage ablockerblock.bombTimer?: number | undefined— Moves remaining on the fuse. Reaching 0 fires a lose trigger.tags?: string[] | undefined— Free-form game labels, carried through every journal event.tier: number— The merge-mode level (0-based).
BlockInit
interface BlockInit
Everything you can specify when creating/placing a block.
color?: number— Colour index0..colors-1, or-1for colourless (ingredients, crates).kind?: BlockKind— What the block fundamentally is. Default 'normal'.special?: SpecialDef | undefined— The special payload. Makes the block a special when set.hp?: number— Matches needed to clear it.1(default) clears on first match; ice-style blocks use2+— each match cracks a layer (blockDamageevents) until 0.matchable?: boolean—false→ never part of a match (ingredients, crates). Default fromkind.static?: boolean—true→ never falls (meringue-style anchored blockers).hitFilter?: HitType[] | undefined— Which hit types damage ablockerblock. Default['adjacent','blast','beam','tool'].bombTimer?: number— Move-countdown fuse: ticks down once per move; reaching 0 fires a lose trigger.tags?: string[] | undefined— Free-form game labels, carried through every journal event.tier?: number— Merge-mode level (0-based). Blocks only match with the SAME colour + tier.
BlockKind
type BlockKind = 'normal' | 'special' | 'ingredient' | 'blocker'
What a block fundamentally is.
BlockSnapshot
type BlockSnapshot = Readonly<Block>
An immutable copy of a block as it was when the event fired.
BoardConfig
interface BoardConfig
Board construction options.
cols: number— The number of board columns.rows: number— The number of board rows.colors?: number— Number of block colours dealt (0..colors-1). Default 6.seed?: number— PRNG seed. Same seed + same moves = identical journals. Default 1.preset?: 'match3' | 'collapse' | 'chain'—match3(default): adjacent swap must make a match; line matcher; cascades re-match.collapse: tap a flood group ofminGroup+ same colour; no refill; empty columns close up horizontally; cascades do NOT auto-match.chain: the player draws a path through adjacent same-colour blocks (board.playPath); refills from the top; cascades do NOT auto-match.chain?: { /** Path step adjacency. Default 4 (orthogonal); 8 allows diagonals. */ adjacency?: 4 | 8; /** Minimum path length. Default 3. */ minLength?: number; /** Closing the path into a loop clears EVERY block of that colour. Default true. */ loopClearsColor?: boolean; }— Chain preset tuning.merge?: { maxTier: number; explode?: boolean; style?: 'fuse' | 'levelAll' }— Merge resolution: matches LEVEL blocks UP instead of clearing them. ReachingmaxTierEXPLODES: the block clears and every neighbour (3×3) levels up — neighbours that hitmaxTierexplode too, chain-reacting. Shape specials are disabled in merge mode. Two styles: -'fuse'(default, Triple-Town): blocks only match EQUAL colour+tier; the group is consumed into ONE next-tier block at the pivot; cascades re-match as normal. -'levelAll': ANY same-colour trio matches regardless of tier; nothing is consumed — EVERY participant gains a tier in place. Only the group(s) containing the swapped cells resolve, and cascades never auto-match (levelled triples persist on the board); explosions are the only exit.layout?: string[]— The board shape, one string per row..normal ·#blocked (hole) ·~conduit ·<>^vconveyor ·Eexit cell (normal + exit). Omit for an all-normal rectangle.minGroup?: number— Collapse preset: minimum flood-group size. Default 2.squareMatch?: boolean— Recognise 2×2 squares as a match shape. Default false.refill?: 'top' | 'none'— Refill from the top after clears. Defaults: match3'top', collapse'none'.diagonalSlide?: boolean— Blocks slide diagonally into cells that cannot be fed vertically (starved by holes/static blocks). Default: true for match3, false for collapse.horizontalCollapse?: boolean— Collapse preset: empty columns close toward the left. Default true (collapse only).spawnWeights?: SpawnWeights— Colour weights for fill/refill. Default uniform.spawnTable?: { weight: number; block: BlockInit | (() => BlockInit) }[]— Weighted deal/refill CONTENT — when present it replacesspawnWeights: every dealt/refilled block picks an entry by weight (Puzzle-Quest skulls, cannon-fed blockers, rare bombs…). Entries may be factories for per-spawn variation. The initial deal still guarantees a no-match, movable board.specialSpawns?: Partial<Record<MatchShape, SpecialDef | null>>— Shape → special spawned at the pivot.nulldisables one shape. Default: four → row/colClear (perpendicular to the run), five/longer → colorClear, L/T/cross → blast r1, square → seeker (if squareMatch). Collapse preset instead usescollapseSpecials.collapseSpecials?: Record<number, SpecialDef>— Collapse preset: group-size thresholds → special left at the tap cell. Default: 5 → row/colClear, 7 → blast, 9 → colorClear.{}disables.comboMatrix?: Record<string, (a: XY, ctx: { colorA: number; colorB: number }) => Action[]>— Override/extend the special+special swap combo matrix. Keys like'blast+rowClear'(kinds sorted alphabetically, joined with+).tapActivatesSpecials?: boolean— Tapping a special block on the board fires it (Royal Match style). Default true.swapAnywhere?: boolean— Swap any two cells, not just adjacent ones (Pokémon Shuffle). Default false.operatorFreeMove?: boolean— Arotate/shiftRow/shiftColumnmove need not create a match to be legal (Bejeweled Twist). Default false — the operator reverts if it makes no match.autoTick?: boolean— Auto-append the turn tick (conveyors, spread, bomb fuses) to every valid move. Default true; set false and callboard.endTurn()yourself.autoShuffle?: boolean— Auto-shuffle when no legal move exists after a move settles. Default true (match3).goals?: GoalDef[]— The level goals. Progress arrives inMoveResult.goalsDelta; live state is onboard.goals.moveLimit?: number— Optional move budget —board.movesLeftcounts down; purely informational.scorer?: (ev: { kind: 'clear' | 'match'; blocks: number; shape?: MatchShape; depth: number }) => number— Replace the default scorer.depthis the cascade round (0 = direct).
BoardSnapshot
interface BoardSnapshot
A board's state from snapshot(): plain data (movesLeft may be Infinity).
cells: Cell[]— The cells, row by row.grid: (Block | null)[]— The blocks, row by row.overlays: (OverlayEntry | null)[]— The overlays, row by row.goals: GoalState[]— Goal counters.score: number— Score.moveCount: number— Valid moves made.movesLeft: number— Moves left in the budget.nextId: number— The next block id.rng: RngState— Where the seeded stream stands.
Cell
interface Cell
One board square. All fields optional except kind; a plain cell is { kind: 'normal' }.
kind: CellKind— What the square fundamentally is. SeeCellKind.flow?: Dir[]— Fall routing out of this cell, tried in order. Default['down']. Diagonal slide past blocked cells is automatic (seeBoardConfig.diagonalSlide); setflowonly for custom routing.conveyor?: Dir— Belt direction forkind: 'conveyor'cells. Belts chain into runs/loops.portal?: { to: XY }— A block that lands here is teleported totoand keeps falling from there.spawner?: boolean— Marks a refill entry point. By default every top-row non-blocked cell spawns.exit?: boolean— Bottom slot: a non-matchableingredientblock resting here exits and counts.layers?: CellLayer[]— Damageable under-layers (jelly etc.), topmost last.
CellKind
type CellKind = 'normal' | 'blocked' | 'conduit' | 'conveyor'
What a board square fundamentally is:
normal— holds a block.blocked— a hole/dead cell: never holds a block, stops falls (carves the board shape out of the rectangle).conduit— holds nothing, but falling blocks pass straight through it (the "invisible gap rows" between board sections).conveyor— holds a block and shifts its content one step inconveyordirection on each turn tick.
CellLayer
interface CellLayer
A damageable layer painted ON the cell itself (under the block): jelly, gold-gilding, grass. Damaged by hits that land on the cell; removed at hp 0.
kind: string— Game-defined label, e.g.'jelly'. Goals can target it.hp: number— Hits remaining before the layer clears. Double jelly = 2.hitFilter?: HitType[]— Which hit types damage it. Default: every direct hit (notadjacent).
Dir
type Dir = 'up' | 'down' | 'left' | 'right' | 'downLeft' | 'downRight'
A direction on the board grid. downLeft/downRight appear in flow routing.
FallMove
interface FallMove
A block displacement within a fall step. path includes every cell traversed
(conduit/portal waypoints included) — tween through it, or teleport past it.
blockId: number— The stable id of the block that moved.from: XY— The cell the block left.to: XY— The cell the block came to rest in.path: XY[]— Every cell the block traversed, in order. Tween through it, or teleport past it.
GoalDef
type GoalDef = | { id?: string; type: 'score'; count: number } | { id?: string; type: 'clearColor'; color: number; count: number } /** Count cleared blocks carrying this tag (crates, gems…). */ | { id?: string; type: 'clearTagged'; tag: string; count: number } | { id?: string; type: 'clearLayer'; kind: string; count: number } | { id?: string; type: 'clearOverlay'; kind: string; count: number } | { id?: string; type: 'ingredients'; count: number } | { id?: string; type: 'fireSpecials'; count: number }
A level goal, wired to journal events.
GoalDelta
interface GoalDelta
A goal counter changing as a consequence of a move.
id: string— The id of the goal that changed.amount: number— How much this move progressed it.remaining: number— The count still to go after this move.done: boolean— True when this move completed the goal.
GoalState
interface GoalState
Live goal state, readable on board.goals.
id: string— The goal id (from the definition, or generated).def: GoalDef— The goal definition this state tracks.target: number— The total count the goal asks for.remaining: number— The count still to go. 0 when the goal is complete.done: boolean— True when the goal is complete.
HitType
type HitType = 'match' | 'adjacent' | 'blast' | 'beam' | 'tool'
Every clearing event lands as typed hits:
match— on the matched cells themselvesadjacent— splash onto orthogonal neighbours of a clear (how crates die)blast— bomb/area explosionsbeam— row/column clearstool— inventory power-ups (hammer etc.) Cell layers, overlays and blocker blocks each declare which types damage them.
Match3Api
type Match3Api = typeof api
game.match3 — every function and class this pack carries, as one namespace.
MatchEvent
interface MatchEvent
One resolved match, with everything the game needs to know about it.
shape: MatchShape— The recognised match shape.length: number— Total blocks involved.orientation: 'h' | 'v' | 'both' | 'none'— The run direction: horizontal, vertical, both (L/T/cross), or none (group/square).color: number— The colour index of the matched blocks.cells: XY[]— Every cell in the match.blocks: BlockSnapshot[]— Every block in the match, snapshotted at match time.pivot: XY— The moved/tapped cell (specials spawn here); run intersection for cascades.overlays: { at: XY; kind: string; hp: number }[]— Overlays present on matched cells at match time.layers: { at: XY; kind: string; hp: number }[]— Cell layers present under matched cells at match time.chainDepth: number— 0 = made directly by the move; 1+ = cascade round that produced it.cause: 'move' | 'cascade' | 'action'— What produced the match: the player's move, a cascade, or an applied action.
MatchGroup
interface MatchGroup
A merged match group (runs sharing a cell fuse into one match).
color: number— The match color shared by every cell in the group.cells: XY[]— The unique cells in the group, from all merged runs.runs: Run[]— The runs that merged into this group. Empty for a standalone square.square: boolean— True when this group is a standalone 2×2 square (no runs).
MatchScanGrid
interface MatchScanGrid
Minimal grid view the scanner needs. Return -2 for empty/unmatchable cells.
cols: number— The number of columns in the grid.rows: number— The number of rows in the grid.matchColorAt(x: number, y: number): number— Returns the match color at a cell, or -2 for an empty or unmatchable cell.
MatchShape
type MatchShape = 'three' | 'four' | 'five' | 'longer' | 'L' | 'T' | 'cross' | 'square' | 'group'
The hard-coded shape registry. longer = straight run of 6+.
MoveResult
interface MoveResult
What one move fully resolved to. All logic already happened; play steps back.
valid: boolean—false→ nothing changed;stepsholds the swap/swapBack for the fail fx.steps: Step[]— The ordered event journal. Play the steps back one visual beat at a time.region: number[]— Every cell index (y * cols + x) touched — for parallel-move admission.totals: { matches: number; blocksCleared: number; chainDepth: number; specialsMade: number; specialsFired: number; score: number; /** Cleared blocks per colour — the resource layer (mana, orders) reads this. */ clearedByColor: Record<number, number>; /** Cleared blocks per tag (skulls, crates, gems…). */ clearedByTag: Record<string, number>; }— Aggregate counters for the whole move: matches, clears, cascade depth, specials, score.goalsDelta: GoalDelta[]— One entry for each goal this move progressed.gameOver?: 'bomb' | 'noMoves'— A lose trigger fired during this move (bomb expiry / unfixable deadlock).
OverlayEntry
interface OverlayEntry
An overlay entry sitting on top of one cell's block.
kind: string— Game-defined label, e.g.'chain','vine','cage'.hp: number— Hits remaining before the overlay clears.locksMove?: boolean— Block cannot be swapped/moved (it can still fall away unless this is set).locksMatch?: boolean— Block cannot take part in a match at all (cage/bubble style).absorbsClear?: boolean—true(ice-casing style): a match ON the block damages the overlay and the block SURVIVES.false(chain style): the match clears overlay and block together.hitFilter?: HitType[]— Which hit types damage it. Default: all.spreads?: boolean— Vine/chocolate growth: each turn tick in which NO overlay of this kind was damaged, grow one copy onto a random adjacent uncovered block.
PredictedMove
interface PredictedMove
A legal move found by findMoves, with its predicted primary outcome.
kind: 'swap' | 'tap' | 'path'— The move type: a swap of two cells, a group tap, or a drawn path.a: XY— The first cell of the move (the tapped cell, or one end of the swap).b?: XY— Present for swaps.path?: XY[]— Present for chain-preset moves: a playable path through one colour group.shape: MatchShape— Predicted best shape the move makes (orgroupfor taps).length: number— Blocks in the predicted match/group.value: number— Heuristic value —findMovessorts by this, descending.
RefillSpawn
interface RefillSpawn
A refilled block. Animate it EXACTLY like a FallMove: walk path at the
same cells-per-second speed as every other item in the step. path starts
ABOVE the board (negative y for top entries), already stacked by spawn order,
so simultaneous spawns keep one-cell spacing and never overlap the blocks
falling below them. entry/to/drop/stack are informational extras.
block: BlockSnapshot— The new block, snapshotted at spawn time.column: number— The column the block enters through.entry: XY— The spawner cell the block enters through.to: XY— The cell the block comes to rest in.drop: number— The number of cells the block falls, for timing.stack: number— The block's position in this column's spawn order (0 = first).path: XY[]— Every cell the block traverses, starting above the board. Tween through it.
Run
interface Run
One straight run of three or more same-color cells found by the scanner.
cells: XY[]— The cells in the run, in scan order.orientation: 'h' | 'v'— The direction of the run: 'h' for horizontal, 'v' for vertical.
SpawnWeights
type SpawnWeights = number[]
Weighted content table for refill spawns. Index = colour, value = weight.
SpecialDef
interface SpecialDef
The special payload carried by a special block.
kind: SpecialKind— Which built-in effect fires when the special activates.radius?: number— Blast radius in cells forblast(1 = 3×3). Default 1.action?: Action— Explicit action forkind: 'custom'.
SpecialKind
type SpecialKind = 'rowClear' | 'colClear' | 'blast' | 'colorClear' | 'seeker' | 'custom'
Built-in special payload kinds. custom fires action.
Step
type Step = | { type: 'swap'; a: XY; b: XY; aBlock: number; bBlock: number } /** The failed-swap bounce back. A `valid: false` result is exactly [swap, swapBack]. */ | { type: 'swapBack'; a: XY; b: XY; aBlock: number; bBlock: number } /** Collapse preset: the tapped flood group about to clear. */ | { type: 'tap'; at: XY; group: XY[] } /** Marks each cascade round, for escalating pitch/fx. depth 0 = the direct result. */ | { type: 'cascadeStart'; depth: number } | { type: 'match'; matches: MatchEvent[] } /** `fromShape: 'convert'` = mass conversion by a colour-clear combo. */ | { type: 'specialSpawn'; block: BlockSnapshot; at: XY; fromShape: MatchShape | 'convert' } /** A special (or pair combo) firing. `cells` = every cell it hits. */ | { type: 'detonate'; sourceId: number | null; at: XY; kind: SpecialKind | 'combo'; cells: XY[] } | { type: 'overlayDamage'; hits: { at: XY; kind: string; remainingHp: number }[] } | { type: 'cellLayerDamage'; hits: { at: XY; kind: string; remainingHp: number }[] } /** Multi-hp blocks cracking (ice), or blockers losing a layer. */ | { type: 'blockDamage'; hits: { blockId: number; at: XY; remainingHp: number }[] } | { type: 'clear'; blocks: { id: number; at: XY; block: BlockSnapshot; cause: HitType }[] } | { type: 'paint'; blockId: number; at: XY; color: number } | { type: 'fall'; moves: FallMove[]; spawns: RefillSpawn[] } | { type: 'conveyor'; shifts: FallMove[] } | { type: 'spread'; growth: { kind: string; at: XY }[] } | { type: 'bombTick'; ticks: { blockId: number; at: XY; remaining: number }[] } | { type: 'ingredientExit'; blockId: number; at: XY; block: BlockSnapshot } /** Move operators (A1). `rotate` = a 2×2 turn; `shift` = a wraparound line slide. */ | { type: 'rotate'; at: XY; dir: 'cw' | 'ccw'; blocks: number[] } | { type: 'shift'; axis: 'row' | 'col'; index: number; by: number; moves: FallMove[] } /** Chain preset: the committed player path. `loop` = the path closed a cycle. */ | { type: 'path'; cells: XY[]; loop: boolean } /** Merge mode: the consumed blocks (`from`) became one tier-`tier` block at `at`. * Present it by flying the `from` sprites into `at` (their `clear` step precedes this). */ | { type: 'merge'; at: XY; blockId: number; block: BlockSnapshot; tier: number; color: number; from: { blockId: number; at: XY }[] } /** Merge mode: blocks levelled up by a neighbouring explosion. */ | { type: 'tierUp'; hits: { blockId: number; at: XY; tier: number }[] } /** Merge mode: a max-tier block detonating (its `clear` follows). */ | { type: 'explode'; at: XY; blockId: number } /** Deadlock auto-reshuffle: every moved block with from → to. */ | { type: 'shuffle'; moves: { blockId: number; from: XY; to: XY }[] } | { type: 'gameOver'; reason: 'bomb' | 'noMoves' }
One journal step = one visual beat. Steps are strictly sequential; events inside a step are simultaneous (tween them together).
XY
interface XY
A board coordinate. x is the column (0 = left), y is the row (0 = top).
x: number— The column, 0 = left.y: number— The row, 0 = top.


