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):
| Call | New 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'. trailingincomingentries spill into bufferBelow.direction: 'up'. leadingincomingentries 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:
| Direction | Survival 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
| 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. |
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.
colis 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.directionis neither'up'nor'down'.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 is part of a cross-reel block (w > 1).
Rejects asynchronously with AbortError:
options.signalaborts 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
- 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’sbufferAbove, 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.