Phases
A phase is one slice of a reel’s lifecycle. Extend ReelPhase<TConfig>,
implement three hooks.
ReelPhase<TConfig, TProfile = SpeedProfile>
├─ onEnter(config) // set up tweens, set reel.speed
├─ update(deltaMs) // called every frame while active
└─ onSkip(ctx) // a press reached the phase: ctx.mode says slam or quicken
Every phase runs on ONE reel. Five reels in StopPhase are five instances, and
SpinController builds them per reel per spin through the PhaseFactory -
there are no long-lived phase objects to hold state across spins.
Built-ins#
Every one of these is exported, so you can subclass it as well as replace it. The KEY is the name to register under; registering a different class under an existing key is what swaps the built-in out.
| Class | Key | Config | Role | Skippable |
|---|---|---|---|---|
StartPhase | 'start' | StartPhaseConfig | Accelerate from rest, with a step-back pull | yes |
SpinPhase | 'spin' | SpinPhaseConfig | Full speed until the result arrives | no |
AnticipationPhase | 'anticipation' | AnticipationPhaseConfig | Slow hold, for tension | yes |
StopPhase | 'stop' | StopPhaseConfig | Decelerate onto the target frame, bounce | yes |
CascadeFallPhase | 'cascade:fall' | CascadeFallPhaseConfig | Tumble fall-out. replaces StartPhase | yes |
CascadePlacePhase | 'cascade:place' | CascadePlacePhaseConfig | Tumble identity swap | yes |
CascadeDropInPhase | 'cascade:dropIn' | CascadeDropInPhaseConfig | Tumble drop-in and gravity slide | yes |
AdjustPhase | 'adjust' | AdjustPhaseConfig | MultiWays reshape. tweens pin overlays | yes |
SpinPhase is the one that cannot be skipped: it is not animating toward
anything, it is waiting for setResult(). A slam resolves it instead. Which is
also why a per-reel minimumSpinTime matters. SpinPhase is where the floor
that decides how early a reel may stop actually lives.
The bottom four are wired only when you asked for them. the cascade three by
builder.tumble(...), AdjustPhase by builder.multiways(...). A plain slot
never sees them.
Register a custom phase#
import { ReelPhase } from 'pixi-reels';
class FlashPhase extends ReelPhase<{ duration: number }> {
readonly name = 'flash';
readonly skippable = true;
private _elapsed = 0;
private _duration = 0;
protected onEnter(cfg: { duration: number }): void {
this._elapsed = 0;
this._duration = cfg.duration;
}
update(deltaMs: number): void {
this._elapsed += deltaMs;
// ... toggle reel alpha or something
if (this._elapsed >= this._duration) this._complete();
}
protected onSkip(ctx: SkipContext): void { /* slam: cleanup; quicken: cut a wait */ }
}
builder.phases((f) => f.register('flash', FlashPhase));
Replacing built-ins works the same way. register with the same name ('start',
'spin', 'anticipation', 'stop') to override - and that is the only way a
from-scratch phase ever runs. The controller asks the factory for the
lifecycle keys and nothing else, so a phase registered under a NAME OF ITS OWN
like 'flash' above is orphaned: nothing constructs it. Own a phase by taking
its key.
Loading recipe…
What a phase may call#
A phase drives its reel through this.reel. Everything the built-in phases
call on it is the contract a custom phase may rely on too, and it is in the
published typings:
| Call | Does | Used by |
|---|---|---|
reel.beginMotion() | Re-masks lifted unmask symbols the instant the reel begins to move. Idempotent. | StartPhase on launch |
reel.notifySpinStart() | Tells every symbol it is in a spin (blur, static-spin presentations). Idempotent. | StartPhase at full speed, and from onSkip |
reel.forceSpeed(v) | Jump to a speed, drive included. Assigning reel.speed from a phase is ramped back under the drive model; this is not. | StartPhase.onSkip, AnticipationPhase.onSkip |
reel.placeStrip(frame) | Place a full strip, buffers included. placeSymbols is the visible-window form for game code and drops a big symbol’s tail parked in a buffer row. | StopPhase.onSkip |
this.land(cells?) | Halt the drive, snap to the grid, tell the symbols the spin ended and that they landed, lift unmask art, raise spin:reelLanding. The only way a phase lands a reel. | StopPhase’s land step |
this.bounce(options?) | The landing overshoot, carrying lifted views along on every frame. Returns { done, cancel() }; done settles on the rest position or right after cancel(). Defaults from the profile, BounceOptions (distance, duration, ease, and animation for a different shape) per call. | StopPhase’s bounce step |
land() and bounce() are public on the base, so a step written outside the
class reaches them as ctx.phase.land() / ctx.phase.bounce(). The reel’s
own haltDrive, snapToGrid, notifySpinEnd, notifyLanded and
offsetLiftedViews stay internal: the two helpers are their whole use.
this.reel and this.speed are the public getters; this._reel and
this._speed are the same fields, kept for subclasses written against them.
Two guarantees a phase that never moves its reel relies on: SpinPhase never
writes reel.speed, and Reel.update does nothing at speed === 0. A stop
that places its frame and slides it in, with no spin-out at all, is supported.
Timing that belongs to the phase can ride on the speed profile. Declare the
profile the phase reads as the second type parameter and this._speed sees
the extra fields; register() infers it from the class, so the registration
needs no cast either. The manager hands every phase the profile instance the
game registered, which is why the fields are there at run time.
interface InstantProfile extends SpeedProfile { slideMs: number }
class InstantStopPhase extends ReelPhase<StopPhaseConfig, InstantProfile> {
readonly name = 'stop';
readonly skippable = true;
private _bounce: ReelBounce | null = null;
protected onEnter(config: StopPhaseConfig): void {
this.reel.forceSpeed(0);
this.reel.placeStrip(config.targetFrame);
// ...slide the container in over this._speed.slideMs, then:
this.land();
this._bounce = this.bounce();
void this._bounce.done.then(() => this._complete());
}
update(): void {}
protected onSkip(): void { this._bounce?.cancel(); }
}
builder
.speed('normal', { ...SpeedPresets.NORMAL, slideMs: 140 })
.phases((f) => f.register('stop', InstantStopPhase));
Loading recipe…
Steps: edit a built-in without subclassing it#
A built-in phase runs a named list of steps in order, and hands a game that
list before it runs. Re-register the same class under the same key with
options.steps:
import { StopPhase, step, insertAfter, replaceStep, removeStep } from 'pixi-reels';
builder.phases((f) => f.register('stop', StopPhase, {
steps: (steps) => insertAfter(steps, 'land', step('flash', (ctx) => flash(ctx.reel.reelIndex))),
}));
| Phase | Steps, in order | Cut by a quicken |
|---|---|---|
StartPhase | delay, launch, pull (only on a profile that bounces), accelerate, announce | delay |
StopPhase | delay, spinOut, land, bounce | delay |
AnticipationPhase | tease (the whole tease, whichever shape) | tease |
SpinPhase, cascade phases, AdjustPhase | none; they wait or run one parallel timeline |
A step is step(name, (ctx) => result, { cut? }). The result is waited on: a
gsap tween or timeline (killed when a slam cuts it), a promise (told to stop
through ctx.signal), a cancellable such as what ctx.phase.bounce()
returns, or nothing. ctx carries reel, profile, config, gsap,
container, main (the container property that is the travel axis),
phase, signal, quickened, and two waits a slam can cut: wait(ms) on
the gsap clock and until(predicate), checked from the phase’s update().
insertBefore, insertAfter, replaceStep and removeStep return a copy
and throw on a name the list does not have, so a stale edit fails at build.
Plain array code works too.
land and bounce are load-bearing: remove land and the reel never
announces its landing. The bounce shape goes through phase.bounce({ animation }) rather than a raw tween of the container, because the base
carries lifted unmask symbols along for as long as a bounce runs.
A subclass changes the list from the inside by overriding defaultSteps(),
or any step method (_beginSpinOut, land, bounce). A phase written from
scratch may run its own list through this.runSteps(steps) and
this.tickSteps() in update(), or ignore the runner entirely.
Loading recipe…
A custom phase takes the same door. Write the class as before, let
runSteps() sequence it, mark the waits cut, and expose defaultSteps()
through a constructor option: a quicken skips the hold with no onSkip
branch, a slam cancels the step in flight, and the game edits the list with
f.register('stop', MyPhase, { steps }) exactly as it edits StopPhase’s.
Loading recipe…
The feel work these lists are for, stagger curves, a wind-up launch, a landing kick, beats and FX that follow the speed profile, a spotlight around the tease and one extra beat on the last reel, is six short recipes on Feel: timings & landing FX.
Subclass a built-in#
Most changes to a built-in are small: a sound on landing, a flash when the stop
begins, a different tease curve. Writing those against ReelPhase means
rebuilding the stop sequencer or the anticipation timeline to get at one line.
Subclass instead, override the hook you care about, and call super.
import { StopPhase } from 'pixi-reels';
import type { StopPhaseConfig } from 'pixi-reels';
class ThudStopPhase extends StopPhase {
protected onEnter(config: StopPhaseConfig): void {
super.onEnter(config);
playSfx('reel-thud');
}
}
builder.phases((f) => f.register('stop', ThudStopPhase));
Loading recipe…
The contract#
Five rules cover every subclass.
Call super from every hook you override. The base onEnter is what
actually starts the phase. a StopPhase whose override never reaches it spins
forever and takes the spin promise with it. Order is yours: before super for
“as the phase begins”, after it for “once the phase is set up”.
Resolve exactly once. A phase ends by calling this._complete(), and the
base classes already do that at the right moment. Only call it yourself in a
phase you wrote from scratch, or in an override that deliberately replaces the
base’s ending. Calling it twice is harmless (the second is a no-op), never
calling it hangs the reel.
onSkip(ctx) is the slam pose under 'slam', a cut wait under 'quicken'.
ctx is a SkipContext: { mode, speed?, payload? }. Under 'slam' (a slam
calls forceComplete(ctx)) the base has cancelled the step in flight; kill
anything else and leave the reel in the state the phase would have ended in,
speed and position where a natural finish would have; the base completes the
phase after it. StopPhase.onSkip places the full target frame and rests the
container; StartPhase.onSkip jumps to full spin speed. Under 'quicken'
the hook is reached only when the phase declares quickenable = true; then
cut a wait, never the landing, and call _complete() yourself when the
natural end is reached (right away if the slam pose already is that end,
which is what StartPhase does). A phase on runSteps() needs nothing
here: its cut steps are skipped for it. A phase without the flag is left
alone by a quicken and runs its course, so a phase written for slams only
keeps working. Override it when your addition also needs to fire on a
pressed reel, and pass ctx to super so the built-in still sees the mode;
a bare super.onSkip() is taken as a slam. ctx.payload is whatever the
game put on the press.
update(deltaMs) runs every frame while active, and only then. It is
ticker-driven accumulation, not wall clock, so it stays correct in a
backgrounded tab. GSAP-driven phases leave it empty.
A quicken reaches every built-in the same way. StopPhase skips its
delay step and spins out at once, AnticipationPhase skips its tease
step and returns to full speed, SpinPhase drops its floor, StartPhase
completes at full speed. A press that names a profile swaps this.speed
before any of that, so the spin-out and bounce that follow read the new one.
A reel quickened before it reached its stop has the stop phase asked as soon
as it is created, so a wait inside a custom stop is cut whether the press
came before it or during it.
A subclass using the first four at once - onEnter to show a per-reel
countdown, update to drive it, onSkip to take it off when a press cuts the
tease short. The demo’s button SLAMS (skipSpin(), queued through
requestSkip() before the result). A mode: 'quicken' press would take the
countdown off through the same onSkip, because AnticipationPhase completes
on either mode; the difference is what the reel does next - placed on its
frame here, spun out and bounced on a quicken:
Loading recipe…
this.reel (including reel.reelIndex), this._speed (the active
SpeedProfile) and this.isActive are what a subclass reads. name and
skippable are readonly fields on the class; a subclass inherits both, and
overriding skippable on a built-in is not a supported way to protect a phase
from a slam - forceComplete() ignores the flag by design. Use
tease protection for that.
Phases that take constructor arguments#
The four standard phases are constructed with (reel, speed), so
register(key, Class) is enough. The cascade and MultiWays phases carry
build-time config as extra constructor arguments, so a subclass of one has to
be registered through registerFactory and forward them.
import { CascadeDropInPhase, resolveTumbleConfig } from 'pixi-reels';
import type { CascadeDropInPhaseConfig } from 'pixi-reels';
const TUMBLE = { dropIn: { duration: 500, cellStagger: 40 } };
// Fills the partial config out to the fully-specified shape the constructor
// takes, the same way `.tumble()` does internally. Hand-writing those fields
// is how a subclass silently drifts from the set's real tumble settings.
const resolved = resolveTumbleConfig(TUMBLE);
class LoudDropInPhase extends CascadeDropInPhase {
protected onEnter(config: CascadeDropInPhaseConfig): void {
super.onEnter(config);
playSfx('tumble-land');
}
}
builder
.tumble(TUMBLE)
// AFTER .tumble(), which registers the defaults this replaces.
.phases((f) =>
f.registerFactory('cascade:dropIn', (reel, speed) =>
new LoudDropInPhase(reel, speed, resolved.dropIn, resolved.gravity)),
);
CascadeFallPhase takes (reel, speed, resolved.fall, resolved.gravity),
CascadePlacePhase takes (reel, speed, resolved.gravity), and AdjustPhase
takes (reel, speed, { durationMs, ease }).
Loading recipe…
Chain position does not matter. .phases(...) configurators are deferred to
build() and applied after the tumble / MultiWays defaults, so an override
wins from anywhere in the chain, and the last override of a given key wins.
(Before 2.3 they ran at call time while .tumble() registered its defaults
inside build(), so a 'cascade:*' or 'adjust' override was silently
discarded no matter where it sat.)
Stability#
These are engine internals with an engine-internal contract. Every field and
method of a built-in phase is protected, so a subclass can reach
_beginSpinOut, _landAndBounce, _stage, _launch, _runSegment and the
rest instead of copying the phase; that surface can change in a minor release,
so a subclass may need to follow. The config TYPES are the stable part, and so
is the hook shape itself. If you want a guarantee
rather than a hook, extend ReelPhase and own the whole phase.
Skip granularity#
A slam force-completes every active phase, AnticipationPhase included, so by
default a skip press ends the tease before the player sees it. Three levers
open that up:
| Lever | What it does |
|---|---|
setAnticipation(reels, { protect }) | Skip presses stop ending the tease. 'once' lands the reels around it and leaves the tease running (the next press ends it); 'stepwise' then releases one tease reel per press, in tease order; 'always' never lets a press end one. |
slamStop({ reels }) / slamStop({ except }) | Per-reel slam. Those reels land now, everything else keeps running its phase chain to a natural landing. |
setMinimumSpinTime(ms | ms[]) | Per-reel SpinPhase floor, replacing the profile’s single shared minimumSpinTime. Without this, no individual reel can land below the profile floor except through an all-reels slam. |
requestSkip({ mode: 'quicken', speed? }) | The press that lands instead of cutting. Frees the same reels a slam press would and quickens them: tease over, delay cut, spin-out and bounce still played, optionally on a named profile. A mode: 'slam' press afterwards still cuts what is moving. builder.skipMode('quicken') makes it the default. |
skip:requested / skip:completed carry { reels, partial } so a listener can
tell a partial slam from a round-ending one.
The per-reel SpinPhase floor is also reachable from a subclass, which is
where to put it when the floor is derived rather than configured:
Loading recipe…
Every lever above has a worked demo on Skip & slam.
There is a fairness reason to reach for protect rather than raising the floor
on teasing spins: if a scatterless skip lands instantly but a teasing skip
settles slower, the response time itself tells the player a feature is coming
before the reels have landed. protect keeps the non-tease reels landing at the
exact same instant either way, and puts the tell where it belongs. on screen.