pixi-reels
Building blocks

Nudge

A nudge shoves one reel a few cells AFTER it has landed, revealing symbols you supply.

The classic use: land a near-miss, then nudge one reel by 1 to reveal the symbol that was missing.

await reelSet.spin();

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

Reel 2 moves one cell. Everything shifts along, the last symbol leaves the window, and 'wild' arrives at the front.

Loading recipe…

The contract#

interface NudgeOptions {
  /**
   * Number of full positions to shift. Positive integer, strictly less than
   * the reel's total strip capacity (bufferStart + visibleCells + bufferEnd).
   */
  distance: number;
  /**
   *  - 'forward'. the way this reel travels; new symbols enter at its
   *    feed edge.
   *  - 'reverse'. the other way; new symbols enter at the opposite edge.
   *
   * Relative to the REEL, not the screen. On a reel built with
   * direction('reverse'), 'forward' nudges upward.
   */
  direction: 'forward' | 'reverse';
  /**
   * 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(reel: number, options: NudgeOptions): Promise<{ symbols: string[] }>;
reelSet.skipNudge(reel?: number): void;

Resolves with the new visible cells, in order. Feed it straight into win re-detection. Rejects AbortError if signal aborts or the set is destroyed mid-tween.

How incoming lays out#

For a 3-cell reel landed on ['A', 'B', 'C'] (first, middle, last):

CallNew visible (first -> last)
nudge(r, { distance: 1, direction: 'forward', incoming: ['X'] })['X', 'A', 'B']
nudge(r, { distance: 2, direction: 'forward', incoming: ['X', 'Y'] })['X', 'Y', 'A']
nudge(r, { distance: 1, direction: 'reverse', incoming: ['X'] })['B', 'C', 'X']
nudge(r, { distance: 2, direction: 'reverse', incoming: ['X', 'Y'] })['C', 'X', 'Y']

incoming[0] always ends up start-most, incoming[distance-1] end-most.

Push further than visibleCells and the overflow lands in the buffer on the matching side:

  • direction: 'forward' — trailing incoming entries spill into the exit-edge buffer.
  • direction: 'reverse' — leading incoming entries spill into the other one.

Nothing is thrown away. Every entry stays on the strip, ready for a follow-up nudge or the next spin’s opening frame.

distance >= total strip capacity throws, because a full rotation would silently eat a pre-placed buffer entry.

Sequential vs parallel#

One nudge, one reel. Multi-reel beats are yours to orchestrate. Same engine output either way — only the pacing differs.

Sequential. three separate beats#

Loading recipe…

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

Each await waits for that reel to land. The player reads reel 1’s wild before reel 2 starts. Total time = reels x duration.

Parallel. one synchronised beat#

Loading recipe…

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

All the tweens start on the same frame. The whole line arrives together. Total time = one duration, however many reels.

Stagger. sugar in between#

startDelay gives you a wave without writing a loop:

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

t=0, t=80, t=160. All running concurrently. Validation still throws synchronously at the call site; only the movement is delayed.

Big symbols on the reel#

A tall block moves as one piece, provided the whole thing stays on the strip afterwards:

DirectionSurvival condition
'forward'anchorStripIdx + h - 1 + distance < total
'reverse'anchorStripIdx - distance >= 0

total = bufferStart + visibleCells + bufferEnd. anchorStripIdx counts from 0 at the outermost start-buffer cell, so bufferStart is visible cell 0.

A block may sit partly in a buffer and still render right — the engine sizes the anchor to span the whole block wherever it lives, and the mask clips the rest. That is the tail-reveal trick: land a tall wild with its anchor in the buffer, nudge, and the rest of it walks into view.

Cross-reel blocks (size.reels > 1) always throw. Nudging one reel would tear the block off its cells on the next one.

// 1x2 wild sits at visible cell 1 with its tail in bufferEnd.
// Nudge back by 1 and the whole block lands at cells 0 and 1.
await reelSet.nudge(2, { distance: 1, direction: 'reverse', 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. skipNudge resolves it normally, so your success path runs. Abort rejects it and fires nudge:cancelled, so your error path runs.

Pick by what happens next. “Now play the win” -> skip. “Kill the feature” -> abort.

Either way the strip lands where it was always going to. You never 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.

landed does NOT fire on a nudge — that belongs to the spin pipeline. Use nudge:complete.

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)#

Bad arguments throw straight away. Cancellation rejects later.

Throws synchronously:

  • The reel set is currently spinning, refilling, or in a cascade.
  • reel is out of [0, reelCount).
  • The target reel has an active pin (call unpin(reel, cell) first).
  • distance < 1, not an integer, or >= total strip capacity.
  • direction is neither 'reverse' nor 'forward'.
  • 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 belongs to a cross-reel block (size.reels > 1).

Rejects asynchronously with AbortError:

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

A nudge touches one reel. Spotlights, pins elsewhere, and every other reel carry through untouched.

Recipes#