Big symbols
A “big symbol” is a single symbol that visually occupies a rectangular block of cells: a 2×2 bonus, a 3×3 giant, a 1×3 bar, a 2×4 banner. Declared at registration via SymbolData.size. The wire format is ColumnTarget[]: the server places the anchor cell and the engine paints OCCUPIED across the rest.
For the broader picture (pyramid layouts, MultiWays, mutual exclusivity rules), start at the Per-reel geometry overview.
Declaration#
Register it like any other symbol. Add a size: { reels, cells } to its metadata:
.symbols((registry) => {
registry.register('bonus', SpriteSymbol, { textures: { bonus: tex } });
})
.symbolData({
bonus: { weight: 0, zIndex: 5, size: { reels: 2, cells: 2 } },
// ^^^^^^^^^ big symbols MUST have weight 0. random fill can't
// place blocks in v1, so a non-zero weight would
// silently never be picked. Builder throws otherwise.
})
zIndex: 5 is recipe convention. sets the anchor’s z-index above 1×1 neighbors so it draws on top.
How the wire format stays unchanged#
The server places the symbol id at the anchor cell of the block only; every other cell of the block can hold any string, since the engine overwrites it with OCCUPIED_SENTINEL.
// 5x4 grid, 2x2 bonus anchored at (reel=2, cell=1):
setResult([
{ visible: ['low1', 'low2', 'low1', 'low2'] },
{ visible: ['low1', 'low2', 'low1', 'low2'] },
{ visible: ['low1', 'bonus', '?', 'low2'] }, // '?' is whatever; engine paints OCCUPIED
{ visible: ['low1', '?', '?', 'low2'] }, // '?' is whatever; engine paints OCCUPIED
{ visible: ['low1', 'low2', 'low1', 'low2'] },
]);
Internally:
- Cross-reel coordinator runs in
SpinControllerahead of per-reel frame building. It reads the result grid, finds big-symbol anchors, validates block fit, and paintsOCCUPIED_SENTINELacross the rest of the block. - At land, the anchor symbol is sized to span the whole block, gaps included:
(w*cellW + (w-1)*gapX, h*cellH + (h-1)*gapY). Non-anchor cells get an invisibleOccupiedStub(a zero-alpha placeholder). the anchor’s view visually covers them. - Public API never sees the sentinel. Both
Reel.getVisibleSymbols()andReelSet.getVisibleGrid()resolve OCCUPIED. same-reel and cross-reel. to the anchor’s id. Iterating per reel and the grid surface return identical data.
Block lookup#
Two related APIs:
reelSet.getSymbolFootprint(reel, cell)— logical info:{ anchor, size }. For evaluation and event payloads.reelSet.getBlockBounds(reel, cell)— pixel rect covering the whole block. For overlays.
const fp = reelSet.getSymbolFootprint(reel, cell);
// -> { anchor: { reel: 2, cell: 1 }, size: { reels: 2, cells: 2 } }
const rect = reelSet.getBlockBounds(reel, cell);
// → { x, y, width: 2*cellW + symbolGap.x, height: 2*cellH + symbolGap.y }
gfx.rect(rect.x, rect.y, rect.width, rect.height).stroke({ color: 0xff6b35, width: 4 });
Pass any cell of a block. anchor or non-anchor. both APIs return the same data. For 1×1 cells, getBlockBounds is equivalent to getCellBounds.
Validation#
During a spin, the engine throws fail-fast at setResult() if a block doesn’t fit:
"big symbol 'giant' (3x3) at (reel=4, cell=2) exceeds reel count 6.""big symbol 'tallBar' (1x3) at (reel=0, cell=3) extends past the bottom of the strip on reel 0 (anchor cell + h = 6 > visibleCells + bufferEnd = 4)."
The vertical check is against the full strip (visible + bufferEnd), not just visible. Anchors are allowed to land partially, with the block extending into bufferStart or bufferEnd.
A setResult() with no spin in flight returns early, so it validates nothing.
Pinning the non-anchor cell of a big symbol is not supported, but it does not throw: the cross-reel coordinator repaints that cell as OCCUPIED at the next setResult() and the pin quietly disappears. Pin the anchor instead, which covers the block visually because the anchor’s view spans it. (setSymbolAt() on a non-anchor cell does throw.)
The build-time check refuses big symbols with non-zero weight (big symbol 'bonus' (size 2x2) must have weight 0. ...). Random fill can’t place blocks, so non-zero weight would silently never be picked.
Partial landings (anchors in a buffer)#
An anchor does not have to be in a visible cell.
Put a tall block’s anchor in bufferStart and only its tail shows, at the top
of the window. Anchor it at the last visible cell and its head shows while the
rest spills into bufferEnd. Both are legal. Both look deliberate.
Use the explicit ColumnTarget form to target buffer slots:
// 1x3 wild lands with its tail at visible cell 0; anchor + one stub are
// off-screen in bufferStart. Requires bufferSymbols(2) or more.
reelSet.setResult([
{ visible: [...], bufferStart: [undefined, 'tallWild'] },
// ...
]);
getSymbolFootprint(reel, 0) returns anchor.cell = -2. Negative, because the anchor sits before the window. getBlockBounds(reel, 0) gives you the WHOLE block’s rect, off-screen part included — the mask does the clipping, not the maths. See Land a big symbol partially in buffer for the runnable demo.
Mask interaction (SharedRectMaskStrategy)#
If symbolGap.x > 0, the default per-reel mask has horizontal gaps between reel columns. A big symbol’s anchor view extends across multiple reels’ worth of width, but the per-reel mask clips it at every column gap. visible as transparent vertical strips through the symbol.
The engine auto-picks SharedRectMaskStrategy when:
- At least one big symbol is registered (
SymbolData.size > 1×1), or any symbol setsunmask: true, AND - the cross-axis gap is above 0 —
symbolGap.xon a vertical set,symbolGap.yon a horizontal one, AND - No explicit
.maskStrategy(...)call was made.
It logs a console.info(...) so you can see what happened. Pass .maskStrategy(...) to override.
import { SharedRectMaskStrategy } from 'pixi-reels';
builder
.symbolGap(4, 4) // horizontal gap > 0
.maskStrategy(new SharedRectMaskStrategy()) // explicit (or rely on auto-pick)
// ...
SharedRectMaskStrategy draws one rect over the whole set. On a pyramid that
means buffer cells past the ends of short reels become visible — the “pyramid
peek”. Production slots cover it with frame art.
zIndex layering#
When the formula below is not the order you want — a row-dominant grading where the symbol in the bottom row always fronts the one above it, whatever its type — hand the engine a resolver and it stops computing:
.symbolZIndex((ctx) => {
if (!ctx.atRest || ctx.visibleCell === null) return ctx.defaultZIndex;
const grade = GRADES[ctx.symbolId];
return grade === undefined
? ctx.defaultZIndex
: grade * 1000 + ctx.visibleCell * 10 + ctx.reelIndex;
})
The resolver is asked with symbolId, symbolData, reelIndex, reelCount,
arrayIndex, visibleCell (null for a buffer slot), visibleCells,
atRest and defaultZIndex — what the engine would have used — and re-asked
whenever a symbol’s id, cell, reel shape or rest state changes, so its answer
is never overwritten. Cross-reel order only exists for symbols that share a
container: an unmask: true symbol at rest sits in the viewport-wide
unmaskedContainer with every other lifted symbol; a masked one is bounded by
its reel container’s own zIndex (reelStacking). That is why the example
grades at rest only and keeps the engine’s order in motion. Keep every value
below Z_INDEX_BUDGET.pinOverlay.
Anchors render at symbolData.zIndex * 100 + arrayIndex — about 500 with
zIndex: 5. The 100x multiplier leaves room for per-cell stacking inside one
layer, so cells at the larger coordinate still draw in front of their
neighbours.
Pin overlays render at zIndex 10000 (Z_INDEX_BUDGET.pinOverlay; Z_INDEX_BUDGET.symbolLayer is the 100x). So a big symbol that’s also pinned (reelSet.pin(anchorReel, anchorCell, id, ...)) gets its overlay rendered above the cell. The overlay is sized to a single cell, so while the reel spins it does not cover the rest of the block; the block only spans again once the frame commits at land.
| Layer | zIndex | Source |
|---|---|---|
| 1×1 symbol, default | 0 * 100 + arrayIndex | symbolData.zIndex ?? 0 |
| 1×1 symbol, elevated | 1 * 100 + arrayIndex | symbolData.zIndex: 1 |
| Big-symbol anchor (recipe convention) | 5 * 100 + arrayIndex (~500) | symbolData.zIndex: 5 |
| Pin overlay during spin | 10000 | internal (engine-managed) |
| Spotlight (winning symbols) | own container | viewport.spotlightContainer |
Constraints#
- Weight 0 required. Random fill cannot place blocks; the builder throws on non-zero weight. Server places via
setResult(). - Big symbols only at landing. During the spin, every cell is 1×1; the block layout commits at stop.
- No random-fill big symbols. Random fill has no footprint awareness; a future frame middleware could place blocks during scroll.
- Big symbols + MultiWays is rejected at build. See the Per-reel geometry constraint matrix.
See also#
- Guide: Per-reel geometry. overview + constraint matrix + mask strategy
- Guide: MultiWays. mutually exclusive with big symbols
big-symbols-mxnrecipe. every shape: 1×3, 2×2, 3×3, 2×4big-symbol-partial-landrecipe. tail-visible landings viabufferStartbig-symbol-held-respinrecipe. nudge to reveal, then hold across a respinnudge-big-symbolrecipe. nudge a block through visible + buffercard-symbol-debugrecipe. the debugCardSymbolused in these recipescell-boundsrecipe. pixel rects for any cell