Loading recipe…
The minimum code
await reelSet.spin();
await reelSet.setResult(grid);
// Down nudge. `wild` slides in from the top and becomes the new top row.
await reelSet.nudge(2, {
distance: 1,
direction: 'down',
incoming: ['wild'],
});
// Up nudge. `wild` slides in from the bottom and becomes the new bottom row.
await reelSet.nudge(3, {
distance: 1,
direction: 'up',
incoming: ['wild'],
});
incoming is top-down. always
The incoming array describes the final visible position of the new
symbols, top-to-bottom. not the order they enter the screen. The engine
handles the wrap mechanics internally so the caller can think in terms of
“what does the player see after.”
For a 3-row column landed on ['A', 'B', 'C']:
| 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'] |
Re-detect wins after the nudge
The returned promise resolves with the new visible column, which is the right input for a win re-detection pass:
const { symbols: newColumn } = await reelSet.nudge(2, {
distance: 1,
direction: 'down',
incoming: ['wild'],
});
// Either re-detect against the column you just got, or pull the full grid:
const grid = reelSet.getVisibleGrid();
const wins = detectWins(grid);
if (wins.length) winPresenter.show(wins);
Sequential vs parallel
Nudges are always per-reel. there’s no batched API. Multi-reel beats are
just multiple nudge(...) calls, and the orchestration is entirely yours:
await them one at a time for a deliberate beat, or Promise.all([...])
them for a single synchronised one. Same engine output, same events fire,
only the pacing changes.
Each canvas below lands the same flat board and then nudges reels 1, 2, and 3 down by one. The only difference is whether the calls await each other.
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 GSAP tween lands. Total
wall time = cols.length * duration. Use this shape when the caller
wants each reel’s resolved promise (and its nudge:complete event) to
fire in order, one after the previous one fully landed.
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 nudge(...) is invoked synchronously inside Promise.all, so the
GSAP tweens all start on the same frame. The whole row of wilds drops in
together. Total wall time = duration (no matter how many reels).
Use this shape when the reveal IS the row landing as a unit and you only
need a single Promise.all resolution to drive the next step.
Both modes fire their own nudge:start / nudge:complete pair per reel,
so per-reel SFX and HUD overlays work cleanly either way. Only the
clock differs.
Staggered. sugar for “wave” reveals
Pass startDelay per call inside a Promise.all for a uniform stagger
without writing the loop yourself:
await Promise.all(
[1, 2, 3].map((col, i) =>
reelSet.nudge(col, {
distance: 1,
direction: 'down',
incoming: ['wild'],
duration: 480,
startDelay: i * 80, // reel 1 → 0ms, reel 2 → 80ms, reel 3 → 160ms
}),
),
);
Validation still throws synchronously on the call site (bad arguments
fail immediately); only the actual mutation is deferred by startDelay.
For irregular stagger profiles just compute per-reel delays in the map.
Skip / abort a nudge
reelSet.skipNudge(col) fast-forwards a single nudge to its landed state
without rejecting the promise. the consumer’s success path runs as if
the tween had finished naturally. For “tear it down” semantics pass an
AbortSignal:
// Skip. resolves normally.
const p = reelSet.nudge(2, { distance: 1, direction: 'down', incoming: ['wild'], duration: 1500 });
skipButton.onclick = () => reelSet.skipNudge(2);
const { symbols } = await p; // success path
// Abort. rejects with AbortError, fires nudge:cancelled.
const controller = new AbortController();
abortButton.onclick = () => controller.abort();
await reelSet.nudge(2, { ...opts, signal: controller.signal })
.catch((e) => { if (e.name !== 'AbortError') throw e; });
Either way the strip snaps to its deterministic post-nudge position.
the landing contract is “incoming lands at these positions” regardless
of how the tween ended.
Test it
import { createTestReelSet, expectGrid } from 'pixi-reels/testing';
const { reelSet, spinAndLand, destroy } = createTestReelSet({
reels: 3, visibleRows: 3, symbolIds: ['a', 'b', 'c', 'wild'],
});
try {
await spinAndLand([
['a', 'b', 'c'],
['a', 'b', 'c'],
['a', 'b', 'c'],
]);
await reelSet.nudge(1, { distance: 1, direction: 'down', incoming: ['wild'] });
expectGrid(reelSet, [
['a', 'b', 'c'],
['wild', 'a', 'b'],
['a', 'b', 'c'],
]);
} finally {
destroy();
}
See the nudge guide for the full event map and a walkthrough of when the engine wraps vs pre-places symbols.