pixi-reels
API

Events

Two emitters:

  • reelSet.events. typed EventEmitter<ReelSetEvents>. Domain-level events for the whole board.
  • reel.events. typed EventEmitter<ReelEvents>. Per-column phase / symbol / land events.

Both emitters’ on(name, cb) returns the emitter, for chaining. Unsubscribe with off(name, fn) — keep a reference to the same function. The library cleans up its own listeners on destroy(). A thrown exception inside a listener does stop the remaining listeners for that event and propagates out of emit() into the engine — emit() has no try/catch, and nothing is logged. Wrap anything that can throw.

Quick map#

FamilyEventsFires from
Spin lifecyclespin:start · spin:allStarted · spin:stopping · spin:reelLanded · spin:allLanded · spin:completeEvery spin()
Nudgenudge:start · nudge:complete · nudge:cancellednudge()
Skip / slamskip:requested · skip:completed · skip:boostedskipSpin() · requestSkip() · slamStop()
Speedspeed:changedsetSpeed()
Cascadecascade:chain:start · cascade:chain:end · cascade:fall:start · cascade:fall:symbol · cascade:fall:end · cascade:place:end · cascade:dropIn:start · cascade:dropIn:symbol · cascade:dropIn:end · cascade:destroy:start · cascade:destroy:end · cascade:gravity:start · cascade:gravity:symbol · cascade:gravity:end · cascade:gravity:error.tumble() + spin({mode:'cascade'}) / refill() / runCascade() / destroySymbols()
Spotlightspotlight:start · spotlight:endspotlight.cycle(...)
Winswin:start · win:group · win:symbol · win:endWinPresenter.show(...)
Pinspin:placed · pin:moved · pin:expired · pin:migrated · pin:overlayCreated · pin:overlayDestroyedpin() · unpin() · movePin() · MultiWays reshape
MultiWaysshape:changed · adjust:start · adjust:completesetShape()
Lifecycledestroyeddestroy()

Every cascade event uses the same three-part shape: cascade:<scope>:<step>. Scopes are chain (one stage inside the runCascade loop), fall / place / dropIn / gravity (phase-level animation), destroy (one batch of destroySymbols). Steps are start / symbol / end. :symbol appears only on the per-cell variants of fall, dropIn, and gravity. :place has only :end because placement is a single-moment swap with no animation. The runCascade chain itself is delimited by the returned Promise. await the call to know when it’s done.

Spin lifecycle#

EventPayloadWhen
spin:start.Any spin() or refill() call. Cascades emit this per refill.
spin:allStarted.Every non-held reel is in the SPIN phase. Cascade refills skip this event.
spin:stopping(reelIndex: number)A reel begins STOP (held reels never fire).
spin:reelLanded(reelIndex: number, symbols: string[])An individual reel landed. symbols is its full visible column.
spin:allLanded(result: SpinResult)Last non-held reel landed.
spin:complete(result: SpinResult)Immediately after spin:allLanded.

Anticipation#

Only fires for reels that actually tease: in the anticipation set AND with a hold above 0.

EventPayloadWhen
anticipation:reel{ reelIndex, order, total }That reel starts teasing. order is 0-based within the tease set, so order / (total - 1) drives a pitch ramp without re-deriving anything from spin:stopping.
anticipation:reelEnd{ reelIndex }That reel’s tease is over.
interface SpinResult {
  symbols: string[][];              // final visible grid [reelIndex][cellIndex]
  wasSkipped: boolean;              // true if skipSpin() / requestSkip() / slamStop() ended the spin
  skipMode: SkipMode | null;        // the mode of the last press that freed reels
  skipContext: SkipContext | null;  // that press: { mode, speed?, payload? }; { mode: 'slam' } for an engine slam
  duration: number;                 // ms from spin:start to spin:complete
}

Skip / slam#

EventPayloadWhen
skip:requested(info: SkipInfo) = ({ reels: number[], partial: boolean, mode: SkipMode, speed?: SpeedProfile, payload?: unknown })A press freed reels. from skipSpin(), requestSkip() (after setResult arrives), or slamStop(). reels is what this press frees; partial is true when reels are still spinning after it (a protected tease, a reel group, or slamStop({ reels }) / slamStop({ except })); mode says what freeing means: 'slam' places them now, 'quicken' asks each for its landing sooner and the landing arrives as the usual spin:reelLanding / spin:reelLanded. speed and payload are the press’s own, the same SkipContext every phase’s onSkip(ctx) saw; keys the press did not set are absent. An engine slam (abort, timeout, slamStop()) is a bare mode: 'slam'.
skip:completed(info: SkipInfo)The same press, once its reels are down: in the same tick for a slam, as the last freed reel lands for a quicken (however it got down, its own stop or a later slam). The same info its skip:requested carried.
skip:queued(info: SkipContext) = ({ mode, speed?, payload? })requestSkip() came before setResult(): the press is kept and fires the moment the result arrives. The same context its skip:requested will carry, minus the reels, which are not known yet.
skip:boosted({ previous: SpeedProfile, current: SpeedProfile })First skipSpin() press of a STANDARD-mode round; engine bumped to the fastest registered profile for the rest of the round. Cascade rounds set the auto-slam-refills flag instead and never emit this event. Restore on the next spin() does not re-emit.
reelSet.events.on('skip:boosted', ({ previous, current }) => {
  hud.flash(`SPEED: ${previous.name} → ${current.name}`);
});

