pixi-reels
API

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.

ClassKeyConfigRoleSkippable
StartPhase'start'StartPhaseConfigAccelerate from rest, with a step-back pullyes
SpinPhase'spin'SpinPhaseConfigFull speed until the result arrivesno
AnticipationPhase'anticipation'AnticipationPhaseConfigSlow hold, for tensionyes
StopPhase'stop'StopPhaseConfigDecelerate onto the target frame, bounceyes
CascadeFallPhase'cascade:fall'CascadeFallPhaseConfigTumble fall-out. replaces StartPhaseyes
CascadePlacePhase'cascade:place'CascadePlacePhaseConfigTumble identity swapyes
CascadeDropInPhase'cascade:dropIn'CascadeDropInPhaseConfigTumble drop-in and gravity slideyes
AdjustPhase'adjust'AdjustPhaseConfigMultiWays reshape. tweens pin overlaysyes

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:

CallDoesUsed 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))),
}));
PhaseSteps, in orderCut by a quicken
StartPhasedelay, launch, pull (only on a profile that bounces), accelerate, announcedelay
StopPhasedelay, spinOut, land, bouncedelay
AnticipationPhasetease (the whole tease, whichever shape)tease
SpinPhase, cascade phases, AdjustPhasenone; 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:

LeverWhat 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.