PR pixi-reels
Building blocks

Nudge

A nudge moves a single reel by a few symbol positions after the spin has landed, revealing new symbols the caller supplies. Common pattern: land a near-miss, then nudge one column down by 1 to reveal the missing symbol.

await reelSet.spin();

await reelSet.nudge(2, {
  distance: 1,
  direction: 'down',
  incoming: ['wild'],
});

That call shifts reel 2 down by one row. The old top row drops to the middle, the old middle drops to the bottom, the old bottom symbol leaves the visible window, and 'wild' slides in at the new top.

Loading recipe…

The contract

interface NudgeOptions {
  /**
   * Number of full positions to shift. Positive integer, strictly less than
   * the reel's total strip capacity (bufferAbove + visibleRows + bufferBelow).
   */
  distance: number;
  /**
   *  - 'down'. symbols move down; new symbols enter from the top.
   *  - 'up'  . symbols move up;   new symbols enter from the bottom.
   */
  direction: 'up' | 'down';
  /**
   * Symbol ids in **top-down order of their FINAL on-strip position**.
   * including any overflow into the off-screen buffer. Length must equal
   * `distance` exactly.
   */
  incoming: string[];
  /** Tween duration in ms. Default: `200 * distance`. */
  duration?: number;
  /** GSAP ease name. Default: `'power2.out'` (smooth deceleration, no overshoot). */
  ease?: string;
  /** Delay before the tween begins (ms). Sugar for staggered Promise.all. */
  startDelay?: number;
  /** Abort the nudge mid-flight. Strip still snaps to landed position. */
  signal?: AbortSignal;
}

reelSet.nudge(col: number, options: NudgeOptions): Promise<{ symbols: string[] }>;
reelSet.skipNudge(col?: number): void;

The promise resolves with the new visible column top-to-bottom, ready to feed straight into a win re-detection step. The promise rejects with an AbortError if options.signal aborts or the reel set is destroyed mid-tween.

How incoming lays out

For a 3-row column landed on ['A', 'B', 'C'] (top, middle, bottom):

CallNew visible (top → bottom)
nudge(c, { distance: 1, direction: 'down', incoming: ['X'] })['X', 'A', 'B']
nudge(c, { distance: 2, direction: 'down', incoming: ['X', 'Y'] })['X', 'Y', 'A']
nudge(c, { distance: 1, direction: 'up', incoming: ['X'] })['B', 'C', 'X']
nudge(c, { distance: 2, direction: 'up', incoming: ['X', 'Y'] })['C', 'X', 'Y']

incoming[0] is always the topmost final position; incoming[distance-1] is always the bottommost. When distance exceeds visibleRows, the overflow lands in the off-screen buffer on the matching side:

  • direction: 'down'. trailing incoming entries spill into bufferBelow.
  • direction: 'up'. leading incoming entries spill into bufferAbove.

Either way, every entry stays on the strip. available to a follow-up nudge or carried into the next spin’s start frame. The engine refuses distance >= total strip capacity so a full rotation can’t silently discard a pre-placed buffer entry.

Sequential vs parallel

Nudges are per-reel by design. Promise.all([...]) is how multi-reel beats happen. The orchestration is yours: await each call for a deliberate beat, or fire them together for a single synchronised one. Same engine output, same events fire, only the pacing changes.

Sequential. three separate beats

Loading recipe…

for (const col of [1, 2, 3]) {
  await reelSet.nudge(col, { distance: 1, direction: 'down', incoming: ['wild'], duration: 480 });
}

Each await parks the loop until that reel’s tween lands. Players register reel 1’s wild before reel 2 even starts. Total wall time = cols.length × duration.

Parallel. one synchronised beat

Loading recipe…

await Promise.all(
  [1, 2, 3].map((col) =>
    reelSet.nudge(col, { distance: 1, direction: 'down', incoming: ['wild'], duration: 480 }),
  ),
);

Every call dispatches synchronously inside Promise.all, so the GSAP tweens all start the same frame. Whole row drops in together. Total wall time = duration (regardless of reel count).

Stagger. sugar in between

Pass per-call startDelay to introduce a fixed gap without writing a loop:

await Promise.all(
  [1, 2, 3].map((col, i) =>
    reelSet.nudge(col, { ...opts, startDelay: i * 80 }),
  ),
);