Speed#

EventPayloadWhen
speed:changed(profile: SpeedProfile, previous: SpeedProfile)setSpeed() (or the round-end restore after skip:boosted).

Cascade (tumble)#

Available only when the builder used .tumble({...}). The per-symbol events fire before the library’s view.y tween starts so listeners can run parallel tweens that finish in lockstep.

Chain-scoped (one stage inside the loop)#

runCascade(...) returns Promise<RunCascadeResult>. await the call to know when the chain is over and read the summary. There’s no separate round-start / round-end event because “round” isn’t a reel-engine concept (the slot industry uses “round” for a bet→payout transaction; for the engine, a round is press-spin → all-stopped, which spin:start / spin:allLanded already cover).

interface RunCascadeResult {
  chainLength: number;     // refill stages that ran (0 = no wins on the initial grid)
  totalWinners: number;    // sum of winners.length across every refill
  finalGrid: string[][];   // grid after the last refill
  wasSkipped: boolean;     // true if the player slammed mid-chain
  skipContext: SkipContext | null;  // the press that ended it, or null; an abort is { mode: 'slam' }
}
EventPayloadWhen
cascade:chain:start{ chain: number, winners: readonly Cell[], currentGrid: string[][] }A chain stage opens inside runCascade(). winners were detected, destroy is about to run. chain is 1-indexed.
cascade:chain:end{ chain: number, winners: readonly Cell[], nextGrid: string[][] }A chain stage closes. both destroy AND refill drop-in finished. About to loop back to detectWinners (or exit if winners came back empty).

Phase-scoped (animation events on each refill or initial drop)#

EventPayloadWhen
cascade:fall:start{ reelIndex: number }A reel’s fall-out begins (Moment A only. refill skips fall).
cascade:fall:symbol{ symbol: ReelSymbol, view: Container, reelIndex: number, cellIndex: number, duration: number, ease: string, distance: number, signal: AbortSignal }One symbol’s fall tween is about to start. Read symbol / view to attach a parallel tween.
cascade:fall:end{ reelIndex: number }A reel’s last fall tween settled.
cascade:place:end{ reelIndex: number, placedSymbols: readonly ReelSymbol[], isInitial: boolean, winnerCells: readonly number[] }New identities placed AND snapped to grid, before drop-in starts. Canonical decoration hook. isInitial: true on Moment A; isInitial: false + populated winnerCells on refills. Place has no :start because it’s a synchronous swap.
cascade:dropIn:start{ reelIndex: number }A reel’s drop-in begins.
cascade:dropIn:symbol{ symbol: ReelSymbol, view: Container, reelIndex: number, cellIndex: number, duration: number, ease: string, offsetCells: number, signal: AbortSignal }One symbol’s drop-in tween is about to start. offsetCells is the number of cells this symbol traverses (1 for top-row refills, more for survivors sliding past larger holes).
cascade:dropIn:end{ reelIndex: number }A reel’s last drop-in tween settled.
cascade:gravity:start{ reelIndex: number }Two-stage refill only (mode: 'gravity-then-drop'). a reel’s survivor-settle phase begins, before new symbols enter. Not emitted when refill is 'combined'.
cascade:gravity:symbol{ symbol: ReelSymbol, view: Container, reelIndex: number, cellIndex: number, duration: number, ease: string, offsetCells: number, signal: AbortSignal }One survivor’s settle tween about to start. Same payload shape as dropIn:symbol, scoped to survivors only.
cascade:gravity:end{ reelIndex: number }A reel’s survivor settle landed. The library now waits for gravityHoldMs + gravityHold to resolve (in parallel via Promise.all) before the drop-in stage begins. Use this as the asymmetric-anticipation cue.
cascade:gravity:error{ error: unknown }Your gravityHold promise rejected, or onGravityComplete threw. The cascade still settles — this event is the only place that reason survives, so forward it to your logger. Typed unknown because user code can throw anything.

Destroy-batch-scoped#

EventPayloadWhen
cascade:destroy:start{ cells: readonly Cell[] }destroySymbols(...) is about to start. Fires from EVERY call. both direct and inside runCascade. Empty-batch calls do not emit.
cascade:destroy:end{ cells: readonly Cell[], failed?: readonly Cell[] }destroySymbols(...) finished. every playDestroy() resolved and the viewport dim (if any) was restored.

