Build your first cascade slot
You’ll build a 6×5 cascade slot. drop on click, detect 3-in-a-row wins, vanish the winners, refill from above, repeat until no more wins. ~80 lines of TypeScript. No prior pixi-reels knowledge required; PixiJS basics help.
By the end you’ll have a working slot. If you already know pixi-reels, skim the cascades guide for the mental model and the API reference for the verbs.
Step 0. Install
npm install pixi-reels pixi.js gsap
pixi-reels requires PixiJS v8 and uses GSAP for its tweens. Both are peer deps.
Step 1. Boot PixiJS
import { Application } from 'pixi.js';
import { gsap } from 'gsap';
const app = new Application();
await app.init({ background: 0xffffff, resizeTo: window, antialias: true });
document.body.appendChild(app.canvas);
// CRITICAL: sync GSAP to PixiJS so tweens run in hidden tabs / iframes.
gsap.ticker.remove(gsap.updateRoot);
app.ticker.add(() => gsap.updateRoot(app.ticker.lastTime / 1000));
The GSAP sync looks weird but it’s mandatory. otherwise GSAP freezes in background tabs, and your cascade animations stall mid-drop. See the GSAP gotcha section in CLAUDE.md.
Step 2. Wire the reel set
import { ReelSetBuilder, SpriteSymbol, SpeedPresets } from 'pixi-reels';
const SYMBOLS = ['A', 'B', 'C', 'D', 'wild'];
const reelSet = new ReelSetBuilder()
.reels(6)
.visibleRows(5)
.symbolSize(95, 95)
.symbolGap(5, 5)
.symbols((r) => {
for (const id of SYMBOLS) {
r.register(id, SpriteSymbol, { textures }); // your loaded sprite textures
}
})
.weights({ A: 18, B: 14, C: 10, D: 6, wild: 2 })
.speed('normal', SpeedPresets.NORMAL)
.tumble({
fall: { duration: 280, ease: 'sine.in', rowStagger: 40 },
dropIn: { duration: 480, ease: 'back.out(1.6)', rowStagger: 50, distance: 'perHole' },
})
.ticker(app.ticker)
.build();
app.stage.addChild(reelSet);
.tumble({...}) flips the default spin mode to cascade and wires the three phase classes (cascade:fall, cascade:place, cascade:dropIn). The config is animation parameters; everything else (multipliers, SFX, win-clear) is your code listening to events.
Step 3. A fake server
In production you call your real backend. For this tutorial, a function that returns a random grid (and a cascade refill that follows the gravity convention):
function randSymbol(): string {
const weights: Record<string, number> = { A: 18, B: 14, C: 10, D: 6, wild: 2 };
const total = Object.values(weights).reduce((a, b) => a + b, 0);
let r = Math.random() * total;
for (const [id, w] of Object.entries(weights)) {
r -= w;
if (r <= 0) return id;
}
return 'A';
}
const server = {
spin: async (): Promise<string[][]> => {
await new Promise((r) => setTimeout(r, 400 + Math.random() * 600));
return Array.from({ length: 6 }, () => Array.from({ length: 5 }, randSymbol));
},
cascade: async (prev: string[][], winners: { reel: number; row: number }[]) => {
await new Promise((r) => setTimeout(r, 120));
const winnersByReel = new Map<number, Set<number>>();
for (const w of winners) {
if (!winnersByReel.has(w.reel)) winnersByReel.set(w.reel, new Set());
winnersByReel.get(w.reel)!.add(w.row);
}
return prev.map((col, reel) => {
const losers = winnersByReel.get(reel);
if (!losers || losers.size === 0) return [...col];
const survivors = col.filter((_, row) => !losers.has(row));
const fillers = Array.from({ length: losers.size }, randSymbol);
// Convention: top winners.length rows = new, rest = survivors in original order.
return [...fillers, ...survivors];
});
},
};
The cascade function follows the gravity convention. If you have a real server, this is the contract its response must follow.
Step 4. Detect winners
Game-rule code. left-anchored runs of 3+ matching symbols on the same row count as wins. wild matches anything.
import type { Cell } from 'pixi-reels';
function detectWinners(grid: string[][]): Cell[] {
const winners: Cell[] = [];
for (let row = 0; row < 5; row++) {
const head = grid[0][row];
if (head === 'wild') continue;
let run = 1;
for (let reel = 1; reel < 6; reel++) {
if (grid[reel][row] === head || grid[reel][row] === 'wild') run++;
else break;
}
if (run >= 3) {
for (let reel = 0; reel < run; reel++) winners.push({ reel, row });
}
}
return winners;
}
Step 5. Drive a round
runCascade is the one-call replacement for a hand-rolled while loop. It:
- Calls
detectWinners(grid)with the current visible grid. - If empty, ends the chain and resolves with
RunCascadeResult({ chainLength, totalWinners, finalGrid, wasSkipped }). - Otherwise fires
cascade:chain:start, callsreelSet.destroySymbols(winners), thennextGrid(grid, winners), thenreelSet.refill(...), then firescascade:chain:end. - Loops back to step 1 with the post-refill grid.
It also takes an AbortSignal so a button-tap mid-round can end the chain cleanly. we’ll wire that up in Step 6, so the signature accepts one from the start:
async function playRound(signal: AbortSignal): Promise<void> {
// Moment A. fall, wait, drop in.
reelSet.setDropOrder('ltr'); // left-to-right reveal
const spinDone = reelSet.spin();
reelSet.setResult(await server.spin());
await spinDone;
// Moment B. cascade refill loop. runCascade owns the orchestration;
// your two callbacks own the game rules.
reelSet.setDropOrder('all'); // all columns drop together on refills
const { chainLength, totalWinners, wasSkipped } = await reelSet.runCascade({
detectWinners: (grid) => detectWinners(grid),
nextGrid: (prev, winners) => server.cascade(prev, winners),
onCascade: ({ chain, winners }) => {
console.log(`cascade ${chain}: ${winners.length} winners`);
},
signal, // honors player slam between refills
});
console.log(
`round done: ${chainLength} cascades, ${totalWinners} total winners` +
(wasSkipped ? ' (slammed)' : ''),
);
}
Step 6. Wire the button
const button = document.createElement('button');
button.textContent = 'SPIN';
button.style.cssText = 'position:fixed;bottom:20px;left:50%;transform:translateX(-50%);padding:16px 32px;font-size:18px;';
document.body.appendChild(button);
let isPlaying = false;
let cascadeAbort: AbortController | null = null;
button.addEventListener('click', async () => {
if (isPlaying) {
reelSet.skipSpin(); // slam in-flight engine motion (no-op if idle)
cascadeAbort?.abort(); // end the cascade chain at the next await boundary
return;
}
isPlaying = true;
cascadeAbort = new AbortController();
try {
await playRound(cascadeAbort.signal);
} finally {
cascadeAbort = null;
isPlaying = false;
}
});
The two-pronged slam handler is deliberate:
reelSet.skipSpin()slams the engine’s in-flight motion. If the engine is idle (between refills, during thepauseAfterDestroyMsbeat) it is a no-op, so the call is safe to make unconditionally.AbortController.abort()flips a flagrunCascadeobserves at the next await boundary. Works whether the engine is mid-refill or idle.
Combined: the engine slams its in-flight motion AND the cascade chain ends. See the cancellation section in the cascades guide for the rationale.
Step 7. Try it
Click SPIN. Reels fall, the server roundtrip (400-1000 ms) shows empty reels, new symbols drop in. If detectWinners finds a 3-in-a-row, the winners disintegrate and survivors slide down. Loop until no more wins.
Open the browser console. window.__PIXI_REELS_DEBUG.log() prints the visible grid and engine state. Useful when something looks wrong.
What you didn’t have to do
Compare with hand-rolling cascades:
- No tween bookkeeping.
tumble({ fall, dropIn })owns the animation timing. - No survivor/gravity math.
refill({ winners, grid })reads the gravity convention from yourgridand animates the right offsets. - No “fade then drop” choreography.
destroySymbolsdefers to each symbol’splayDestroy(), and the engine sequencesplaceanddropInfor you. - No slam-state machine.
skip()is round-aware;wasSkippedpropagates through the chain.
Next steps
Now that the slot works:
- Pick a feel. five preset
tumble({...})shapes side-by-side in tumble feels. - Tune the lead-in. the pause between SPIN click and first frame of fall. see fall delays.
- Add a cascading multiplier. bump a meter inside
onCascade. see cascade-6x5 recipe. - Use Spine symbols with custom disintegration. your
SpineReelSymbolsubclass overridesplayDestroy()to play theoutanimation; everything else stays the same. See the pyramid-cascade example for the spine wiring. - Spin first, cascade after. open with a strip-spin landing, then cascade. see spin then cascade.
Stuck? Read the cascades guide. the mental model is what makes the verbs click.