A cascade win resolves in a fixed order: present the win → destroy the winners → refill the holes. Every canvas below runs the same orchestrator — reelSet.runCascade({ detectWinners, nextGrid }) — and the page walks the round’s timeline: first the presentation pass, then the destroy stage isolated two ways so you can compare the destruction itself.
1. Present the win, then destroy
WinPresenter is the same presenter you use for paylines: a “win” is just cells, whether they came from a payline, a cluster, or a cascade pop. The canvas plays a full storyboard: the reels stop, the presenter dims the board and shows the first combination, then dims and shows the second, holds a beat — and only then does the engine explode every winner and drop the refill. Both combinations go through one presenter.show([...]) call inside runCascade’s presentWinners hook (higher value presents first), and the destruction waits for the whole pass:
Loading recipe…
The minimum wiring:
const presenter = new WinPresenter(reelSet, {
dimLosers: { alpha: 0.35 },
symbolAnim: async (symbol) => symbol.playWin(), // authored win clip
});
await reelSet.runCascade({
detectWinners: (g) => yourDetector(g),
nextGrid: (prev, winners) => yourNextGrid(prev, winners),
presentWinners: async () => {
await presenter.show([ // cycles: dim + show A, dim + show B
{ id: 1, cells: comboA, value: 60 },
{ id: 2, cells: comboB, value: 30 },
]);
await wait(450); // the beat before the explosion
},
});
runCascade guarantees the order: presentWinners is awaited before destroySymbols runs (and onCascade stays the post-destroy hook), so win → explode reads as one continuous sequence.
UX guardrails
presenter.abort()onspin:start. slam-spin mid-cascade cancels cleanly.- If your destroy has no authored art and the presenter already played the visual, suppress the implode with
destroyOptions: { zIndex: null }. the destroy still snaps cells to alpha 0, invisibly. - With authored art on both stages (this canvas), drop
destroyOptionsentirely and let the two clips chain.
2. The destroy stage. engine default
The two canvases below isolate the destroy stage — no presenter pass — so the destruction itself is comparable. First, the baseline: no destruction art registered. destroySymbols falls back to the library’s GSAP scale-and-fade implode. This is what every cascade gets on day one, and it’s deliberately decent: you can build the whole game loop before an artist ships a single destruction clip.
Loading recipe…
Anatomy of a cascade drop
A real cascade is not a new spin. and crucially, survivors with no cleared slots beneath them must not move at all. Three beats, all owned by the library:
- Pop.
reelSet.destroySymbols(winners)defers to each symbol’splayDestroy()(sprite implode by default; Spine subclasses route toout). - Replace.
reel.placeSymbols(nextGrid[reelIndex])swaps the symbol identities in place. Done internally byrefill(). - Drop. per survivor, the library computes a fall distance from how many winners sat below its original row. Survivors with 0 winners below them keep their y unchanged. New symbols at the top fall in with a staggered entrance.
The whole sequence lives behind two verbs:
const winners = detectMyWins(reelSet.getVisibleGrid());
await reelSet.destroySymbols(winners);
const next = await server.cascade(winners);
await reelSet.refill({ winners, grid: next.map((visible) => ({ visible })) });
refill({ winners, grid }) reads the gravity convention from grid and animates only the cells that actually need to move.
Winners are semantic, not diffed
The legacy “diff the two grids and call everything different a winner” trick is wrong for any cascade where survivors slide past cleared slots. The survivor’s new row holds a different symbol id than it used to. diffing would treat it as a new arrival from above, even though it should slide down instead.
Always compute winners from your match-detection logic:
import type { Cell } from 'pixi-reels';
function winnersOfX(grid: string[][]): Cell[] {
const out: Cell[] = [];
for (let reel = 0; reel < grid.length; reel++) {
for (let row = 0; row < grid[reel].length; row++) {
if (grid[reel][row] === 'x') out.push({ reel, row });
}
}
return out;
}
await reelSet.destroySymbols(winnersOfX(reelSet.getVisibleGrid()));
3. The destroy stage. authored explosion
Same board, same one-shot cascade as the canvas above. The only change is one line at symbol registration:
r.register(id, SpineReelSymbol, {
spineMap,
outAnimation: 'explode', // ← the whole diff
});
playDestroy checks whether the skeleton actually has the configured out clip. With explode registered it plays the authored destruction — here a 1.27 s clip with a baked 23-frame explosion sequence, shown at full length:
Loading recipe…
When the authored clip outruns your cascade rhythm, don’t cut it — compress it. playOnTrack returns the spine TrackEntry, and entry.timeScale plays the same art in less time. The tumble-feel pages run this exact explosion between 2× and 3.2× depending on each canvas’s tempo.
Chain it with runCascade
For multi-cascade rounds, reelSet.runCascade({ detectWinners, nextGrid }) is the one-call wrapper. same beats, looped:
await reelSet.runCascade({
detectWinners: (grid) => winnersOfX(grid),
nextGrid: (prev, winners) => server.cascade(winners),
onCascade: ({ chain }) => hud.setMultiplier(chain),
});
detectWinners is your match logic. nextGrid is the gravity-correct post-refill grid: per reel, the top winners.length rows are new symbols, the rest are survivors in their original top-to-bottom order. Your server most likely already emits this shape; if not, do the transform client-side before returning.
Recycling
Recycled symbols reset view.alpha and view.scale automatically on acquisition, so you don’t need cleanup logic between stages.
Related
- Cascade 6×5 tumble. full cascade slot end-to-end.
- Tumble feels. the refill side of the same loop, five ways.
- Cascades guide. the mental model behind
destroySymbols+refill+runCascade.