PR pixi-reels
All recipes

Value-carrying coin (Hold & Win)

Classic Hold & Win architecture. one 1x1 ReelSet per cell, coin symbols carry their payout value in the pin payload, pinned cells skip their spin entirely.

Loading recipe…

The Lightning Link / Dragon Link Hold & Win pattern, built with CellPin. The grid is not one 5×3 ReelSet. it’s 15 independent 1×1 ReelSets, one per cell. Each spins on its own, each holds coins on its own, and the CellPin primitive handles the “hold” part.

Why per-cell ReelSets

In a Hold & Win feature, locked cells don’t spin. only unlocked cells do. A single 5×3 ReelSet spins whole columns at once; you’d have to work around it. Per-cell mini-reels make the “spin only the unlocked cells” pattern trivial:

for (const cell of cells) {
  if (cell.reelSet.getPin(0, 0)) continue; // already held, skip
  spinPromises.push(cell.reelSet.spin());
}

The mechanic in full

// Create 15 mini ReelSets at build time (shown abbreviated)
for (let col = 0; col < 5; col++) {
  for (let row = 0; row < 3; row++) {
    const mini = new ReelSetBuilder()
      .reels(1).visibleRows(1)
      .symbolSize(CELL, CELL)
      .symbols((r) => {
        r.register('coin', BlurSpriteSymbol, { textures, blurTextures });
        r.register('empty', EmptySymbol, {});
      })
      .weights({ coin: 1, empty: 3 })
      .ticker(app.ticker)
      .build();
    cells.push({ col, row, reelSet: mini });
  }
}

// Each round: spin only the unlocked cells, then pin any hits
async function playRound(hits) {
  const spinning = cells.filter((c) => !c.reelSet.getPin(0, 0));
  const promises = spinning.map((c) => c.reelSet.spin());
  await new Promise((r) => setTimeout(r, 140));

  for (const cell of spinning) {
    const isHit = hits.some((h) => h.col === cell.col && h.row === cell.row);
    cell.reelSet.setResult([[isHit ? 'coin' : 'empty']]);
  }
  await Promise.all(promises);

  // Pin every hit with its payout value. the engine keeps the coin in place
  for (const cell of spinning) {
    if (!hits.some((h) => h.col === cell.col && h.row === cell.row)) continue;
    cell.reelSet.pin(0, 0, 'coin', {
      turns: 'permanent',
      payload: { value: rollCoinValue() },
    });
  }
}

Reading the total

Because each ReelSet’s pins live on their own map, the total is a sum across all mini-reels:

function currentTotal(): number {
  let total = 0;
  for (const cell of cells) {
    const pin = cell.reelSet.getPin(0, 0);
    if (typeof pin?.payload?.value === 'number') total += pin.payload.value;
  }
  return total;
}

The demo recomputes this on every pin:placed and pin:expired, keeping the TOTAL display live.

Ending the feature

When the round counter runs out:

for (const cell of cells) {
  const pin = cell.reelSet.getPin(0, 0);
  if (pin) cell.reelSet.unpin(0, 0);
}

Each unpin fires pin:expired with reason 'explicit', clearing badges and zeroing the total.

  • EmptySymbol. the blank-cell symbol class this recipe registers as the non-coin background
  • Collector symbol. same architecture + one collector that absorbs adjacent pin values
  • Multiplier wild. payload pattern on a single full-grid ReelSet