Cascades. the mental model
A cascade looks like one mechanic. It is two.
Split them in your head and .tumble() and
refill() stop feeling magic.
The two moments#
Moment A — the player presses spin. Everything falls off the bottom. Reels sit empty while the server thinks. New symbols drop in on the answer.
Moment B. a cascade refill.
You clear the winning cells. Survivors above a hole slide down into it. New
symbols drop in from off-window to fill the gap at the top — winners.length
of them per reel. The reels never re-spin.
Same animation engine. The split matters because:
- A is one beat: fall, wait, drop. Same shape as
spin()+setResult(). - B is N beats, one per cascade level, until nothing wins. Each beat starts on whatever the last one landed.
[CLICK] [WIN!] [WIN!] [no wins]
│ │ │ │
▼ ▼ ▼ ▼
fall ── wait ── place ── dropIn ── refill ── ── ── ── ── refill ── ── ── ── ── (end)
└────────── Moment A ──────────┘└── Moment B ──┘└────── Moment B ──────┘
Three phases, one pipeline#
Internally the engine wires three ReelPhase subclasses:
| Phase | Fires on | What it does |
|---|---|---|
cascade:fall | Moment A. spin() click | Tweens every visible symbol off the bottom of the viewport, then waits at speed 0. |
cascade:place | Both moments | Swaps the symbol identities for the new grid and snaps every view to its grid position. Survivors are made visible immediately; new arrivals stay at alpha 0 until cascade:dropIn repositions them above the viewport. |
cascade:dropIn | Both moments | Tweens each “moving” view from its origin (above the viewport for new symbols, its old grid cell for survivors) down to its current grid position. Survivors that didn’t move skip the tween entirely. |
Per-symbol events (cascade:fall:symbol, cascade:dropIn:symbol) fire
before the library’s tween, so your parallel tween finishes in lockstep
with it.
Chain and destroy events sit on the same bus:
| Event | When | What it’s for |
|---|---|---|
cascade:chain:start | Each chain stage opens (after detectWinners, before destroySymbols) | Per-chain SFX cue, light up a chain counter, freeze the spin button. |
cascade:destroy:start / cascade:destroy:end | Around every destroySymbols(...) batch. direct calls included | Shatter SFX, viewport dim independent of destroyOptions.dim. |
cascade:chain:end | Each chain stage closed (after refill drop-in settled) | Per-chain teardown, queue the next decoration burst. |
runCascade emits the chain events. Hand-roll the loop with bare refill()
calls and only the per-phase ones fire.
Override any phase by registering a subclass under its name:
builder
.tumble({ /* ... */ })
.phases((f) => f.register('cascade:fall', MyCometFallPhase));
The other two keep their defaults. Override a phase only when events and config genuinely cannot express what you want. Usually a per-symbol listener is the tool.
Refill geometry. the survivor convention#
Your next grid follows one convention, per reel: the first winners.length
cells are new symbols, the rest are survivors in their original order.
That is what server-side gravity sims already emit.
Reel ['A','B','C','D','E'], winners at cells 1 and 3:
Pre-refill: Post-refill: Why:
cell 0: A cell 0: ?(new) ── new symbol enters from above
cell 1: B ← cell 1: ?(new) ── new symbol enters from above
cell 2: C cell 2: A ── survivor cell 0 slid down 2
cell 3: D ← cell 3: C ── survivor cell 2 slid down 1
cell 4: E cell 4: E ── survivor cell 4, no movement
Follows the convention? Pass it straight to refill({ winners, grid }).
Does not? Do the transform client-side first.
computeDropOffsets is exported. It is the engine’s own algorithm, so you can
check your server output against it offline.
The verbs you’ll actually use#
Three calls cover every cascade slot:
// 1. Fall, wait, drop. Symmetric to a normal spin.
const spinDone = reelSet.spin(); // or spin({ mode: 'cascade' }) if you also use standard mode
reelSet.setResult(await server.spin());
await spinDone;
// 2. Clear winning cells. The library defers to each symbol's playDestroy().
const winners = detectWinners(reelSet.getVisibleGrid());
await reelSet.destroySymbols(winners);
// 3. Refill survivors + new symbols.
// grid is ColumnTarget[]. for the common per-reel visible case, wrap
// your server's string[][] with .map(visible => ({ visible })). For
// big-symbol anchors that land into the buffer, use the full
// ColumnTarget shape with bufferStart / bufferEnd.
const next: string[][] = await server.cascade(winners);
await reelSet.refill({ winners, grid: next.map((visible) => ({ visible })) });
That is one win, one refill. Multi-cascade? Loop it:
while (true) {
const winners = detectWinners(reelSet.getVisibleGrid());
if (winners.length === 0) break;
await reelSet.destroySymbols(winners);
await wait(PAUSE_AFTER_REMOVAL_MS);
const next = await server.cascade(winners);
await reelSet.refill({ winners, grid: next.map((visible) => ({ visible })) });
}
Or call reelSet.runCascade({ detectWinners, nextGrid }). Same loop, plus
per-stage chain events and a RunCascadeResult summary. Both callbacks may
be async — nextGrid is where you await server.cascade(winners).
Per-cascade options#
What tumble slots actually tune:
| Option | Default | When to set |
|---|---|---|
pauseAfterDestroyMs | 250 | Tighten to 150 for snappy turbo modes; lengthen to 500 for cinematic feels. |
maxChain | 32 | Safety cap. Bump up only if your game can legitimately exceed 32 cascades. |
onCascade | . | Per-cascade hook. Bump multipliers, fire SFX, run “winners gone” UI here. Return a Promise to delay the next refill. |
destroyOptions | {} | Forwarded to destroySymbols(...). Per-cell stagger, lift z-index, viewport dim. |
signal | . | AbortSignal for caller-driven cancellation. See cancelling a chain. |
await reelSet.runCascade({
detectWinners,
nextGrid,
pauseAfterDestroyMs: 180, // snappy
destroyOptions: {
delay: (cell, i) => i * 0.03, // left-to-right disintegration
dim: true, // dim the viewport while pops play
},
onCascade: async ({ chain }) => {
bumpMultiplier(chain);
await playWinSound(chain); // delays the next refill until the sound resolves
},
});
DestroySymbolsOptions is delay, zIndex, dim and signal. delay takes a function of the cell:
destroyOptions: {
delay: (cell, i) => i * 0.02, // first-to-last stagger
zIndex: 1500, // lift above the default 1000
dim: 0.5, // custom viewport dim alpha
}
Cancelling a chain via AbortSignal#
skipSpin() slams the phase in flight. But it early-returns when the engine
is idle — between two refills, or during the pause. Tap in that window and
nothing happens. The chain keeps going.
Use an AbortController instead:
const controller = new AbortController();
skipButton.addEventListener('click', () => controller.abort());
await reelSet.runCascade({
detectWinners,
nextGrid,
signal: controller.signal,
});
// summary.wasSkipped === true after an abort
When the signal aborts, runCascade:
- Sets the internal
wasSkippedflag. - If a
refill()is in flight, callsslamStop()so the await unblocks immediately. - Exits at the next await boundary.
- Resolves with
RunCascadeResultcarryingwasSkipped: true.
That is the slam pattern for cascades. skipSpin() is still right while the
engine is moving. They work together.
refill() or runCascade()?#
Start with runCascade. It is a one-screen wrapper around refill, and it
gets the slam path right for free. Drop to bare refill when your chain needs
something the options cannot say — asymmetric pauses, per-level branching, a
game loop that already owns the chain. You will lift the same five lines into
your own handler. There is no magic to lose.
Composing with the rest of the library#
Cascades are not a separate mode. Everything else still works:
setDropOrder('all')before each refill makes the canonical “every column drops together” pattern. Set once beforerunCascadeand it persists across every refill in the chain.setStopDelays([...])gives you per-reel custom delays for unusual reveal shapes (V-shape, outside-in, etc.).speed.set('turbo')changes the strip-spin speed on standard rounds; cascade phase durations are static (seeTumbleConfig). To do “fast cascades” you swap the.tumble({})config or override the phase.pin(...)persists a cell across cascades. A pinned wild stays put through every refill in the round.reelSet.viewport.showDim(alpha)dims the whole board. pair withdestroySymbols(..., { dim: true })for a focus-pull on the winners.
The empty wait#
Between cascade:fall:end and cascade:dropIn:start the board is empty and
the server has not answered. Show a spinner on fall end and hide it on the
first drop-in, or show nothing — under 100ms of server time nobody notices.
If the gap bothers you, shrink fall.duration and let dropIn carry the
energy.
The pause matters#
Put a beat between winners fading out and the refill starting. Without it the refill begins on the frame the winners hit alpha 0, and the player reads it as a teleport. 150-500ms and the brain sees two events instead: wins cleared, then new symbols arrived. 250ms is the default for a reason; ~120ms for a snappy slam, ~350ms for bouncy.
pauseAfterDestroyMs in runCascade(), or await wait(ms) in your own loop.
When to override a phase vs. listen to events#
- Run something alongside the library’s tween? Listen to
cascade:fall:symbol/cascade:dropIn:symbol. They fire first, so your tween locks to theirs. - Decorate symbols after they land? Listen to
cascade:place:end. UseisInitialandwinnerCellsto skip survivors. - Hold a beat between fall and drop-in? You cannot gate a phase from a
listener. Override
cascade:placeand put the beat in itsonEnter. - Symbols fly sideways instead of falling? Override
cascade:fall.
Override only when 1-3 do not fit. You then owe the full ReelPhase
contract: onEnter, update, onSkip, _complete.
Recipes by purpose#
| If you want… | Look at |
|---|---|
| The minimal cascade slot end-to-end | Cascade 6×5 tumble |
| Compare animation feels side-by-side | Tumble feels |
| Compare refill reveal orders | Cascade refill orders |
| Adjust the click -> fall delay (lead-in) | Fall delays |
| Cascades on a strip-spin landing | Spin then cascade |
| Cluster wins + cascades on MultiWays | MultiWays cascade |
Drive fades via WinPresenter | Cascade wins. present, destroy, refill |
| The full vocabulary, in depth | docs/recipes/tumble-cascade.md |
Related references#
ReelSetBuilder.tumble()reference. every config field with defaults and trade-offsReelSet.refill,destroySymbols,runCascade. verb signatures- Cascade event surface. payload shapes