Skip to content

Debug Tools

The @yagejs/debug package provides a toggleable debug overlay with physics shape visualization, HUD text, performance stats, and a contributor system for custom debug views.

import { DebugPlugin } from "@yagejs/debug";
engine.use(new DebugPlugin({
startEnabled: true, // show debug overlay on launch
toggleKey: "Backquote", // key to toggle (default: backtick `)
}));

Press the toggle key (` by default) to show/hide the debug overlay at runtime.

PropertyTypeDefaultDescription
startEnabledbooleanfalseShow overlay on launch
toggleKeystring"Backquote"Key code to toggle overlay
stepKeystring"Period"Key to advance one frozen frame
maxGraphicsnumber256Graphics object pool size
maxHudLinesnumber32Max HUD text lines
flagsRecord<string, boolean>Initial flag overrides
eventLogbooleantrueRecord bus + entity events at startup

When the debug overlay is active, the plugin automatically shows:

  • Physics colliders — colored outlines around all collider shapes:
    • Green: dynamic bodies
    • Gray: static bodies
    • Blue: kinematic bodies
    • Yellow: sensor colliders
  • FPS counter — frames per second in the HUD
  • Entity count — total entities in the current scene
  • System timing — per-system execution time breakdown
  • Vector arrows — any per-entity vector you registered with drawVector

drawVector draws an arrow on an entity for a vector you read fresh every frame — velocity, aim direction, knockback, a steering output. You supply the entity and a callback that returns the vector; the overlay draws the arrow from the entity’s world position for as long as the registration lives.

import { DebugRegistryKey } from "@yagejs/debug/api";
class AgentVisual extends Component {
private stopArrow?: () => void;
onAdd(): void {
// tryResolve rather than use(): a build that ships without DebugPlugin
// has no registry to resolve, and use() throws on a missing service.
this.stopArrow = this.context
.tryResolve(DebugRegistryKey)
?.drawVector(this.entity, () => this.agent.velocity, {
scale: 0.35,
color: 0x4ade80,
minLength: 1,
});
}
onDestroy(): void {
this.stopArrow?.();
}
}

Use this.use(DebugRegistryKey) instead when the plugin is always installed — in an example or a dev-only build — and keep tryResolve for game code that has to survive without it.

The callback runs once per frame while the overlay is on, and not at all while it is off, so leaving a drawVector call in a hot path is free. Return null from it to skip a frame (for example while the agent is disabled).

PropertyTypeDefaultDescription
scalenumber1Pixels of arrow per unit of the vector
colornumber0xffffffArrow color
alphanumber0.9Arrow opacity
originVec2Like{ x: 0, y: 0 }World-space offset from the entity’s position
minLengthnumber0Draw nothing below this length
widthnumber2Shaft thickness, in screen pixels
headSizenumber8Arrowhead length, in screen pixels

minLength is measured on the value the callback returns, before scale is applied — so the cutoff is in the vector’s own units, and minLength: 1 on a velocity means “don’t draw below 1 px/s”. A zero-length vector never draws either way, since it has no direction to point in.

The arrow’s length is in world pixels, so it grows and shrinks with camera zoom like everything else in the scene. width and headSize are divided by the camera zoom, so the shaft and head keep the same apparent size no matter how far you are zoomed in. headSize is also clamped to the arrow’s own length, so a very short arrow becomes all head and no shaft rather than growing a head that points back past its own start.

origin shifts the arrow’s start away from the entity’s world position — a muzzle, a hand, a point above the head. It is a world-space offset and is not rotated by the entity. The start point comes from the entity’s world position, so a child entity’s arrow follows its parent correctly.

drawVector returns a disposer that stops the drawing; calling it twice is harmless. The registration is also dropped when the entity’s life ends — whether it was destroyed or was a pool member whose lease ended — so a callback closing over an entity never keeps it alive or draws for something that is gone. A dormant entity (setActive(false)) is different: it keeps its registration and stops drawing until it is active again.

A pooled entity that registers per lease in onAcquire should call the disposer in onRelease, alongside its other per-lease cleanup. Arrows never accumulate across lives either way — a new lease retires the previous lease’s — but without the onRelease call the last lease’s registration is held until the member is leased again or the pool is disposed.

Arrows draw under the vectors contributor’s arrows flag, so they can be switched off on their own while the rest of the overlay stays up:

registry.setFlag("vectors", "arrows", false);

Arrows share the overlay’s Graphics pool (maxGraphics, default 256) with collider outlines and every other world-space view. If the pool runs out in a frame, the remaining arrows are skipped that frame — raise maxGraphics if that happens.

Implement DebugContributor to add your own debug visualizations:

import type { DebugContributor, WorldDebugApi, HudDebugApi } from "@yagejs/debug/api";
class WallDebugContributor implements DebugContributor {
readonly name = "walls";
readonly flags = ["show-walls"] as const;
private readonly shapes: Array<{ x: number; y: number; w: number; h: number }>;
constructor(shapes: Array<{ x: number; y: number; w: number; h: number }>) {
this.shapes = shapes;
}
drawWorld(api: WorldDebugApi): void {
if (!api.isFlagEnabled("show-walls")) return;
for (const s of this.shapes) {
const g = api.acquireGraphics();
if (!g) break;
g.rect(s.x, s.y, s.w, s.h)
.stroke({ color: 0xff0000, width: 2 / api.cameraZoom });
}
}
drawHud(api: HudDebugApi): void {
api.addLine(`Walls: ${this.shapes.length}`);
}
}

Register it in your scene:

import { DebugRegistryKey } from "@yagejs/debug/api";
onEnter(): void {
const registry = this.service(DebugRegistryKey);
registry.register(new WallDebugContributor(this.wallShapes));
}
MethodDescription
acquireGraphics()Get a pooled Graphics object (returns undefined if pool exhausted)
cameraZoomCurrent camera zoom level (scale line widths by 1/cameraZoom)
isFlagEnabled(flag)Check if a debug flag is active
MethodDescription
addLine(text)Add a line to the HUD text overlay
isFlagEnabled(flag)Check if a debug flag is active
screenWidth / screenHeightScreen dimensions for custom positioning

Contributors can declare flags for toggling specific views:

class MyContributor implements DebugContributor {
readonly name = "my-debug";
readonly flags = ["show-paths", "show-hitboxes"] as const;
drawWorld(api: WorldDebugApi): void {
if (api.isFlagEnabled("show-paths")) {
// draw paths...
}
if (api.isFlagEnabled("show-hitboxes")) {
// draw hitboxes...
}
}
}

Set flags programmatically:

registry.setFlag("my-debug", "show-paths", true);

Visual debug overlays and programmatic debug helpers are separate concerns. Use DebugRegistry contributors for drawing, and register inspector extensions when a plugin wants to expose imperative helpers to tests or the browser console.

import type { EngineContext } from "@yagejs/core";
import { InspectorKey } from "@yagejs/core";
install(context: EngineContext): void {
const inspector = context.resolve(InspectorKey);
inspector.addExtension("inventory", {
listItems: () => this.inventory.snapshot(),
grantItem: (id: string) => this.inventory.grant(id),
});
}

Consume that extension from test code or the browser console:

const inventory = window.__yage__.inspector.getExtension<{
listItems(): string[];
grantItem(id: string): void;
}>("inventory");
inventory?.grantItem("rocket-boots");
inventory?.listItems();

DebugPlugin uses the same pattern for renderer-aware helpers:

const debug = window.__yage__.inspector.getExtension("debug");
debug?.getCameraStack?.();
debug?.getLayerTransform?.("game", "world");
debug?.setHudVisible?.(false);

setHudVisible(false) hides only the HUD text readouts (FPS, system timings, entity counts) — world-space debug graphics such as collider outlines stay visible. It re-renders the stage synchronously, so it takes effect even while the debug clock is frozen. Screenshot tooling uses it to keep wall-clock numbers out of captures that would otherwise differ on every run.

StatsStore provides rolling-window statistics for performance monitoring:

import { StatsStore } from "@yagejs/debug";
const stats = new StatsStore();
// Push samples each frame
stats.push("updateTime", performance.now() - start);
// Read stats
stats.average("updateTime"); // rolling average
stats.latest("updateTime"); // most recent sample

Frame-step mode and agent-driven debugging

Section titled “Frame-step mode and agent-driven debugging”

DebugPlugin always installs the frozen-clock implementation. To step exact frames, freeze through the Inspector and then advance from code or with the step key:

window.__yage__.inspector.time.freeze();
window.__yage__.inspector.time.step(1);
window.__yage__.inspector.time.thaw();

The configured stepKey only advances frames while the inspector clock is frozen. This is useful for debugging physics, animations, or frame-specific logic.

time.step(N) is fully synchronous, so it never gives async work a chance to run. A scene transition, a dialogue runner, or anything else that resolves through a promise chain queues its continuation as a microtask, and a plain step() call never drains that queue — a script waiting on the transition sees stale state and looks stuck.

time.stepUntil() and time.stepAsync() solve this by yielding to a real macrotask after every frame, giving pending microtasks a chance to run before the next frame steps:

// Advance frame-by-frame until a condition holds, checking before the first
// frame and after each one. Throws if `maxFrames` (default 600, ~10s at
// 60fps) is reached without the predicate becoming true.
const frames = await window.__yage__.inspector.time.stepUntil(
() => window.__yage__.inspector.getSceneStack().some((s) => s.name === "level2"),
{ maxFrames: 300 },
);
// Advance a known frame count, still draining async work between frames:
await window.__yage__.inspector.time.stepAsync(45);
await window.__yage__.inspector.time.stepAsync(10, { dtMs: 32 }); // custom per-frame dt

The clock still has to be frozen first, same as time.step. Reach for stepUntil/stepAsync whenever the sequence crosses a scene transition or an async runner; keep using time.step(N) for everything else, since it is the simpler call.

The Inspector + frozen clock + scripted input together make a tight short-loop for LLM-assisted debugging and gameplay validation. The intended workflow is a throwaway Playwright spec — write it, run it, delete it. Not a CI fixture, not a permanent regression suite. A scratch session.

Suppose you just changed the jump arc. Drop a spec that boots the platformer, holds Right for 30 frames, fires the jump action, steps 45 frames, and asserts the player landed on a specific ledge entity. Run it. If it passes, delete it. If it fails, iterate.

import { test, expect } from "@playwright/test";
test("can the player jump onto the ledge?", async ({ page }) => {
await page.goto("/platformer.html");
await page.waitForFunction(() => window.__yage__?.inspector);
const result = await page.evaluate(async () => {
const i = window.__yage__.inspector;
i.setSeed(42); // pin RNG for reproducible runs
i.time.freeze(); // stop auto-advance
await i.input.hold("ArrowRight", 30);
await i.input.fireAction("jump", 1);
i.time.step(45); // advance 45 fixed-timestep frames
return i.snapshotJSON();
});
// Optional: capture a frame for visual inspection.
// await page.screenshot({ path: "/tmp/probe.png" });
expect(result).toContain('"name":"player"');
});

setSeed(seed) reseeds the scene RNG that game code reads through RandomKey, a scene-scoped ServiceKey<RandomService> resolved in a Component with this.use(RandomKey) (float, range, int, pick, shuffle, getSeed). Read random values through it rather than Math.random(), or a replay diverges from the seeded run. For boot-time or cross-scene code that runs outside a scene, use globalRandom — but setSeed does not reseed it, so keep replay-critical rolls on RandomKey.

The two spec styles serve very different purposes:

Permanent Inspector tests in CIThrowaway agent-vision specs
FrequencyRareCommon
LifetimeLives with the repoDeleted after one session
What’s assertedReproducibility (“snapshots match across reruns”)Gameplay outcome (“player landed at x>200”)
Brittleness to balance changesStable by designBrittle on purpose — that’s why they’re throwaway
Examplee2e/specs/inspector-determinism.spec.ts”did my jump-arc change still let the player reach the ledge?”

Always advance via inspector.time.step(N) (loops one fixed-timestep frame at a time) over clock.step(bigDt) (collapses the interval into one fat frame). Component.update, tweens, and AI logic only see one update at the full bigDt under the latter, which diverges from real gameplay even though physics still substeps correctly.

A snapshot’s components[].state and getComponentData() read a component’s serialize() result when it defines one. A component with no serialize() still reports state: both fall back to reading the component’s own enumerable fields plus its public getters (get isReady(), get health(), and similar) straight off the instance.

class Cooldown extends Component {
private _ready = false;
get isReady() {
return this._ready;
}
}
// No serialize() defined — the reflected state still shows up:
window.__yage__.inspector.getComponentData("turret", "Cooldown");
// { isReady: true }

Fields and getters starting with _ are excluded, along with functions and non-plain-object values — a Pixi/Rapier handle or other class instance would either fail to serialize or leak a meaningless object identity. A getter that throws is skipped rather than failing the whole snapshot. Define serialize() when a component needs a specific shape, such as renamed keys or a value that shouldn’t be recomputed on every read; otherwise the reflected state is enough to inspect a component with no extra code.

The entity transform in a snapshot is the entity origin. It does not tell you where a component actually paints, how big it is, or whether it is hidden — and for partial reveals (a typewriter SplitTextComponent that toggles chars[i].visible) the persisted serialize() state disagrees with the screen, reporting the full string with no per-glyph detail.

When RendererPlugin is active it publishes a derived render facet for each graphical component on the snapshot, under facets.render, computed on demand from the live display object:

const scene = window.__yage__.inspector.snapshot().scenes[0];
const label = scene.entities.find((e) =>
e.components.some((c) => c.type === "SplitTextComponent"),
);
// World-space bounds + component-local visibility, surfaced at the entity level
// (first painted component the entity added) and per component:
label?.facets?.render; // { bounds: { x, y, width, height } | null, visible }
label?.components.find((c) => c.type === "SplitTextComponent")?.facets?.render;
// SplitTextComponent additionally reports per-glyph state and the visible
// substring — so a typewriter reveal is testable without touching Pixi.
// `facets.render` is typed as the base facet, so widen it to read the SplitText
// extras (SplitTextRenderFacet is exported from @yagejs/renderer):
const split = label?.components.find(
(c) => c.type === "SplitTextComponent",
)?.facets?.render as SplitTextRenderFacet | undefined;
split?.glyphs; // [{ visible }, ...] in reading order
split?.visibleText; // e.g. "Hel" while only the first 3 glyphs show

bounds are world-space pixels — the same space as transform, before the camera and responsive fit transform — measured from the geometry itself, so a sized-but-hidden object still reports its real box. bounds is null only when there is no geometry to measure (an empty Graphics, a zero-area object), never merely because the object is hidden — read visible for that. visible is the component’s own (local) flag; Pixi v8 exposes no world-resolved visibility, so a hidden parent/layer container is not folded in. glyphs and visibleText cover only rendered glyph segments — SplitText.chars excludes whitespace, so a fully-revealed "Hello world" reports "Helloworld"; compare which glyphs are visible rather than treating it as the verbatim string. The facet is read-only and never part of serialize(), so it does not affect save/load.

The facet lives entirely in @yagejs/renderer, keeping @yagejs/core renderer-agnostic. The Inspector exposes a generic seam — registerFacetContributor() attaches namespaced facets to snapshots — and RendererPlugin registers a RenderFacetContributor for the render namespace (the same contributor idiom as a DebugContributor or a save SnapshotContributor). That contributor duck-types inspectRender() off each component, so a custom graphical component opts in simply by exposing an inspectRender(): RenderFacetSnapshot method. SpriteComponent, AnimatedSpriteComponent, GraphicsComponent, TextComponent, and SplitTextComponent all implement it.

These are truths, not bugs to fix — keep them in mind when interpreting probe results:

  • Visuals: snapshotJSON() covers structural state — positions, components, scene stack — plus the render facet’s world-space bounds and visibility, but still not pixel output (colours, shaders, exact rasterisation). page.screenshot() helps but agent-grade interpretation of pixels is imperfect. Combine both for confidence.
  • Audio: no introspection surface, and WebAudio doesn’t pause in step mode. Probes can’t tell you whether a sound effect played at the right frame.
  • Wall-clock leaks: setTimeout, Date.now(), and raw performance.now() reads bypass the frame clock. No callers in core YAGE today, but custom plugins might.
  • step(bigDt)stepFrames(N) for variable-update logic. Always prefer the latter in probes.

Scene lookup, the event log, and stall detection

Section titled “Scene lookup, the event log, and stall detection”

snapshotScene(nameOrId) takes either the public scene.name or the inspector-assigned id from snapshot().scenes[].id / getSceneStack()[].id, trying the name first:

window.__yage__.inspector.snapshotScene("level2");

If more than one active scene shares that name, snapshotScene throws instead of guessing — pass the id in that case.

The event log records bus and entity events for events.getLog() and events.waitFor(). It is on by default (DebugConfig.eventLog); turn it off at runtime when a probe doesn’t read the log, to skip the per-event allocation entirely:

window.__yage__.inspector.events.setEnabled(false);
window.__yage__.inspector.events.isEnabled(); // false

Each entry’s payload is plain data. A class instance inside a payload is stored as a compact ref rather than a deep copy: an Entity as { id, name }, a Component as { component: "Health" }, a Scene as { name }, a Vec2 as { x, y }, and anything else as { _type: "ClassName" }.

One entry, for a component:added bus event:

{
"frame": 12,
"source": "bus",
"type": "component:added",
"payload": {
"entity": { "id": 4, "name": "player" },
"component": { "component": "Health" }
}
}

Engine events pass live objects — component:added carries the Component itself — and a deep copy of one would pull in its entity, that entity’s scene, and everything the scene holds. A ref carries only the fields that identify what the event was about. For a component’s field values, read the entity snapshot; that is where component state lives. Event subscribers (engine.events.on, entity.on) always receive the live object — the ref applies only to the log’s stored copy.

time.isAdvancing(withinMs = 250) reports whether the game loop actually ticked within the last withinMs milliseconds, independent of time.isFrozen(). A frozen clock that isn’t being stepped reads isAdvancing() === false, but a manual time.step/stepUntil/stepAsync fires a real tick, so isAdvancing() reads true for withinMs after one. A game that has stalled without being frozen — a hung await, a runaway synchronous loop — also reads false. isFrozen() alone can’t distinguish those two cases; isAdvancing() exists for that.

The Logger class is a category-tagged, ring-buffered logger built into the engine. Unlike the debug overlays above, it lives in @yagejs/core, not @yagejs/debug — you don’t need the DebugPlugin installed to use it. Every Engine has a Logger attached at construction time, and the game loop auto-updates the logger’s frame counter so every log entry records the frame it was emitted on.

Access it as engine.logger, or through DI via LoggerKey:

import { Logger, LogLevel, LoggerKey } from "@yagejs/core";
// Direct access on the engine
engine.logger.info("physics", "Shape spawned", { x: 100, y: 200 });
// From inside a System or Component
class SpawnSystem extends System {
private logger!: Logger;
init(context: EngineContext) {
this.logger = context.resolve(LoggerKey);
}
update(dt: number) {
if (wave.complete) {
this.logger.warn("gameplay", "Wave ended with no kills");
}
}
}

All four methods take the same shape: (category, message, data?). The category is a free-form string — use whatever taxonomy suits your game ("physics", "ai", "input", "gameplay"). data is an arbitrary object that gets attached to the entry for later inspection.

MethodLevelTypical use
logger.debug(cat, msg, data?)DebugChatter that only matters when actively diagnosing
logger.info(cat, msg, data?)InfoNormal lifecycle events (scene change, entity spawn)
logger.warn(cat, msg, data?)WarnRecoverable problems (missing texture, late frame)
logger.error(cat, msg, data?)ErrorBroken invariants — something a human should look at

Pass a logger option when constructing the engine to configure level, category filter, ring buffer size, or a custom output sink:

import { Engine, LogLevel } from "@yagejs/core";
const engine = new Engine({
logger: {
level: LogLevel.Info, // drop anything below Info
categories: ["physics", "ai"], // only accept these categories (empty = all)
bufferSize: 1000, // keep the last 1000 entries
output: (entry) => { // optional: replace the default dev console output
console.log(`[${entry.category}] ${entry.message}`, entry.data);
},
},
});

LogLevel is an enum: Debug (0), Info (1), Warn (2), Error (3), None (4). Anything below the configured level is silently dropped at the log site — entries never enter the ring buffer, so there’s no cost to leaving debug calls in shipping code with level: LogLevel.Info.

The ring buffer is what makes the logger useful beyond console.log. Each entry is a LogEntry carrying level, category, message, optional data, a timestamp, and the frame number the game loop was on when the entry was emitted. You can pull recent entries at any time:

// Grab the last N entries (default: everything in the buffer)
const recent: LogEntry[] = engine.logger.getRecent(20);
// Or pre-formatted as a string, handy for crash dumps
const dump: string = engine.logger.formatRecentLogs(50);
console.log(dump);

A common pattern is to catch an error, dump the recent logs, and ship the result to a crash reporter:

window.addEventListener("error", (ev) => {
reportCrash({
error: ev.error,
recentLogs: engine.logger.formatRecentLogs(100),
});
});

Because every entry carries a frame number, you can correlate log output with specific frames seen in the debug overlay or inspector — very useful for reproducing frame-specific bugs.

In a dev build, the logger prints every accepted entry through the matching console.* method by default — no output needed. That default drops out of a production build, so a shipped game logs to the ring buffer only unless you supply your own output. Passing output always overrides the default, in dev or production. Use it to ship logs somewhere else — an in-game debug panel, a remote telemetry service, or a file (in Electron/Tauri builds):

const engine = new Engine({
logger: {
output: (entry) => {
// Forward warnings and errors to a remote logging backend
if (entry.level >= LogLevel.Warn) {
telemetry.send("game-log", {
level: LogLevel[entry.level],
category: entry.category,
message: entry.message,
data: entry.data,
frame: entry.frame,
timestamp: entry.timestamp,
});
}
// Also mirror to the console in dev
if (import.meta.env.DEV) {
console.log(`[${entry.category}]`, entry.message, entry.data);
}
},
},
});

Custom sinks don’t replace the ring buffer — entries are still stored in-memory and accessible via getRecent() regardless of what the sink does.

The engine calls a lot of code you supply directly — collision and trigger handlers, entity/scene/global event listeners, input listeners, process callbacks, scene lifecycle hooks, and your own systems’ and components’ update(). When one of those throws, the engine reports the culprit with its stack and rethrows. A game running in an indeterminate state — one part quietly disabled while everything else keeps going on stale assumptions — is worse than one that stopped with useful information:

// A door pad. The typo is real: `otherr`.
pad.get(ColliderComponent).onTrigger((e) => {
if (e.otherr.has(PlayerTag)) openDoor(); // throws on every touch
});
[yage] ERROR core Collision handler threw on entity "DoorPad"
TypeError: Cannot read properties of undefined (reading 'has')
Uncaught TypeError: Cannot read properties of undefined (reading 'has')

That console line follows the same dev-only default as any other logger.error call — see LoggerConfig above. The thrown error itself is not gated by that default, but it doesn’t surface through a try/catch around engine.start() — a collision handler like the one above fires on a later game-loop tick, well after start() has already returned. GameLoop.tick() is the one place that decides a failure is terminal: an error that escapes an entire frame unhandled stops the loop and rethrows out of tick(), so it reaches the host — window.onerror in a browser, uncaughtException in Node, or your own try/catch around a manual engine.loop.tick(dt). An error your own code catches inside the frame — wrapping the call that invokes the callback, e.g. around entity.emit(...) — leaves the loop running.

An async callback that rejects can’t be rethrown into the call that triggered it — that call already returned by the time the rejection settles. The engine reports it, then re-raises the rejection so it reaches the host’s unhandled-rejection channel (window.onunhandledrejection, or Node’s unhandledRejection event) instead of vanishing.

Catch what you expect and let the rest surface. Wrap a call in your own try/catch when a failure there is routine (a save file that might not parse, a network request that might fail); anything you didn’t wrap is a bug, and it surfaces immediately instead of hiding behind a silently disabled system.

Every failure is also recorded, so a test or crash reporter can check for it without scraping console output:

const errors = engine.inspector.getErrors();
console.log(errors.callbackErrors);
// [{ kind: "Collision handler", entity: "DoorPad",
// error: "Cannot read properties of undefined (reading 'has')" }]

callbackErrors is a bounded history — the 200 most recent failures. The same Error object propagating through nested wraps (a collision handler’s throw reaching the system update that dispatched it) is recorded once, not once per wrap.

Scene lifecycle hooks (onEnter, onExit, onPause, onResume, a plugin’s beforeEnter) are reported the same way. A synchronous throw also keeps propagating to the caller — await engine.scenes.replace(...) still rejects. An async hook’s rejection is reported only, not propagated: the call has already returned by the time the rejection settles.