pixi-reels
For authors

Debugging

PixiJS draws to a canvas, so AI agents and automated tooling can’t see the reels. pixi-reels ships three utilities that solve this: debugSnapshot, debugGrid, and enableDebug.

One-liner: attach to window#

import { enableDebug } from 'pixi-reels';
enableDebug(reelSet);
// → window.__PIXI_REELS_DEBUG is now live

In the browser devtools console:

__PIXI_REELS_DEBUG.log();
// [pixi-reels debug] spinning=false speed=normal
// ┌────────┬────────┬────────┬────────┬────────┐
// │ cherry │ lemon  │ bar    │ seven  │ cherry │
// │ plum   │ cherry │ wild   │ lemon  │ orange │
// │ orange │ bell   │ cherry │ plum   │ bell   │
// └────────┴────────┴────────┴────────┴────────┘

const stop = __PIXI_REELS_DEBUG.trace();  // logs every event on the set bus from now on
stop();                                    // and stops

trace() is reelSet.events.onAny(...) with a console.log in it. The same hook is yours on every bus, and onNotice is its twin for engine notices:

import { onNotice } from 'pixi-reels';

reelSet.events.onAny((event, ...args) => log.push({ event, args }));
reelSet.reels[4].events.onAny((event, info) => {
  if (event === 'phase:step') console.log(info); // { phase: 'stop', step: 'delay', status: 'cut' }
});
const off = onNotice(({ kind, code, message }) => telemetry.note(kind, code, message));

The events panel on the recipes site#

Every live recipe on this site has a Debug button (top-left of the canvas). It opens the events panel beside the canvas, under it on a narrow screen: every event every reel set, reel and board in the demo raised, as it happens, with a timestamp relative to the round’s spin:start and the payload unwrapped under the event, one member per line. The filter takes words and !words (skip !symbol:created), pause freezes the view while the recorder keeps going, copy puts the visible rows on the clipboard as JSON. Engine notices show up as notice rows. On a recipe that returns a reel set the same button also draws the geometry overlay described below.

Programmatic snapshot#

import { debugSnapshot, debugGrid } from 'pixi-reels';

const snap = debugSnapshot(reelSet);
// snap: { timestamp, isSpinning, currentSpeed, availableSpeeds, spotlightActive,
//         reelCount, visibleCells, reels: [...], grid: [[...], ...] }

console.log(debugGrid(reelSet));   // ASCII table

Using snapshots in tests#

The test harness pipes snapshots into readable error messages automatically when expectGrid() fails:

Grid mismatch:
  reel 0 cell 1: expected "seven" got "cherry"

Current grid:
┌────────┬────────┬────────┬────────┬────────┐
│ cherry │ bar    │ wild   │ plum   │ seven  │
│ cherry │ lemon  │ orange │ bell   │ seven  │
│ bar    │ bell   │ wild   │ bar    │ cherry │
└────────┴────────┴────────┴────────┴────────┘

The snapshot shapes are typed as DebugSnapshot and DebugReelSnapshot. they’re plain data so they survive structuredClone / postMessage / JSON.stringify without losing fidelity.

Recording sessions#

When a bug only reproduces across a whole spin, record it and read the sequence back offline.

Recording is event-driven, not per-frame: startRecording listens for spin:start, spin:reelLanded, spin:allLanded, spin:complete and destroyed, and captures a snapshot at each. A spin yields a handful of frames, not one per render tick.

import { startRecording, stopRecording, getFrames, clearFrames } from 'pixi-reels';

startRecording(reelSet, 'spin-1');   // (reelSet, tag?, { maxFrames? })
await reelSet.spin();
reelSet.setResult([/* ... */]);
await /* the spin */;
stopRecording(reelSet);

const frames = getFrames('spin-1');  // RecordedFrame[]
console.log(`captured ${frames.length} frames`);
clearFrames();

Each RecordedFrame is { tag, trigger, snapshot } — the tag you passed, the event name that triggered the capture, and the DebugSnapshot. The timestamp lives inside snapshot.

Two things to know about the buffer:

  • It is process-wide and not cleared between sessions, so bare getFrames() returns every take since the page loaded. Pass the tag.
  • It is a rolling window, 1000 frames by default (maxFrames). A long session silently drops the oldest.

The visual overlay#

debugSnapshot tells you what the engine thinks; debugOverlay draws it on top of the canvas, so you can see whether the picture agrees.

import { debugOverlay } from 'pixi-reels';

const overlay = debugOverlay(reelSet, {
  layers: ['cells', 'axis', 'bounds'],   // or 'all'
  live: true,                            // redraw each tick; false = draw once
  ticker: app.ticker,                    // drive the live redraw off your ticker
});

