PR pixi-reels
Building blocks

Spin lifecycle

A single spin moves through four phases per reel:

START → SPIN → [ANTICIPATION] → STOP → landed

START accelerates from rest with a tiny step-back “pull.” SPIN holds full speed until the server answer arrives. ANTICIPATION (optional) slows a specific reel for dramatic tension. STOP decelerates onto the target frame with a bounce.

The happy path

reelSet.events.on('spin:start',       () => console.log('pulled the handle'));
reelSet.events.on('spin:allStarted',  () => console.log('all reels at full speed'));
reelSet.events.on('spin:stopping',    (i) => console.log('reel', i, 'braking'));
reelSet.events.on('spin:reelLanded',  (i, symbols) => console.log('reel', i, 'landed on', symbols));
reelSet.events.on('spin:allLanded',   (result) => console.log('final grid', result.symbols));
reelSet.events.on('spin:complete',    (result) => console.log('spin done in', result.duration, 'ms'));

await reelSet.spin();           // triggers all of the above

Pattern: fetch result mid-spin

Real slots call the server while the reels are spinning. pixi-reels is built for exactly this.

const promise = reelSet.spin();                 // reels accelerate and spin
const response = await fetch('/api/spin').then((r) => r.json());
reelSet.setResult(response.symbols);            // ← triggers STOP phase
if (response.anticipationReels?.length) {
  reelSet.setAnticipation(response.anticipationReels);
}
const result = await promise;

setResult() must be called while the reels are spinning. If you call it too early (before spin:allStarted), the engine defers the stop until all reels are in the SPIN phase.

Pattern: player slam-stop

The library exposes three slam verbs with three different intents:

VerbIntentSide effects
skip()Round-aware “player tapped the slam button”First press of the round also boosts speed (standard mode) or auto-slams future refills (cascade mode). Emits skip:boosted when boost applies.
requestSkip()”Slam when ready”. safe BEFORE setResult() arrivesNo boost, no auto-slam. Queues until setResult(), then slams once.
slamStop()Unconditional “land NOW”. tests / anti-cheat / programmaticNo boost, no auto-slam.
button.addEventListener('click', () => {
  if (reelSet.isSpinning) {
    // requestSkip is the safe variant. if the player taps before the
    // server response arrives, it queues until setResult() and then
    // slams. skip() in pre-result state is a no-op.
    reelSet.requestSkip();
  } else {
    reelSet.spin();
  }
});

All three force-land on whatever setResult() told the engine. result.wasSkipped === true. The lifecycle hooks (spin:reelLanded, spin:allLanded, spin:complete) all still fire on the slam path. so win presenters and effect chains keep working without a separate code path.

The skipStage getter reports the round’s stage (0 before any press, 2 after). drive a “SPIN → SKIPPED” button label from it.

Pattern: hold some reels

// Reels 0 and 4 are frozen for this spin. only the middle three reroll.
const spin = reelSet.spin({ holdReels: [0, 4] });
reelSet.setResult(serverGrid); // entries at held indices are ignored
await spin;

Held reels skip START / SPIN / STOP entirely and stay on whatever symbols they’re currently showing. They count as already-landed for spin:allLanded, so the resolver fires when all non-held reels land. No spin:reelLanded / spin:stopping fires for held reels. setAnticipation([...]) filters held indices silently. See the hold-and-win recipe for when to use this vs the per-cell pattern.

Pattern: hybrid spin-then-cascade

const reelSet = new ReelSetBuilder()
  // ...
  .tumble({
    fall:   { duration: 280, ease: 'power3.in',  rowStagger: 60 },
    dropIn: { duration: 450, ease: 'power3.out', rowStagger: 60, distance: 'perHole' },
  })
  .ticker(app.ticker)
  .build();

await reelSet.spin();                  // round 1. strip-spin (default mode)
await reelSet.spin({ mode: 'cascade' }); // respin. cascade drop-in

SpinOptions.mode overrides the builder’s default phase chain on a per-call basis. The engine throws if you pick 'cascade' without .tumble(...) on the builder. the error names the missing method. See the spin-then-cascade recipe.

Pattern: nudge after landing

After a spin lands, you can shift a single reel by N positions to reveal caller-supplied symbols via reelSet.nudge(col, ...). The spin pipeline is idle during a nudge; the nudge’s own tween drives the strip.

await reelSet.spin();
// landed on a near-miss
await reelSet.nudge(2, { distance: 1, direction: 'down', incoming: ['wild'] });
// reel 2 has now shifted; nudge:complete fired

Multi-reel beats are Promise.all([...]) of independent calls. Cancel mid-tween via NudgeOptions.signal (rejects with AbortError + nudge:cancelled), or fast-forward via reelSet.skipNudge(col) (resolves normally).

Read the full contract in the nudge guide. Live recipes: nudge, skip, abort, stagger, spotlight after a nudge, big symbols.

Full event map

EventPayloadWhen
spin:start.Any spin() call
spin:allStarted.Every (non-held) reel is in SPIN phase
spin:stopping(reelIndex)A reel begins STOP (held reels never fire)
spin:reelLanded(reelIndex, symbols)Individual reel landed (held reels never fire)
spin:allLanded(result)Last non-held reel landed
spin:complete(result)Just after spin:allLanded
skip:requested.A slam fired. from skip(), requestSkip() (after setResult), or slamStop()
skip:completed.All non-held reels force-landed
skip:boosted({ previous, current })First skip() press of a standard-mode round; engine bumped speed to the fastest registered profile for the rest of the round. Cascade mode auto-slams refills instead.
speed:changed(profile, previous)setSpeed() called
spotlight:start(positions)spotlight.cycle(...) began
spotlight:end.Spotlight finished
pin:placed(pin)reelSet.pin(...) succeeded
pin:expired(pin, reason)Pin removed by unpin, turns exhausted, or 'eval' reset
pin:moved(pin, from)reelSet.movePin(...) resolved
pin:migrated(pin, info)MultiWays reshape moved a pin to a new row
pin:overlayCreated(pin, symbol)Mid-spin overlay symbol mounted for a pin
pin:overlayDestroyed(pin, symbol)Mid-spin overlay torn down on land
shape:changed(rowsPerReel)MultiWays setShape(...) accepted
adjust:start({ reelIndex, fromRows, toRows })AdjustPhase entered for a reel
adjust:complete({ reelIndex })AdjustPhase finished
nudge:start({ reelIndex, distance, direction })reelSet.nudge(...) pre-placement done; tween about to begin
nudge:complete({ reelIndex, distance, direction, symbols })Nudged reel has snapped to its new grid position
nudge:cancelled({ reelIndex, distance, direction, reason })NudgeOptions.signal aborted or the reel was destroyed mid-tween. Does not fire alongside nudge:complete.
destroyed.destroy() called

Deeper dive