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
manualClockbooleanfalseEnable frame-step mode
stepKeystring"Period"Key to advance one frame (manual clock)
maxGraphicsnumber256Graphics object pool size
maxHudLinesnumber32Max HUD text lines
flagsRecord<string, boolean>Initial flag overrides

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

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);

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

Enable manualClock in the config to pause the game loop and step one frame at a time using the step key. This is useful for debugging physics, animations, or frame-specific logic.

new DebugPlugin({ manualClock: true, stepKey: "Period" })

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: 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.

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.

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.