Event order inside one runCascade() call#

Combined-mode refill (default. refillMode: 'combined'):

for each chain stage with winners:
  cascade:chain:start
    cascade:destroy:start
      (destroy tweens, one per cell, parallel)
    cascade:destroy:end
    onCascade callback (optional)
    pause (pauseAfterDestroyMs)
    (refill: per reel. cascade:place:end → cascade:dropIn:start → ... → cascade:dropIn:end)
  cascade:chain:end
// then the awaited runCascade promise resolves with RunCascadeResult

Two-stage refill (refillMode: 'gravity-then-drop') splits the refill into a gravity stage and a drop-in stage with an anticipation window between them:

for each chain stage with winners:
  cascade:chain:start
    cascade:destroy:start → ... → cascade:destroy:end
    onCascade callback (optional)
    pause (pauseAfterDestroyMs)
    (gravity stage: per reel. cascade:place:end → cascade:gravity:start → cascade:gravity:symbol* → cascade:gravity:end)
    hold (Promise.all of `gravityHoldMs` setTimeout + caller-supplied `gravityHold` promise)
    onGravityComplete callback (optional)
    (drop-in stage: per reel. cascade:dropIn:start → cascade:dropIn:symbol* → cascade:dropIn:end)
  cascade:chain:end

Common patterns#

// Lock auto-play around the call itself. `await` IS the lifecycle.
hud.lock();
try {
  await reelSet.runCascade({ detectWinners, nextGrid });
} finally {
  hud.unlock();
}

// Per-chain SFX cues.
reelSet.events.on('cascade:chain:start', ({ chain }) => sfx.play(`chain_${Math.min(chain, 5)}`));

// Destroy cue, even when consumers call `destroySymbols` outside runCascade.
reelSet.events.on('cascade:destroy:start', ({ cells }) => sfx.play('shatter', cells.length));

// Decorate ONLY new arrivals (skip survivors).
reelSet.events.on('cascade:place:end', ({ placedSymbols, isInitial, winnerCells }) => {
  const newRowSet = new Set(isInitial ? placedSymbols.map((_, i) => i) : winnerCells);
  for (const [row, sym] of placedSymbols.entries()) {
    if (newRowSet.has(row)) addMultiplierBadge(sym);
  }
});

// Squish each symbol on land, in sync with the library's drop-in tween.
reelSet.events.on('cascade:dropIn:symbol', ({ view, duration }) => {
  // Library will animate view.y; you can animate view.scale on the same beat.
  gsap.to(view.scale, { x: 1.1, y: 0.9, duration: duration / 2000, yoyo: true, repeat: 1 });
});

Nudge#

Fired by reelSet.nudge(reel, opts). landed does not fire for a nudge — that belongs to the spin pipeline.

EventPayloadWhen
nudge:start{ reelIndex, distance, direction: 'forward' | 'reverse' }Pre-placement done, tween about to begin.
nudge:complete{ reelIndex, distance, direction, symbols: string[] }Strip snapped to the post-nudge grid; symbols is the new visible column.
nudge:cancelled{ reelIndex, distance, direction, reason: string }Signal aborted or the reel was destroyed mid-tween. Does not fire alongside nudge:complete.

Spotlight#

EventPayloadWhen
spotlight:start(positions: SymbolPosition[])spotlight.cycle(...) began.
spotlight:end.Spotlight finished.

Wins (WinPresenter)#

Emitted by WinPresenter only. listeners are dead weight if you don’t use it.

EventPayloadWhen
win:start(wins: readonly Win[])A presenter sequence began. List is sorted by value desc by default.
win:group(win: Win, cells: readonly SymbolPosition[])Each individual win. fires once per win, before its cells animate.
win:symbol(symbol: unknown, cell: SymbolPosition, win: Win)Each cell, one at a time when WinPresenter.stagger > 0. Cast symbol to your concrete ReelSymbol subclass.
win:end(reason: 'complete' | 'aborted')Sequence finished. either naturally or via abort.

Pins#

EventPayloadWhen
pin:placed(pin: CellPin)reelSet.pin(...) succeeded. Replacing an existing pin does NOT fire pin:expired for the old one. only this event fires (silent replace).
pin:moved(pin: CellPin, from: { reel: number; cell: number })reelSet.movePin(...) resolved. The pin’s reel/cell are already at the destination; from carries the origin.
pin:expired(pin: CellPin, reason: PinExpireReason)Pin removed. 'explicit' (via unpin), 'turns' (numeric counter hit zero), or 'eval' (round-scoped pin reset on the next spin).
pin:migrated(pin: CellPin, info: { fromCell: number; toCell: number; clamped: boolean; reelIndex: number })MultiWays reshape moved a pin to a new row inside the same reel. clamped: true when the origin row no longer fits the new shape. Never emitted on non-MultiWays slots.
pin:overlayCreated(pin: CellPin, overlay: unknown)Mid-spin overlay symbol mounted. Cast overlay to your concrete symbol class (typed as unknown to keep this module free of symbol-layer imports).
pin:overlayDestroyed(pin: CellPin, overlay: unknown)Mid-spin overlay torn down on land / unpin / replacement. Fires BEFORE the symbol returns to the pool, so you can stop animations on a still-valid instance.

