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):
| Call | New 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'— trailingincomingentries spill into the exit-edge buffer.direction: 'reverse'— leadingincomingentries 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:
| Direction | Survival 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#
| Event | Payload | When |
|---|---|---|
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.
reelis 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.directionis neither'reverse'nor'forward'.incoming.length !== distance.- An
incomingid 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.signalaborts 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#
- Nudge a reel. the minimum runnable demo.
- Spotlight after a nudge. nudge into a
win line, then run
WinPresenteron the new cells. - Nudge through a big symbol. block survival math + tail-reveal canvas.
- Land a big symbol partially in buffer.
the dual entry point: land in tail-visible state via
setResult’sbufferStart, nudge to reveal. - Nudge a big symbol in, then hold it across a respin. reveal a buffer-anchored wild, then hold the reel through a respin of the others.