Spin lifecycle
A spin has three phases. Sometimes four.
Start — pulls back a little, then accelerates. Like a lever.
Spin — full speed. Waits for the server.
Anticipation — optional. Slows one reel to build tension. Only appears if
you asked for it with setAnticipation() before the result. Most scatter
games want it, so it ships ready.
Stop — brakes onto the target frame, overshoots, settles.
What events will be fired by ReelSet#
You never poll. The set tells you.
One await reelSet.spin() walks the whole chain, in order:
spin:start — reels are moving. Fires on every spin(), however it ends.
spin:allStarted — every reel (except held ones) is at full speed. This is
the beat that matters for timing. You CAN call setResult() earlier; the
engine just sits on it until this passes.
spin:stopping — one per reel, carrying its index, as it starts braking.
Held reels never fire it.
spin:reelLanding — that reel is ON its result frame: strip snapped, every
visible symbol told it landed, the bounce about to start. Carries its index
and its symbols. The frame to start anything coupled to the landing on — an
overlay growing over the reel, a per-reel SFX, a symbol takeover.
spin:reelLanded — that reel settled: the bounce is over, a whole
bounceDuration after spin:reelLanding (the same tick on a slam, which has
no bounce). Carries its index and its symbols. Use it for per-reel beats that
should wait for the rest: a scatter ping, a column glow. You do not have to
wait for the whole grid.
Each symbol hears the landing too: onReelLanded(ctx) runs on every visible
symbol just before spin:reelLanding, and ctx says which reel and cell it
landed in (reelIndex, reelCount, cell, visibleCells, symbolId). A
Spine symbol’s autoPlayLanding may be a function of it, returning true,
false or an animation name — so the symbols on the reel a takeover is about
to run on skip their landing, and the rest land as usual:
new SpineReelSymbol({
spineMap,
autoPlayLanding: ({ reelIndex }) => !copyReels.has(reelIndex),
});
reelSet.events.on('spin:reelLanding', (reelIndex) => {
if (copyReels.has(reelIndex)) overlay.growOver(reelIndex);
});
A symbol that starts a landing beat this way reports it as symbol.landing
(a promise, kept until the reel moves again), so anything that wants to play
after the landing can await it instead of hijacking the track.
spin:allLanded — last non-held reel is down. Full result attached.
spin:complete — fires immediately after, with the identical SpinResult (which already carries duration). This
is where win presentation starts.
Pattern: fetch result mid-spin#
Real slots call the server while the reels spin. That is the design.
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;
Call setResult() while the reels are spinning. Too early is fine — the
engine holds the stop until every reel is in SPIN. Never calling it is not
fine: the promise waits forever.
Player slam-stop#
Three verbs. Three intents. Pick by what the player did.
skipSpin() — “player tapped slam”. Round-aware. Throws if the result has not arrived yet; use requestSkip() in that window. The first press also boosts
speed (standard mode) or auto-slams later refills (cascade mode). Fires
skip:boosted when it boosts.
requestSkip() — “slam as soon as you can”. Safe before setResult()
arrives; it queues and fires once. No boost. This is the one for a player
mashing the button before the server answers.
slamStop() — “land it. now.” No boost, no queueing, no opinions.
All three land on whatever setResult() gave the engine, and set
result.wasSkipped === true.
Every lifecycle event still fires on the slam path. Your win presenter needs no second code path.
skipStage tells you where the round is: 0 before any press, 2 after,
1 while a protected tease is still running (see below). Drive the button’s
look from it.
Skip granularity#
By default a slam is all-or-nothing: it force-completes every active phase,
including the skippable AnticipationPhase. So a press on a teasing spin ends
the tease before the player ever sees it.
To land some reels and let others keep playing:
// The tease rule, applied by the engine.
reelSet.setAnticipation([2, 3, 4], { stagger: 'sequential', protect: 'once' });
// press 1 -> reels 0-1 land now, 2-4 keep teasing, skipStage becomes 1
// press 2 -> the tease ends too, skipStage becomes 2
// 'stepwise' walks it forward instead of ending it in one go.
reelSet.setAnticipation([2, 3, 4], { protect: 'stepwise' });
// press 1 -> reels 0-1; press 2 -> reel 2; press 3 -> reel 3;
// press 4 -> reel 4, and that press ends the round
// Or do it by hand, for your own rule.
reelSet.slamStop({ except: [3, 4] });
For a grouping the built-in modes don’t cover, use protect: 'always' so no
press can ever end a tease, and land your own groups with
slamStop({ reels }) per press.
In a tumble cascade the initial spin protects exactly the same way; what
changes is that the round’s side effect is auto-slam-refills rather than a
speed boost, and that refills carry no tease of their own (refill() clears
the anticipation set on entry). A Hold & Win board is a different shape
entirely - one single-reel ReelSet per cell - so per-reel slam has nothing to
divide there and BoardGrid.skipSpinning() is the per-cell lever instead. See
Skip & slam for both.
Two mid-spin calls that look like they would help here do not, and both are
pinned by tests. Shrinking the set with setAnticipation() does NOT release a
reel already teasing - the set is read once, when a reel leaves SPIN. And
setSpeed() does not re-time the round in flight, because spin() captures
the active profile and hands that instance to every phase (the same reason the
skipSpin() boost lands on the next spin).
protect applies to skipSpin() and requestSkip() (including a press queued
before the result arrived, which is exactly the case it exists for. call
setAnticipation BEFORE setResult so the queued press can see the tease).
Bare slamStop() stays an unconditional land-now.
skip:requested / skip:completed carry { reels, partial }, so a listener
can tell a partial slam from the round-ending one.
The related floor is minimumSpinTime, which lives on the speed profile and is
therefore one value shared by every reel. setStopDelays() is per-reel but
cannot go under it, so the two levers used to miss each other: instant was only
ever global, and per-reel could not go below the floor.
setMinimumSpinTime(ms | ms[]) overrides it per reel and closes that gap.
reelSet.setMinimumSpinTime([0, 0, 0, 900, 900]); // last two hold longer
reelSet.setMinimumSpinTime(null); // back to the profile
Reach for protect rather than a raised floor on teasing spins. If a
scatterless skip lands instantly but a teasing one settles slower, the response
time itself tells the player a feature is coming before the reels have landed.
Holding some reels#
Expanding wilds and single-reel respins need some reels to stay put.
const spin = reelSet.spin({ holdReels: [0, 4] });
reelSet.setResult(serverGrid);
await spin;
A held reel does not spin. It keeps what it is showing.
It counts as already landed, so spin:allLanded waits only on the others. It
fires no spin:stopping and no spin:reelLanded.
setAnticipation([...]) drops held indices without complaining.
Overriding spin mode#
Some slots spin first, then cascade. SpinOptions.mode swaps the phase chain
for one call.
const reelSet = new ReelSetBuilder()
// ...
.tumble({
fall: { duration: 280, ease: 'power3.in', cellStagger: 60 },
dropIn: { duration: 450, ease: 'power3.out', cellStagger: 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
Pick 'cascade' without .tumble(...) and the engine throws. The error names
the method you forgot.
See the spin-then-cascade recipe.
How to nudge#
After a spin lands, shove one reel a few cells to reveal symbols you choose:
reelSet.nudge(reel, ...).
The spin pipeline is idle while this runs. The nudge drives the strip itself.
await reelSet.spin();
await reelSet.nudge(2, { distance: 1, direction: 'forward', incoming: ['wild'] });
direction is relative to the reel’s own travel, not to the screen. See
Orientation & direction.
Several reels at once? Promise.all([...]) of independent calls.
Cancel mid-tween with NudgeOptions.signal — rejects AbortError, fires
nudge:cancelled. Or fast-forward with reelSet.skipNudge(reel), which
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#
| Event | Payload | When |
|---|---|---|
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:reelLanding | (reelIndex, symbols) | Individual reel is on its result frame, bounce not started; after every onReelLanded(). Every landing path, including cascade refill stages |
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 | ({ reels, partial }) | A slam fired. from skipSpin(), requestSkip() (after setResult), or slamStop(). partial is true when reels are still spinning after it |
skip:completed | ({ reels, partial }) | Those reels have been force-landed |
skip:boosted | ({ previous, current }) | First skipSpin() 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, an 'eval' reset, or a 'collision' (a reshape clamped it onto an occupied cell) |
pin:moved | (pin, from) | reelSet.movePin(...) resolved |
pin:migrated | (pin, info) | MultiWays reshape moved a pin to a new cell |
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 | (cellsPerReel) | MultiWays setShape(...) accepted |
adjust:start | ({ reelIndex, fromCells, toCells }) | 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#
- Phases. customize START/SPIN/STOP timing
- Events. types and exact signatures
- SpinOptions.
holdReels,mode