Cascade physics
Why survivors without cleared slots below them do not move.
A correct cascade drop has a simple invariant: a symbol only moves if there are cleared slots beneath its original row. Getting this wrong is the classic cascade bug. naive implementations shift every visible cell up by the winner count, then tween back down, making untouched symbols visually jump.
pixi-reels computes per-cell fall distance from the winners list. Survivors with no winners below them are never touched. no animation job is created, their view.y stays exactly whereplaceSymbols put it.
The algorithm, in one column
flowchart LR
subgraph Before["1. Before"]
direction TB
B0["A (row 0)"]
B1["B winner (row 1)"]
B2["C (row 2)"]
B3["D winner (row 3)"]
B4["E (row 4)"]
B0 --- B1 --- B2 --- B3 --- B4
end
subgraph Remove["2. Remove winners"]
direction TB
R0["A"]
R1["empty"]
R2["C"]
R3["empty"]
R4["E"]
R0 --- R1 --- R2 --- R3 --- R4
end
subgraph Gravity["3. Gravity"]
direction TB
G0["empty"]
G1["empty"]
G2["A · fell 2"]
G3["C · fell 1"]
G4["E · no move"]
G0 --- G1 --- G2 --- G3 --- G4
end
subgraph Fill["4. Fill from top"]
direction TB
F0["X · new"]
F1["Y · new"]
F2["A · fell 2"]
F3["C · fell 1"]
F4["E · no move"]
F0 --- F1 --- F2 --- F3 --- F4
end
Before --> Remove --> Gravity --> Fill
Per-row fall distance formula
For a column with visible rows indexed 0..V-1 and winner rows winnerCells (sorted ascending), letnonWinnerRows be the surviving row indices in order. For each final row R:
| Final row R | Is it a new symbol? | Starting y offset (slots above target) |
|---|---|---|
R < winnerCells.length | yes. falls from above the viewport | R + 1 |
R ≥ winnerCells.length | no. survivor from nonWinnerRows[R - winCount] | R - originalCell (0 means: don't touch it) |
Crucially: when offsetCells === 0, the code returns early and never creates an animation job for that cell. Its view.yis already correct from placeSymbols, and tweening it would be visually wrong.
Four canonical cases
One new symbol falls in at row 0. Every survivor stays put.
Survivors above the winner each fall 1 slot. Survivors below don't move.
Every survivor falls exactly 1 slot. New symbol fills row 0.
Two new symbols fall in. No survivor moves.
API surface
reelSet.destroySymbols(winners, opts?). the "pop" primitive. Defers to each symbol's ownplayDestroy()so subclasses (Spine, particles) get art-appropriate disintegration (the default is a brief scale/fade implode). Lifts zIndex so destroys aren't clipped.reelSet.refill({ winners, grid }). the "place + drop" primitive. Reads each reel'svisibleCells, computes per-cell fall distances fromwinners, animates only the cells that actually move. Untouched survivors stay perfectly still.reelSet.runCascade({ detectWinners, nextGrid }). the chain orchestrator. Loops detect → destroy → pause → refill untildetectWinnersreturns[]. Per-stage progress fires viacascade:chain:start/cascade:chain:end; the awaitedRunCascadeResultcarries the summary. Passsignal: AbortSignalfor caller-driven cancellation.computeDropOffsets(visibleCells, winnerCells). the algorithm the engine itself uses, exported so you can validate server-side gravity sims offline.
Why winners must be *semantic*, not diffed
Consider prevCol = [A, C, X], X at row 2 wins. The correct next col is [N, A, C]. new symbol at row 0, survivors shifted down. A naive "diff the two grids" approach reportsthree changed cells (row 0: A→N, row 1: C→A, row 2: X→C). Treating those as "three winners" would animate three new symbols falling from above, instead of one new + two survivors sliding by one slot. The library's API takes a Cell[] of match winners, not a diff. so the answer is unambiguous.
Your detectWinners callback inrunCascade always returns the cells your game rules consider winners. The cascade-destroy recipeshows the minimal case.