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 where
placeSymbols 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 winnerRows (sorted ascending), let
nonWinnerRows 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 < winnerRows.length | yes. falls from above the viewport | R + 1 |
R ≥ winnerRows.length | no. survivor from nonWinnerRows[R - winCount] | R - originalRow (0 means: don't touch it) |
Crucially: when offsetRows === 0, the code returns early
and never creates an animation job for that cell. Its view.y
is 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'svisibleRows, 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(visibleRows, winnerRows). 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 reports
three 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 in
runCascade always returns the cells your game rules
consider winners. The remove-symbol recipe
shows the minimal case.