Reel 1 starts at t=0, reel 2 at t=80ms, reel 3 at t=160ms. all running their own tween durations concurrently. Reads as a wave. Validation errors still throw synchronously on the call site; only the actual mutation is deferred.

Big symbols on the reel

A 1xH block on the target reel is nudged through as a unit, as long as its post-rotation position keeps the whole block on the strip:

DirectionSurvival condition
'down'anchorStripIdx + h - 1 + distance < total
'up'anchorStripIdx - distance >= 0

Where total = bufferAbove + visibleRows + bufferBelow and anchorStripIdx is the anchor’s index on the full strip (0 = topmost bufferAbove cell, bufferAbove = visible row 0).

The block can land partially in either buffer and still render correctly. _finalizeFrame scans both bufferAbove and visible-row anchors, sizing the sprite to span the whole block whichever side the anchor lives on. The mask clips the off-screen portion, the visible portion shows the block’s tail or head. The classic use case: a 1xH wild lands with its anchor in bufferAbove (tail visible at row 0); nudge down to reveal the full block. Cross-reel blocks (w > 1) always throw. splitting an anchor from its right-side cells on another reel isn’t safe.

// 1x2 wild lives in visible row 1 + bufferBelow. Nudge up by 1 reveals
// the full block at visible rows 0 + 1.
await reelSet.nudge(2, { distance: 1, direction: 'up', incoming: ['filler'] });

Aborting / skipping

Two paths to cut a nudge short:

// 1. Skip. fast-forward to landed. Promise resolves normally.
const p = reelSet.nudge(2, { ...opts });
button.onclick = () => reelSet.skipNudge(2);
const { symbols } = await p;
// 2. Abort. reject with AbortError. Strip still snaps to landed.
const controller = new AbortController();
const p = reelSet.nudge(2, { ...opts, signal: controller.signal });
button.onclick = () => controller.abort();
try { await p; }
catch (e) { if (e.name !== 'AbortError') throw e; }

The difference is the promise contract. skipNudge resolves the nudge() call’s promise normally. the consumer’s success path runs. An abort rejects with AbortError and fires nudge:cancelled on the bus. the consumer’s error path runs. Pick by what should happen next: “play the win presenter” → skip; “tear down the whole feature” → abort.

Either way the strip lands at its deterministic post-nudge position; you don’t end up half-tweened.

Events

EventPayloadWhen
nudge:start({ reelIndex, distance, direction })Pre-placement is done; tween about to begin. Listeners see the about-to-animate state.
nudge:complete({ reelIndex, distance, direction, symbols })Strip snapped to post-nudge grid; symbols is the new visible column.
nudge:cancelled({ reelIndex, distance, direction, reason })Signal aborted or reel destroyed mid-tween. Does not fire alongside nudge:complete. the call’s promise rejected instead.
phase:enter (per-reel)('nudge')Mirror of nudge:start, on the affected reel’s bus.
phase:exit (per-reel)('nudge')Mirror of nudge:complete.

A normal-spin landed event does not fire on a nudge. that surface is reserved for the spin pipeline. Use nudge:complete for win re-detection.

reelSet.events.on('nudge:complete', ({ reelIndex, symbols }) => {
  console.log(`reel ${reelIndex} now reads`, symbols);
  // Re-run win detection on reelSet.getVisibleGrid() here.
});

When nudge() throws (or rejects)

Synchronous throws. argument shape errors. Async rejections. state errors during the tween or AbortSignal cancellation.

Throws synchronously:

  • The reel set is currently spinning, refilling, or in a cascade.
  • col is out of [0, reelCount).
  • The target reel has an active pin (call unpin(col, row) first).
  • distance < 1, not an integer, or >= total strip capacity.
  • direction is neither 'up' nor 'down'.
  • incoming.length !== distance.
  • An incoming id is not registered, or is a big symbol.
  • A block on the target reel wouldn’t survive the rotation (split detection).
  • A cell on the target reel is part of a cross-reel block (w > 1).

Rejects asynchronously with AbortError:

  • options.signal aborts before or during the tween.
  • reelSet.destroy() is called mid-tween.

Other reels are never touched by a nudge call; spotlights, pins on unrelated reels, and other reels’ symbols all carry through.

Recipes