overlay.setLayers(['cells', 'pins']);
overlay.redraw();
overlay.destroy();
LayerDrawsCatches
maskMask box + per-reel rectsPyramid peek; a mask strategy clipping on the wrong axis
cellsEvery visible cell, with reel,cell labelsOff-by-one in cell indexing
buffersThe off-window strip cells, dimmerBuffer targets and big-symbol tails, which are otherwise invisible
axisOne arrow per reel, pointing the way it travelsReverse polarity and horizontal orientation become obvious instead of inferred
feedThe edge new symbols arrive atConfirms feedEdge derives from direction rather than being set twice
thresholdsThe wrap linesNo symbol should ever be drawn past one
boundsReal view.getBounds() per symbolSpine overrun past its cell
blocksgetBlockBounds outline for big symbols2x2 anchors, and block/screen transposition
pinsPin cells and pin-overlay positionsA pin overlay drifting off its cell
hudPer-reel r0 VF feed=start spd=0.0 idle cells=3Reading state without opening the console

The overlay draws into the ReelSet itself, so it renders above the viewport and above the spotlight container. It is dev-only: it reads internals, it is not semver-protected, and it must not reach a production bundle.

describe() - the overlay as data#

A canvas is opaque to CI and to AI agents, and some of what the overlay draws cannot be recovered from a screenshot anyway: a travel arrow pointing up has exactly the same bounding box as one pointing down. So the overlay also hands back plain JSON:

const info = debugOverlay(reelSet, { layers: 'all' }).describe();

info.reels[0];
// {
//   reel: 0,
//   orientation: 'vertical', direction: 'reverse', feedEdge: 'end',
//   axisArrow: { fromMain: 249.6, toMain: 62.4 },   // signed: to - from is the direction
//   feedMain: 187.2,
//   thresholds: { start: -124.8, end: 249.6 },
//   visibleCells: 3,
//   phase: 'idle',
// }

axisArrow’s signed span is the assertion worth making: it is the one thing that distinguishes a reverse reel from a forward one, and a bounding box cannot see it.

Console notices#

Separate from the debug tooling above: the library itself talks to you through one channel, for things a DEVELOPER should act on - a call that will not do what you meant, a hook of yours that threw, a value that had to be clamped. Never per-frame state, and nothing in the hot path.

Every notice carries a stable CODE, so it can be grepped, searched for in these docs, or quoted in a bug report:

pixi-reels  quicken-cascade      requestSkip({ mode: 'quicken' }) has nothing to quicken in
                                 cascade mode: a tumble reel lands by placing its symbols...

Levels#

One knob, four settings, each level including the ones before it:

LevelPrintsUse it when
'info'everythingThe default. Advisory notices (the mask-strategy auto-pick, warp/unmask caveats) plus every problem.
'warn'errors + warningsYou have read the advisories and want them out of the way, but still want to hear about problems.
'error'errors onlySomething the library could not do, and recovered from. Nothing else.
'silent'nothingProduction - once you have read the warnings at least once.
import { setLogLevel, getLogLevel } from 'pixi-reels';

setLogLevel('silent');
getLogLevel(); // 'silent'

The default is 'info' rather than something quieter on purpose: several notices - the mask-strategy auto-pick in particular - used to print unconditionally, and a quieter default would have silently removed advice the engine had always given.

What it looks like where#

In a browser a notice renders as a styled badge: a pixi-reels pill, then the code, then the message. Everywhere else - Node, a test runner, CI - %c is not a thing and the console would print the style directives literally, so it degrades to a plain line instead:

[pixi-reels] warn(quicken-cascade) requestSkip({ mode: 'quicken' }) has nothing to quicken...

Notices go out on console.warn / console.error / console.info rather than funnelling through one console.log, which is what keeps devtools level filtering, stack capture and the browser’s own warn/error styling working. Any extra detail is passed straight through, so an Error argument keeps its stack rather than being stringified.

A few notices fire only once per process (buffer-clamped, for instance) so a builder constructed in a loop cannot flood the console.

The full list of codes lives in the events reference.

Gotchas#

  • debugGrid returns plain text. safe to log, assert against, or paste anywhere.
  • enableDebug is a no-op in Node (it checks typeof window).
  • Snapshots contain no PixiJS objects, so JSON.stringify() will always work.
  • The DEFAULTS constant (exported from pixi-reels) holds five of the engine’s defaults: bufferSymbols, symbolGap, initialSpeed, maxPoolPerKey, zIndexStep. Read it when you want to set a value relative to one of those (e.g. builder.bufferSymbols(DEFAULTS.bufferSymbols + 2)). Other defaults live with the feature that owns them — tumble’s in TumbleConfig, stacking and pin migration on the builder.