Events
Two emitters:
reelSet.events. typedEventEmitter<ReelSetEvents>. Domain-level events for the whole board.reel.events. typedEventEmitter<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#
| Family | Events | Fires from |
|---|---|---|
| Spin lifecycle | spin:start · spin:allStarted · spin:stopping · spin:reelLanded · spin:allLanded · spin:complete | Every spin() |
| Nudge | nudge:start · nudge:complete · nudge:cancelled | nudge() |
| Skip / slam | skip:requested · skip:completed · skip:boosted | skipSpin() · requestSkip() · slamStop() |
| Speed | speed:changed | setSpeed() |
| Cascade | cascade: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() |
| Spotlight | spotlight:start · spotlight:end | spotlight.cycle(...) |
| Wins | win:start · win:group · win:symbol · win:end | WinPresenter.show(...) |
| Pins | pin:placed · pin:moved · pin:expired · pin:migrated · pin:overlayCreated · pin:overlayDestroyed | pin() · unpin() · movePin() · MultiWays reshape |
| MultiWays | shape:changed · adjust:start · adjust:complete | setShape() |
| Lifecycle | destroyed | destroy() |
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#
| Event | Payload | When |
|---|---|---|
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.
| Event | Payload | When |
|---|---|---|
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#
| Event | Payload | When |
|---|---|---|
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#
| Event | Payload | When |
|---|---|---|
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' }
}
| Event | Payload | When |
|---|---|---|
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)#
| Event | Payload | When |
|---|---|---|
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#
| Event | Payload | When |
|---|---|---|
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.
| Event | Payload | When |
|---|---|---|
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#
| Event | Payload | When |
|---|---|---|
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.
| Event | Payload | When |
|---|---|---|
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#
| Event | Payload | When |
|---|---|---|
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#
| Event | Payload | When |
|---|---|---|
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#
| Event | Payload | When |
|---|---|---|
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
awaitper-event work, queue it yourself. the engine doesn’t. - The library emits
cascade:*:symbolevents BEFORE its own tween starts. Your listener can read the sameviewand queue a parallel tween. they’ll run in lockstep. - The
Celltype used in cascade payloads is{ reel: number; cell: number }(column =reel, visible cell =cell). Same shape asdestroySymbolsinput.
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).
| Code | Level | Meaning |
|---|---|---|
quicken-cascade | warn | A 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-threw | error | A reel’s phase chain rejected. The engine slammed to recover so the spin still settles. |
refill-threw | error | A two-stage refill rejected, usually from a caller’s gravityHold / onGravityComplete. Slammed to recover. |
pin-hook-threw | error | A movePin flight hook threw. The flight continues so the flight symbol is not leaked. |
destroy-rejected | warn | A cell’s playDestroy() rejected during destroySymbols(). Reported per cell on cascade:destroy:end as well. |
buffer-clamped | warn | bufferSymbols() was given less than 1 and was clamped. Once per process. |
hw-coin-win-failed | warn | A Hold & Win coin win animation rejected. |
mask-auto-shared | info | The builder auto-selected SharedRectMaskStrategy. Pass .maskStrategy(...) to override. |
warp-skips-unmask | info | curveMode('warp') does not bend unmask: true symbols, the win spotlight or pin overlays. |