pixi-reels
Building blocks

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:

PhaseFires onWhat it does
cascade:fallMoment A. spin() clickTweens every visible symbol off the bottom of the viewport, then waits at speed 0.
cascade:placeBoth momentsSwaps 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:dropInBoth momentsTweens 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:

EventWhenWhat it’s for
cascade:chain:startEach 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:endAround every destroySymbols(...) batch. direct calls includedShatter SFX, viewport dim independent of destroyOptions.dim.
cascade:chain:endEach 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:

OptionDefaultWhen to set
pauseAfterDestroyMs250Tighten to 150 for snappy turbo modes; lengthen to 500 for cinematic feels.
maxChain32Safety 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 wasSkipped flag.
  • If a refill() is in flight, calls slamStop() so the await unblocks immediately.
  • Exits at the next await boundary.
  • Resolves with RunCascadeResult carrying wasSkipped: 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 before runCascade and 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 (see TumbleConfig). 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 with destroySymbols(..., { 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#

  1. Run something alongside the library’s tween? Listen to cascade:fall:symbol / cascade:dropIn:symbol. They fire first, so your tween locks to theirs.
  2. Decorate symbols after they land? Listen to cascade:place:end. Use isInitial and winnerCells to skip survivors.
  3. Hold a beat between fall and drop-in? You cannot gate a phase from a listener. Override cascade:place and put the beat in its onEnter.
  4. 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-endCascade 6×5 tumble
Compare animation feels side-by-sideTumble feels
Compare refill reveal ordersCascade refill orders
Adjust the click -> fall delay (lead-in)Fall delays
Cascades on a strip-spin landingSpin then cascade
Cluster wins + cascades on MultiWaysMultiWays cascade
Drive fades via WinPresenterCascade wins. present, destroy, refill
The full vocabulary, in depthdocs/recipes/tumble-cascade.md