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.
DebugPlugin Setup
Section titled “DebugPlugin Setup”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.
DebugPlugin Config
Section titled “DebugPlugin Config”| Property | Type | Default | Description |
|---|---|---|---|
startEnabled | boolean | false | Show overlay on launch |
toggleKey | string | "Backquote" | Key code to toggle overlay |
stepKey | string | "Period" | Key to advance one frozen frame |
maxGraphics | number | 256 | Graphics object pool size |
maxHudLines | number | 32 | Max HUD text lines |
flags | Record<string, boolean> | — | Initial flag overrides |
Built-In Debug Views
Section titled “Built-In Debug Views”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
Custom Debug Contributors
Section titled “Custom Debug Contributors”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));}WorldDebugApi
Section titled “WorldDebugApi”| Method | Description |
|---|---|
acquireGraphics() | Get a pooled Graphics object (returns undefined if pool exhausted) |
cameraZoom | Current camera zoom level (scale line widths by 1/cameraZoom) |
isFlagEnabled(flag) | Check if a debug flag is active |
HudDebugApi
Section titled “HudDebugApi”| Method | Description |
|---|---|
addLine(text) | Add a line to the HUD text overlay |
isFlagEnabled(flag) | Check if a debug flag is active |
screenWidth / screenHeight | Screen dimensions for custom positioning |
Debug Flags
Section titled “Debug Flags”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);Inspector Extensions
Section titled “Inspector Extensions”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");StatsStore
Section titled “StatsStore”StatsStore provides rolling-window statistics for performance monitoring:
import { StatsStore } from "@yagejs/debug";
const stats = new StatsStore();
// Push samples each framestats.push("updateTime", performance.now() - start);
// Read statsstats.average("updateTime"); // rolling averagestats.latest("updateTime"); // most recent sampleFrame-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.
Throwaway Inspector specs
Section titled “Throwaway Inspector specs”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"');});The two spec styles serve very different purposes:
| Permanent Inspector tests in CI | Throwaway agent-vision specs | |
|---|---|---|
| Frequency | Rare | Common |
| Lifetime | Lives with the repo | Deleted after one session |
| What’s asserted | Reproducibility (“snapshots match across reruns”) | Gameplay outcome (“player landed at x>200”) |
| Brittleness to balance changes | Stable by design | Brittle on purpose — that’s why they’re throwaway |
| Example | e2e/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.
Honest limits
Section titled “Honest limits”These are truths, not bugs to fix — keep them in mind when interpreting probe results:
- Visuals:
snapshotJSON()covers structural state — positions, components, scene stack — not pixel output.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 rawperformance.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.
Logging
Section titled “Logging”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 engineengine.logger.info("physics", "Shape spawned", { x: 100, y: 200 });
// From inside a System or Componentclass 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"); } }}Log Methods
Section titled “Log Methods”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.
| Method | Level | Typical use |
|---|---|---|
logger.debug(cat, msg, data?) | Debug | Chatter that only matters when actively diagnosing |
logger.info(cat, msg, data?) | Info | Normal lifecycle events (scene change, entity spawn) |
logger.warn(cat, msg, data?) | Warn | Recoverable problems (missing texture, late frame) |
logger.error(cat, msg, data?) | Error | Broken invariants — something a human should look at |
LoggerConfig
Section titled “LoggerConfig”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: override 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.
Inspecting the Ring Buffer
Section titled “Inspecting the Ring Buffer”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 dumpsconst 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.
Custom Output Sinks
Section titled “Custom Output Sinks”The output option replaces the default console.* handler. 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.