The mechanic
Each SPIN click plays the full cascade sequence from a single click:
- Land on stage 0 via
reelSet.spin()+setResult(...). reelSet.runCascade({ detectWinners, nextGrid })owns the chain.- For every cascade level: detect winners →
destroySymbols→ pause →refill. - When
detectWinnersreturns[], the awaitedrunCascadepromise resolves withRunCascadeResultand the round ends.
The library owns the orchestration; your two callbacks own the game rules (what’s a winner, what’s the next grid).
Setup
1. Ship a single-click cascade with a batch response
If your server returns all cascade stages in one response:
async function spin() {
const response = await fetch('/api/spin').then((r) => r.json());
// response.stages: string[][][]. stage 0 first, tumble stages next.
// Land the first stage via the regular spin API.
const p = reelSet.spin();
reelSet.setResult(response.stages[0]);
await p;
// Cascade through the remaining stages.
let level = 0;
reelSet.setDropOrder('all');
await reelSet.runCascade({
detectWinners: (grid) =>
level + 1 < response.stages.length
? diffWinners(grid, response.stages[level + 1])
: [],
nextGrid: () => {
level += 1;
return response.stages[level];
},
onCascade: ({ chain }) => hud.showMultiplier(chain + 1),
pauseAfterDestroyMs: 140,
});
}
diffWinners is whatever match-detector your game uses. it’s NOT a naive grid diff, because survivors that slide past cleared rows would look “changed” by id. Compute winners from your match rules.
2. Stream stages from a server that dribbles responses
If your backend computes cascades one at a time and streams them, do the same shape but await the next stage inside nextGrid:
const stream = streamFromServer();
const first = (await stream.next()).value!;
const p = reelSet.spin();
reelSet.setResult(first);
await p;
await reelSet.runCascade({
detectWinners: (g) => yourMatchDetector(g),
nextGrid: async () => {
const r = await stream.next();
return r.done ? r.value /* shouldn't happen if detect returns [] first */ : r.value;
},
pauseAfterDestroyMs: 100,
});
nextGrid can be async, so awaiting a fresh server response between cascades is a one-liner.
3. Custom vanish animation
By default, destroySymbols defers to each symbol’s playDestroy() (sprite implode by default). Three ways to customize:
- Per-symbol. your
SpriteSymbol/SpineSymbolsubclass overridesplayDestroy(). Every cascade uses it. - Per-cascade options. pass
destroyOptions: { direction, delay, dim }torunCascade. - Replace entirely. drive the visual yourself inside
onCascade, then passdestroyOptions: { zIndex: null }so the engine’s implode is invisible. See cascade with WinPresenter.
4. Identifying winners
The canonical winner shape is Cell[]. { reel, row } for every cell your match rules consider a winner. Compute it from the grid your detectWinners callback receives; never from a naive diff of “the next grid differs at row X.” That bug only surfaces when survivors slide past cleared rows, and is the most common cascade implementation mistake.
Cheats on this page
- One-click 4-stage cascade (default on).
cascadingStages(STAGES)bundles the whole tumble in one response;runCascadeconsumesmeta.stagesvianextGrid. - One stage per click (legacy).
cascadeSequence(STAGES); each click lands one stage. Useful for stepping through the sequence manually.
Test the chain
import { computeDropOffsets } from 'pixi-reels';
// Validate your server's gravity sim offline.
const offsets = computeDropOffsets(5 /* visible rows */, [1, 3] /* winnerRows */);
expect(offsets.find(o => o.row === 0)?.offsetRows).toBe(1); // new symbol, 1 slot
expect(offsets.find(o => o.row === 4)?.offsetRows).toBe(0); // survivor, no move
For end-to-end tests that drive a real ReelSet, use the testing utilities. createTestReelSet, FakeTicker, spinAndLand, captureEvents.