Hold & Win
Hold & Win (a.k.a. Lock & Respin, Hold & Spin) is a feature where coins land,
stick, and refill a respin counter until the board fills or the respins run
out. pixi-reels ships it as a built-in board:
import { HoldAndWinBuilder } from 'pixi-reels';
The recipes show individual tricks; this guide shows how the whole thing fits together and how you drive it from a real game client.
The mental model#
A Hold & Win board is a grid of cells that spin independently. The engine’s
atomic spin unit is the column (a ReelSet); the mechanic’s atomic unit is the
cell. So each cell is its own 1×1 ReelSet, and HoldAndWinBuilder wires the
whole grid plus the round choreography.
The most important thing: the board is value-blind. A coin is an opaque record:
{ cell: { reel, cell }, id: 'coin', data: /* anything you want */ }
id picks the registered symbol art; data is yours: values, jackpot tiers,
multipliers, collector flags. The board never reads data. Adders, doublers,
collectors, flights to a meter - none of that is a board feature, all of it is
game logic you run on top of three openings:
| Opening | What it gives you |
|---|---|
board.events | every beat of the round, with the coin payload |
board.symbolAt(cell) | the live ReelSymbol instance - call your own symbol’s methods |
board.cellCenter(cell) / board.cellBounds(cell) | exact pixel geometry for flights & trails |
Keep that split and any Hold & Win variant you can think of is a small amount of game code - the board never changes.
The lifecycle#
The board is a three-state machine. board.phase reports where it is at any
moment ('idle' | 'active' | 'spinning').
idle ──enter(seed)──▶ active ──respin(hits)──▶ spinning ──cells land──▶ active ──┐
▲ │
└──────────── respins left, board not full ──────────────┘
spinning ──board fills OR respins hit 0──▶ feature:end ──▶ idle
enter(seed)- the trigger coins seed the board. They lock instantly (no spin) and the respin counter arms.idle → active.respin(hits)- spins every free cell. The cells named inhitsland their coins; everything else lands blank.active → spinning → active.- A coin landed this wave → counter resets to full.
- Nothing landed → counter decrements.
- The feature ends when the board fills or the counter hits 0 (
→ idle). Then you collect.
What one respin() fires#
Events come in a fixed order. Cells land in stagger order, so cell:landed /
coin:locked fire progressively, not all at once:
respin(hits)
respin:start
├─ cell:landed per cell, in landing order (coin = null on a miss)
└─ coin:locked only on a hit → the board plays the lock animation ('win' by default)
respins:changed reason: 'hit-reset' (full) or 'miss' (-1)
respin:end
[board:full] if the last free cell just locked
[feature:end] if full, or the counter reached 0 → phase returns to idle
Wiring it to your game client#
The board owns the choreography; you own the loop and the result source. A respin’s results come from wherever your real results come from: an RGS, a mock server, an RNG. The canonical driver is deliberately short and visible:
const board = new HoldAndWinBuilder<{ value: number }>()
.grid(5, 3)
.cellSize(72, { gap: 4 })
.symbols((r) => r.register('coin', CoinSymbol, COIN_OPTS))
.weights({ coin: 1, empty: 4 }) // how often coins flash past during the spin
.respins(3)
.ticker(app.ticker) // required — drives every cell's reel
.build();
app.stage.addChild(board.container);
// React to the round however your game presents — HUD, sounds, flights.
board.events.on('coin:locked', ({ coin, locked, capacity }) => {
hud.total += coin.data?.value ?? 0; // YOU own the value; the board doesn't
hud.text = `${locked} / ${capacity}`;
});
// 1. trigger
board.enter(await server.triggerCoins());
// 2. the respin loop — one round per iteration, you pace it
while (true) {
const round = await server.nextRound(board.freeCells); // your result source
const result = await board.respin(round.hits); // board animates the wave
if (result.done) break; // full or out of respins
// anything between rounds — celebrate, delay, play a teaser — happens here
}
// 3. collect — release the coins and fly them wherever you like
for (const coin of board.lockedCoins) flyToMeter(board.cellCenter(coin.cell), coin.data?.value ?? 0);
board.release(board.lockedCoins.map((c) => c.cell));
respin() resolves only after the whole wave has landed and the counter has
resolved, so the loop reads top-to-bottom. Want a single skip button?
Call board.skip() to slam every in-flight cell; the landing → coin:locked →
feature:end flow still resolves, you stop waiting. board.skip({ mode: 'quicken' })
is the press that lands instead of cutting: every in-flight cell drops its spin
floor (the stagger lives there, so the wave lands together) and still spins its
symbol in and bounces; add speed: 'turbo' to land it on turbo, or make it the
board’s default with HoldAndWinBuilder.skipMode('quicken').
Events reference#
Every event carries TData on its coins.
| Event | Payload | Fires when |
|---|---|---|
feature:enter | { seed, respins } | enter() seeds the board |
respin:start | { round, respinsLeft, spinning } | a wave begins |
cell:landed | { cell, coin } (coin: null on a miss) | each cell settles, in stagger order |
coin:locked | { coin, locked, capacity } | a hit cell locks |
cells:activated | { cells, capacity } | activate() woke dormant cells; capacity is the new full-board count |
speed:changed | { name, previous } | setSpeed() moved every cell to another profile |
respins:changed | { value, reason } | counter changes (seed / hit-reset / miss) |
respin:end | { round, hits, respinsLeft } | the wave finishes |
board:full | { coins } | the last free cell locks |
feature:end | { coins, rounds, full } | the feature is over → idle |
coin:released | { coin, remaining } | release() removes a coin (the collect moment) |
feature:reset | { clearedCoins } | reset() hard-clears the board |
feature:skip | { inFlight, mode, speed?, payload? } | skip() freed the in-flight cells: placed ('slam') or landing through their stop ('quicken'). speed and payload are the press’s options as given |
Two deliberate distinctions:
coin:releasedvsfeature:reset.release()means “collect this coin” and firescoin:releasedper coin.reset()is a hard clear back to idle and fires a singlefeature:reset, so a HUD that decrements on collect doesn’t mistakenly tick on a reset.setSymbolAt(cell, id, data)rewrites a locked coin in place (coin → MINI → MAJOR), keeping the ledger correct. It throws on a free cell; placing a brand-new tracked coin isenter/respin’s job.
Rectangular cells, the lock animation, dormant cells#
Three builder knobs cover what a production board usually needs beyond the square default:
const board = new HoldAndWinBuilder()
.grid(5, 3)
// art is 202x170: a seam between columns, rows touching
.cellSize({ width: 101, height: 85 }, { columnGap: 6, rowGap: 0 })
// 'win' (default) plays playWin() on every lock; 'landing' plays the
// symbol's playLanding() settle instead; 'none' plays nothing
.lockAnimation('landing')
// built, drawn as `sealed`, but outside the feature until activate()
.inactive(row(0).concat(row(4)), 'sealed')
...
With 'landing' or 'none' the celebration is yours: await board.playWin() pulses
every locked coin (or board.playWin(cells) a subset) whenever the game decides,
typically on board:full or after feature:end. A coin that starts its own landing
beat on land (a Spine coin with autoPlayLanding, a sprite that reports its settle
through trackLanding) keeps it: 'win' waits for that beat before the celebration
and 'landing' does not replay it. The mode can also be a function of the coin that
locked — .lockAnimation((coin) => coin.id === 'collector' ? 'win' : 'landing') — so a
collector celebrates while plain coins only settle. board.activate(cells) wakes dormant
cells between waves - they show the empty symbol, join the next respin and count toward
isFull from then on; reset() seals them again. The Rectangular cells, Landing only
and Clover board that grows recipes on the Hold & Win recipes page
show each one running.
Draw order between cells#
Every cell is its own reel set, so a coin’s zIndex only ever sorts it against
its own buffers. The art the engine lifts above a cell’s mask at rest (unmask: true) renders in one layer above all cells, in attach order — column-major —
so a lower coin’s overflow is covered by the next column’s. cellZIndex makes
that layer sortable and asks for each cell’s order on every place and landing:
.cellZIndex(({ cell, symbolId, cols }) =>
(symbolId === 'grand' ? 1000 : 0) + cell.cell * cols + cell.reel)
board.liftedLayer is the layer itself, for an effect that has to sit between the
cells and their lifted art. symbolZIndex(resolver) is the per-symbol form from
ReelSetBuilder, applied inside every cell — see big symbols.
Speeds, board-wide#
Every cell is its own reel set with its own SpeedManager, so the board registers
speeds into all of them and switches all of them:
const board = new HoldAndWinBuilder()
.speeds({ normal: NORMAL, turbo: TURBO, superTurbo: SUPER_TURBO })
.stagger((reel, cell, speed) => (speed === 'superTurbo' ? 0 : (reel + cell) * 70))
...
board.setSpeed('turbo'); // every cell, at once; fires speed:changed
board.addSpeed('cinematic', slow); // one more, after build
Same rule as reelSet.setSpeed(): a cell already in flight finishes on the profile it
started with, the next wave runs on the new one, and board.skip() is how a turbo press
cuts the wave in progress - or board.skip({ mode: 'quicken', speed: 'turbo' }), which
lands it on turbo instead of cutting it. Each name also gets a name:tension variant the
board uses for an anticipating wave.
Own the board#
pixi-reels ships boards as two layers, on purpose:
BoardGrid- the primitive. A grid of cells that each spin independently. It knows geometry, instances and spinning, and nothing about coins, locks, respins or value.HoldAndWinBoard- one opinionated board built entirely onBoardGrid’s public API. It adds the lock / respin / collect rules.
That split is the whole point: the engine never decides your game for you. When the built-in board doesn’t fit, you have three escape hatches, in order of effort:
- Configure it. Stagger, respins, weights, anticipation, cell chrome - all builder options. Most games never go past here.
- Build on the primitive. If your feature isn’t lock-and-respin at all
(pick-and-reveal, cluster-collect, scratch-card), skip
HoldAndWinBuilderand useBoardGriddirectly. Here’s a non-Hold & Win board on the primitive. - Fork the board. Want lock-and-respin but with different rules? Copy
HoldAndWinBoard+HoldAndWinStateinto your project and repoint their imports atpixi-reels- everything they reach for (BoardGrid,EventEmitter, theHw*types,cellKey,HwEffect) is public API, so the copy stays on supported ground - then rewrite the reducer. The respin-reset-on-hit rule and board-full-ends-the-feature rule live inHoldAndWinState, yours to change.
The one thing the board will never do is read your data or encode a
paytable. Meaning stays yours; the board only moves reels. That line - mechanism
on our side, domain on yours - is the contract that keeps it out of your way.
Where to go next#
- See the lifecycle live - the event-trace board prints every event as it fires.
- Starter - Hold & Win respin, the minimal board.
- Carry a value - on the coin’s data · baked into a symbol class.
- Flights & collect - collector + bezier choreography · fly to a meter.
- Tension - per-board anticipation.
- Base game → feature - transition in one chain.