pixi-reels
Building blocks

Spin lifecycle

So generally unless you’ve mangled with it, your spin has 3 phases:

  • start
  • spin
  • anticipation
  • stop

Wait?! That’s four! Yeah… kinda 😮‍💨

So you might enable the anticipation explicitly before setting the result, which will make that phase appear. And in most games where you have scatters you will need that phase anyway so it comes out of the box.

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.

What events will be fired by ReelSet

When you fire off reelSet.spin(), you don’t have to poll anything to know what’s happening.

The reelset emits events at every beat of the lifecycle, and a single await reelSet.spin() walks through all of them in order.

It opens with spin:start, which tells you that reels are starting to spin. Any reelSet.spin()call fires it, no matter how it ends.

Then spin:allStarted fires once every reel (minus any you’ve held) is up to full speed and in the SPIN phase. This is the moment that matters for timing the result: it’s safe to call setResult() before this, but the engine will sit on it until this event passes.

As reels begin braking you get spin:stopping, one per reel, carrying the reel index. Held reels never fire it. Right after a reel settles, spin:reelLanded follows with that reel’s index and the symbols it landed on, so you can kick off per-reel effects (a scatter ping, a column glow) the instant each one stops rather than waiting for the whole grid.

Once the last non-held reel lands, spin:allLanded fires with the full result, the final grid in hand. And immediately after, spin:complete closes it out, carrying the same result plus the total duration. That’s your cue to run win presentation.

Pattern: fetch result mid-spin

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

const promise = reelSet.spin();                 
const response = await fetch('/api/spin').then((r) => r.json());
reelSet.setResult(response.symbols);            
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.

Player slam-stop

The library exposes three slam verbs with three different intents:

skip() is a 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() is the “slam when ready” one. You can call it before setResult() arrives. No boost, no auto-slam. Queues until setResult(), then slams once. Why? Damn, maybe the guy was slamming spin button like he was crazy, up to you.

slamStop() is an uncunditional “land it now”. No boost, no auto-slam.

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). You can change visuals of spin button using it.

Holding some reels

An expanded wild from something like Starburst or respin of single reel would require holding one or more reels in their current results.


const spin = reelSet.spin({ holdReels: [0, 4] });
reelSet.setResult(serverGrid);
await spin;

Held reels skip the spin 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.

Note that setAnticipation([...]) filters held indices silently.

Overriding spin mode

Some slots spin the reels yet use cascades after the initial spin. To achieve that you can use SpinOptions.mode - it overrides the builder’s default phase chain on a per-call basis.

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

The engine throws if you pick 'cascade' without .tumble(...) on the builder. the error names the missing method.

See the spin-then-cascade recipe.

How to nudge

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();
await reelSet.nudge(2, { distance: 1, direction: 'down', incoming: ['wild'] });

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