MultiWays#

EventPayloadWhen
shape:changed(cellsPerReel: number[])setShape(...) accepted. Fires before any geometry change. pin migrations and AdjustPhase happen later in the spin.
adjust:start({ reelIndex: number, fromCells: number, toCells: number })Per-reel AdjustPhase entered.
adjust:complete({ reelIndex: number })Per-reel AdjustPhase finished.

Lifecycle#

EventPayloadWhen
destroyed.reelSet.destroy() called. Listeners attached BEFORE this fire one last time, then the emitter clears every listener.

Per-reel events#

reel.events is a smaller surface scoped to one column.

Every bus (reelSet.events, reel.events, a board’s events) also takes onAny((event, ...args) => ...) / offAny(fn): every event, name first, after the event’s own listeners. For a trace, an events panel, a recorder; game logic names the events it wants. Engine notices are not events; onNotice(fn) hears them the same way, whatever the log level.

type ReelEvents = {
  'phase:enter':     [phaseName: string];
    // 'start' | 'spin' | 'stop' | 'anticipation' | 'adjust' | 'nudge'
    // (+ 'cascade:fall' | 'cascade:place' | 'cascade:dropIn' on a tumble set)
    // | 'cascade:fall' | 'cascade:place' | 'cascade:dropIn'
  'phase:exit':      [phaseName: string];
  'phase:step':      [{ phase: string, step: string, status: PhaseStepStatus }];
    // a step of a phase on runSteps(): 'start' | 'end' | 'skipped' (a cut
    // step a quicken never started) | 'cut' (the cut step a quicken stopped
    // in flight) | 'cancelled' (the step a slam or a destroy stopped)
  'symbol:created':  [symbolId: string, stripIndex: number];
  'landed':          [symbols: string[]];
  'destroyed':       [];
};
reelSet.getReel(4).events.on('phase:enter', (name) => {
  if (name === 'anticipation') playTensionMusic();
});

Listener etiquette#

  • Subscribe once at startup; for hot-swap modules keep the function reference and call off(name, fn).
  • Async listeners run “fire and forget.” If you must await per-event work, queue it yourself. the engine doesn’t.
  • The library emits cascade:*:symbol events BEFORE its own tween starts. Your listener can read the same view and queue a parallel tween. they’ll run in lockstep.
  • The Cell type used in cascade payloads is { reel: number; cell: number } (column = reel, visible cell = cell). Same shape as destroySymbols input.

Console notices#

Everything the library prints goes through one channel, so notices all look the same and all carry a stable CODE you can grep for, search the docs for, or quote in a bug report.

import { setLogLevel } from 'pixi-reels';

setLogLevel('warn');   // drop the advisory notices, keep problems
setLogLevel('silent'); // production

'silent' | 'error' | 'warn' | 'info', each level including the ones before it. Default 'info' (everything) - see the debugging guide for what each level covers and why the default is not quieter. In a browser a notice prints as a styled badge - pixi-reels, then the code, then the message; elsewhere it degrades to [pixi-reels] warn(code) message, since %c is a browser console feature.

Notices keep using console.warn / console.error / console.info rather than one console.log, so devtools level filtering and stack capture still work, and detail arguments pass through untouched (an Error keeps its stack).

CodeLevelMeaning
quicken-cascadewarnA mode: 'quicken' press in cascade mode. A tumble reel lands by placing its symbols, so there is nothing to spin out; the press slammed.
phase-chain-threwerrorA reel’s phase chain rejected. The engine slammed to recover so the spin still settles.
refill-threwerrorA two-stage refill rejected, usually from a caller’s gravityHold / onGravityComplete. Slammed to recover.
pin-hook-threwerrorA movePin flight hook threw. The flight continues so the flight symbol is not leaked.
destroy-rejectedwarnA cell’s playDestroy() rejected during destroySymbols(). Reported per cell on cascade:destroy:end as well.
buffer-clampedwarnbufferSymbols() was given less than 1 and was clamped. Once per process.
hw-coin-win-failedwarnA Hold & Win coin win animation rejected.
mask-auto-sharedinfoThe builder auto-selected SharedRectMaskStrategy. Pass .maskStrategy(...) to override.
warp-skips-unmaskinfocurveMode('warp') does not bend unmask: true symbols, the win spotlight or pin overlays.