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();
| Layer | Draws | Catches |
|---|---|---|
mask | Mask box + per-reel rects | Pyramid peek; a mask strategy clipping on the wrong axis |
cells | Every visible cell, with reel,cell labels | Off-by-one in cell indexing |
buffers | The off-window strip cells, dimmer | Buffer targets and big-symbol tails, which are otherwise invisible |
axis | One arrow per reel, pointing the way it travels | Reverse polarity and horizontal orientation become obvious instead of inferred |
feed | The edge new symbols arrive at | Confirms feedEdge derives from direction rather than being set twice |
thresholds | The wrap lines | No symbol should ever be drawn past one |
bounds | Real view.getBounds() per symbol | Spine overrun past its cell |
blocks | getBlockBounds outline for big symbols | 2x2 anchors, and block/screen transposition |
pins | Pin cells and pin-overlay positions | A pin overlay drifting off its cell |
hud | Per-reel r0 VF feed=start spd=0.0 idle cells=3 | Reading 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:
| Level | Prints | Use it when |
|---|---|---|
'info' | everything | The default. Advisory notices (the mask-strategy auto-pick, warp/unmask caveats) plus every problem. |
'warn' | errors + warnings | You have read the advisories and want them out of the way, but still want to hear about problems. |
'error' | errors only | Something the library could not do, and recovered from. Nothing else. |
'silent' | nothing | Production - 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#
debugGridreturns plain text. safe to log, assert against, or paste anywhere.enableDebugis a no-op in Node (it checkstypeof window).- Snapshots contain no PixiJS objects, so
JSON.stringify()will always work. - The
DEFAULTSconstant (exported frompixi-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 inTumbleConfig, stacking and pin migration on the builder.