pixi-reels
Building blocks

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 first-class board:

import { HoldAndWinBuilder } from 'pixi-reels';

This guide is the map. The recipes show individual tricks; here is 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 single most important thing to internalise: the board is value-blind. A coin is an opaque record —

{ cell: { col, row }, 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:

OpeningWhat it gives you
board.eventsevery 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
  1. enter(seed) — the trigger coins seed the board. They lock instantly (no spin) and the respin counter arms. idle → active.
  2. respin(hits) — spins every free cell. The cells named in hits land their coins; everything else lands blank. active → spinning → active.
    • A coin landed this wave → counter resets to full.
    • Nothing landed → counter decrements.
  3. 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 calls symbolAt(cell).playWin()
  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;     // 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);
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:lockedfeature:end flow still resolves, you just stop waiting.

Events reference

Every event carries TData on its coins.

EventPayloadFires 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
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 }skip() slams the in-flight cells

Two deliberate distinctions:

  • coin:released vs feature:reset. release() means “collect this coin” and fires coin:released per coin. reset() is a hard clear back to idle and fires a single feature: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 is enter/respin’s job.

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 on BoardGrid’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:

  1. Configure it. Stagger, respins, weights, anticipation, cell chrome — all builder options. Most games never go past here.
  2. Build on the primitive. If your feature isn’t lock-and-respin at all — pick-and-reveal, cluster-collect, scratch-card — skip HoldAndWinBuilder and use BoardGrid directly. Here’s a non-Hold & Win board on the primitive.
  3. Fork the board. Want lock-and-respin but with different rules? Copy HoldAndWinBoard + HoldAndWinState into your project and repoint their imports at pixi-reels — everything they reach for (BoardGrid, EventEmitter, the Hw* 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 in HoldAndWinState, 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