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 |
eventLog | boolean | true | Record bus, entity and scene events at startup |
deterministicSeed | number | — | Seed every scene’s RNG with this value, for reproducible runs |
startFrozen | boolean | false | Freeze the Inspector clock before the first frame |
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 across the scene stack, including inactive entities and excluding destroyed ones
- System timing — per-system execution time breakdown
- Vector arrows — any per-entity vector you registered with
drawVector
Vector Arrows
Section titled “Vector Arrows”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 { Component } from "@yagejs/core";import { DebugRegistryKey } from "@yagejs/debug/api";import { SteeringAgent } from "@yagejs-addons/steering";
class AgentVisual extends Component { private readonly agent = this.sibling(SteeringAgent); private stopArrow: (() => void) | undefined;
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 while the overlay is on and the entity’s scene is visible.
Arrows follow that scene’s primary camera, including shake, rotation and zoom.
Hidden or popped scenes neither draw arrows nor call their providers. Return
null from the callback to skip a frame.
Options
Section titled “Options”| Property | Type | Default | Description |
|---|---|---|---|
scale | number | 1 | Pixels of arrow per unit of the vector |
color | number | 0xffffff | Arrow color |
alpha | number | 0.9 | Arrow opacity |
origin | Vec2Like | { x: 0, y: 0 } | World-space offset from the entity’s position |
minLength | number | 0 | Draw nothing below this length |
width | number | 2 | Shaft thickness, in screen pixels |
headSize | number | 8 | Arrowhead 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.
Lifetime
Section titled “Lifetime”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:
import { DebugRegistryKey } from "@yagejs/debug/api";
const registry = context.resolve(DebugRegistryKey);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.
Custom Debug Contributors
Section titled “Custom Debug Contributors”Implement DebugContributor to add your own debug visualizations:
import type { Scene } from "@yagejs/core";import type { DebugContributor, WorldDebugApi, HudDebugApi } from "@yagejs/debug/api";
export 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( private readonly scene: Scene, shapes: Array<{ x: number; y: number; w: number; h: number }>, ) { this.shapes = shapes; }
drawWorld(api: WorldDebugApi): void { if (!api.isFlagEnabled("show-walls")) return; const target = api.forScene(this.scene); if (!target) return;
for (const s of this.shapes) { const g = target.acquireGraphics(); if (!g) break; g.rect(s.x, s.y, s.w, s.h) .stroke({ color: 0xff0000, width: 2 / target.cameraZoom }); } }
drawHud(api: HudDebugApi): void { api.addLine(`Walls: ${this.shapes.length}`); }}Register it in your scene:
import { Scene } from "@yagejs/core";import { DebugRegistryKey } from "@yagejs/debug/api";import { WallDebugContributor } from "./walls.js";
class ArenaScene extends Scene { readonly name = "arena"; private readonly wallShapes = [{ x: 0, y: 584, w: 800, h: 16 }];
onEnter(): void { const registry = this.service(DebugRegistryKey); registry.register(new WallDebugContributor(this, this.wallShapes)); }}WorldDebugApi
Section titled “WorldDebugApi”| Method | Description |
|---|---|
forScene(scene) | Get a SceneWorldDebugApi for a visible scene, or undefined when hidden or popped |
acquireGraphics() | Get pooled DebugGraphics using the topmost visible camera, or undefined when the pool is exhausted |
cameraZoom | That camera’s effective zoom |
isFlagEnabled(flag) | Check if a debug flag is active |
SceneWorldDebugApi has acquireGraphics() and readonly cameraZoom. Use it
for drawings tied to a scene, such as walls or paths. Graphics use that scene’s
full primary-camera transform, regardless of explicit layer bindings in the
game. With no camera, graphics use identity coordinates and zoom is 1.
Physics outlines and vector arrows use these scene targets automatically.
The unscoped methods use the primary camera of the topmost visible scene that
has one. They suit input and singleton diagnostics. Every scene and contributor
shares the same maxGraphics limit.
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:
import type { DebugContributor, WorldDebugApi } from "@yagejs/debug/api";
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:
import { DebugRegistryKey } from "@yagejs/debug/api";
const registry = context.resolve(DebugRegistryKey);registry.setFlag("my-debug", "show-paths", true);The Inspector
Section titled “The Inspector”The Inspector is the API that tests, agents and the browser console use to read
and drive a running game: snapshots, entity queries, the event log, frame
stepping and synthetic input. DebugPlugin installs one. To get the Inspector
without the overlay, in a headless test, a tool, or a build with no renderer,
install InspectorPlugin from @yagejs/core:
import { Engine, InspectorKey, InspectorPlugin } from "@yagejs/core";
const engine = new Engine({ debug: true });engine.use(new InspectorPlugin());await engine.start();
engine.context.resolve(InspectorKey).snapshot();window.__yage__.inspector.snapshot(); // debug: true publishes it here tooThe engine does not create an Inspector by itself, so a production build that
installs neither plugin runs without one. debug: true publishes
window.__yage__; if no Inspector is installed, the engine warns once in a
development build. Installing both plugins is harmless: whichever installs
first creates the Inspector, and the other uses it.
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, Plugin } from "@yagejs/core";import { InspectorKey } from "@yagejs/core";
interface Inventory { snapshot(): string[]; grant(id: string): void;}
class InventoryPlugin implements Plugin { readonly name = "inventory"; readonly version = "1.0.0"; private context!: EngineContext;
constructor(private readonly inventory: Inventory) {}
install(context: EngineContext): void { this.context = context; }
onStart(): void { // In onStart: DebugPlugin or InspectorPlugin installs the Inspector, and // every install has run by now. tryResolve: a build without either has none. this.context.tryResolve(InspectorKey)?.addExtension("inventory", { listItems: () => this.inventory.snapshot(), grantItem: (id: string) => this.inventory.grant(id), }); }}Register it in onStart rather than install. Another plugin installs the
Inspector, and every plugin’s install has run by the time onStart does.
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:
import type { DebugDiagnostics } from "@yagejs/debug";
const debug = window.__yage__.inspector.getExtension<DebugDiagnostics>("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
Section titled “StatsStore”StatsStore provides rolling-window statistics for performance monitoring:
import { StatsStore } from "@yagejs/debug";
const stats = new StatsStore();
// Push samples each frameconst start = performance.now();// ...the work you are timing...stats.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.
time.getFrame() counts real game-loop frames during automatic playback and
manual stepping. Snapshot, event-log and logger frame numbers use the same
count. Freezing the clock does not reset it; take a baseline before stepping
when measuring an interval.
Async stepping across scene transitions
Section titled “Async stepping across scene transitions”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 dtThe clock has to be frozen first, same as time.step — inspector.drive()
below does that for you. 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.
Reserving clock control
Section titled “Reserving clock control”Use a lease when a custom tool needs exclusive control across several calls:
const time = inspector.time.acquire();try { time.freeze(); await time.stepAsync(30, { dtMs: 1000 / 60 });} finally { time.release();}The returned InspectorTimeLease has the same controls as inspector.time
plus release(). Acquisition needs DebugPlugin and throws if another tool
owns the clock. Raw inspector.time mutations reject while leased; queries
remain available. isOwned() reports whether a lease is held.
Acquisition and release do not change the frozen state, delta or frame count.
Release is safe to call twice. A released lease can query, but cannot mutate.
Raw stepAsync and stepUntil reserve control until they settle. Await each
operation before starting another, including operations through one lease.
Waiting for the engine, and driving it in one call
Section titled “Waiting for the engine, and driving it in one call”An engine built with debug: true publishes window.__yage__ as start()
begins. The object carries logger, ready, and the inspector that
DebugPlugin installs. DebugPlugin also supplies the controls accessed
through inspector.time.
ready settles when start() finishes — plugins installed, loop running,
onStart hooks done. It is what a script waits on after loading or reloading
the page, instead of sleeping and hoping:
await page.waitForFunction(() => window.__yage__ !== undefined);await page.evaluate(() => window.__yage__.ready);The global appears before startup work, so finding it says nothing about how
far boot got, and a failed boot rejects ready with the error that stopped it
rather than leaving the script to time out. inspector appears partway through
startup, when DebugPlugin installs it. Read it after ready. A check that can
run earlier should use window.__yage__?.inspector?.…, because the plain
window.__yage__.inspector.… throws while the inspector is still missing.
The host pushes the first scene after await engine.start(), so ready does
not cover that. Wait for a scene separately, and poll for it rather than
stepping — the clock is running at that point unless DebugPlugin was given
startFrozen, and step/stepUntil throw on a clock that is not frozen:
await page.waitForFunction( () => window.__yage__.inspector.getSceneStack().length > 0,);inspector.drive(fn) then runs a whole probe in one call. It freezes the
clock, hands the callback awaitable play verbs, restores the clock to the
state it was in and releases synthetic input afterwards:
const run = await window.__yage__.inspector.drive(async ({ input, until, step }) => { const i = window.__yage__.inspector; input.keyDown("KeyD"); const frames = await until(() => (i.getEntityPosition("player")?.x ?? 0) > 950, { maxFrames: 240, }); input.clearAll(); await step(10); return { frames };});
if (!run.ok) throw new Error(run.error);run.value.frames; // frames the walk tookrun.framesUsed; // frames the whole drive issuedA drive holds an exclusive clock lease until cleanup completes. Advance with
the context’s controls inside the callback; raw inspector.time mutations
reject while the drive is running.
The context carries step, until, input, events and capture. Every
frame-advancing call is awaitable and drains async work between frames —
including input.tap, input.hold and input.fireAction, whose
inspector.input counterparts are synchronous and do not. A throw inside the
callback, an assertion included, comes back as { ok: false, error } rather
than escaping, and the clock is restored either way.
Work out frame budgets from the game’s own numbers instead of guessing at
them: a 900px gap crossed at 300px/s takes 3 seconds, so 180 frames at 1/60.
Drive it with until(pred, { maxFrames: 240 }) and let the predicate decide
when the sequence moves on.
Picking between a drive on the game page, a lab scenario and the Inspector verbs on their own — plus frame budgets, rule-driven runs and the traps of driving a live page — is covered in Driving a Running Game.
The scenario lab builds the same verbs for a scenario’s drive, adding
scene, controls and expect, so a probe worth keeping moves into a
scenario file with little edited. Four things change on the way: a scenario’s
drive returns void, so assert with expect inside the callback rather
than returning a measurement; fireAction pulses the action once per frame
here and holds it down for the whole span there, which a hold-to-charge move
can tell apart; pressAction/releaseAction exist only on the lab’s context;
and a scenario’s own drive runs with no frame budget, since yage-lab test
and the panel’s Run button own that timeout.
Clicking the user interface
Section titled “Clicking the user interface”The drive context’s input verbs and inspector.input write InputManager
state, which drives action maps and the pointer position. A @yagejs/ui element receives clicks as renderer events on
its own container, so the input verbs never press a button. inspector.pointer
dispatches real pointer events at the renderer’s canvas, and the renderer
hit-tests each one as it does for a person’s click.
const i = window.__yage__.inspector;const button = i.snapshot().scenes[0]?.ui?.root.children[0];if (!button) throw new Error("The first scene has no UI element.");
const hit = i.pointer.click(button.id); // or click({ x, y })hit.path.some((node) => node.type === "UIButton"); // truei.time.step(1); // the action map has seen the pressclick, down, up and move dispatch events, and hitTest reports what
sits under a target without dispatching. A target is a UI snapshot node id or a
point in virtual space. The button’s own onClick has run by the time the call
returns, while engine input state reflects the press one frame later. The four
verbs that dispatch need RendererPlugin and one rendered frame, and each
throws with what to do when that is missing.
Driving a Running Game covers what a click reaches
and what blocks it.
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__ !== undefined); await page.evaluate(() => window.__yage__.ready); await page.waitForFunction( () => window.__yage__.inspector.getSceneStack().length > 0, );
const result = await page.evaluate(async () => { const i = window.__yage__.inspector; i.setSeed(42); // pin RNG for reproducible runs const run = await i.drive(async ({ input, step }) => { await input.hold("ArrowRight", 30); await input.fireAction("jump", 1); await step(45); // advance 45 fixed-timestep frames return i.snapshotJSON(); }); if (!run.ok) throw new Error(run.error); return run.value; });
// 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.
inspector.setSeed(seed) calls engine.sceneRandom.setSeed(seed). Game code
that pins a seed as part of play, such as a daily-challenge run, calls
engine.sceneRandom.setSeed(seed) itself. Either call throws when the seed is
NaN or infinite. engine.sceneRandom.clearSeed() ends the pinned run: scenes
already on the stack keep their current sequence, and a scene that enters later
starts from deterministicSeed when DebugPlugin set one, or from a fresh
seed.
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?” |
Advance frame-by-frame through the drive context’s step/until, or through
inspector.time.stepAsync(N) outside a drive. A large per-frame dtMs gives
Component.update, tweens and AI one large update; it does not simulate a
sequence of normal frames.
Component state
Section titled “Component state”An Inspector snapshot’s components[].state and getComponentData() reflect a
component’s own enumerable fields plus its public getters (get isReady(),
get health(), and similar) straight off the instance. These snapshots are
diagnostic readings of the live world, not save data.
import { Component } from "@yagejs/core";
class Cooldown extends Component { private _ready = false;
get isReady() { return this._ready; }}
// The reflected state shows up without save-specific code:window.__yage__.inspector.getComponentData("turret", "Cooldown");// { isReady: true }
// A name reads the first active match, so pass an entity id when several// entities share a name:const turrets = window.__yage__.inspector .getEntities() .filter((e) => e.name === "turret");for (const turret of turrets) { window.__yage__.inspector.getComponentData(turret.id, "Cooldown");}Fields and getters starting with _ are excluded, along with functions and
non-plain-object values. A Pixi/Rapier handle or other class instance would
otherwise leak a meaningless object identity. Vec2 is the exception and reads
as { x, y }. A field declared with this.sibling() or this.service() is
skipped too. Engine objects nested inside a field become the same compact refs
the event log stores: an entity reads as { id, name }, a component as
{ component }, a scene as { name }, and any other class instance as
{ _type }. A getter that throws is skipped rather than failing the whole
snapshot.
Reflection reads fields and getters, and nothing else. No path calls a
serialize() method on a component, so defining one adds nothing to what a
snapshot shows. To publish a value the rules above leave out — a derived
number, or a short summary of an excluded field — declare a public getter for
it.
A component keeps bulk data out of its reflected state with a static list. Lists merge down the class chain, so a subclass adds to its base class’s exclusions:
import { Component } from "@yagejs/core";
class NavGrid extends Component { static inspectExclude = ["cells"]; // one entry per grid cell cells: number[] = [];}Reading rendered geometry and visibility
Section titled “Reading rendered geometry and visibility”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.
For partial reveals, such as a typewriter SplitTextComponent that toggles
chars[i].visible, the component’s text alone does not describe the screen.
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:
import type { SplitTextRenderFacet } from "@yagejs/renderer";
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 ordersplit?.visibleText; // e.g. "Hel" while only the first 3 glyphs showbounds 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 exists only in Inspector
output.
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.
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.
Reading authored collider geometry
Section titled “Reading authored collider geometry”With PhysicsPlugin installed, you can read collider footprints even before
the component has a physics body. This is useful for tools that keep entities
inactive while editing:
import { InspectorKey, Transform } from "@yagejs/core";import { ColliderComponent } from "@yagejs/physics";
const collider = entity.get(ColliderComponent);const inspector = engine.context.resolve(InspectorKey);const facet = inspector.getComponentFacet(collider, "collider");const transform = entity.get(Transform);const worldOutlines = facet?.outlines.map((outline) => ({ ...outline, vertices: outline.vertices.map((point) => transform.localToWorld(point)),}));ColliderFacetSnapshot contains sensor and outlines. Each
ColliderOutlineSnapshot has vertices and closed. Vertices are local
pixels with each part’s offset and rotation already applied. Apply the
entity’s transform once to get world coordinates. Compound colliders return
one outline per part. Polygons describe their convex hull; polylines stay
open. Curves are approximated by line segments.
Each read returns fresh geometry from the current collider configuration.
It describes authored geometry, regardless of whether the collider is active
or attached to an entity. It does not report live collision contacts. The same
facet appears on component snapshots under facets.collider.
getComponentFacet(component, namespace) reads one registered contributor
without reflecting component fields or building a scene snapshot. It returns
undefined when the namespace is absent, the contributor has no facet for
that component, or inspection throws. The plugin owns registration and removes
its contributor on teardown. Both need an installed Inspector, which
InspectorPlugin provides without DebugPlugin.
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 — 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 rawperformance.now()reads bypass the frame clock. No callers in core YAGE today, but custom plugins might. - Frame count and delta are different.
stepAsync(60)runs 60 frames;stepAsync(1, { dtMs: 1000 })runs one large frame.
Screenshots
Section titled “Screenshots”inspector.capture renders the current stage, so a frozen clock gives back the
exact frame you stepped to. It needs RendererPlugin and throws without it.
await inspector.capture.dataURL(); // "data:image/png;base64,..."await inspector.capture.pngBase64(); // the base64 payload aloneawait inspector.capture.png(); // Uint8Array of PNG bytesInside a drive, ctx.capture(label?) takes the same image and records it in
the run’s captures as { label, dataUrl }, so one call returns both the
measurements and the frames behind them.
The HUD’s FPS and timing readouts differ between runs. Hide it before taking an image you intend to compare:
import type { DebugDiagnostics } from "@yagejs/debug";
inspector.getExtension<DebugDiagnostics>("debug")?.setHudVisible(false);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.
Scene ids identify instances for the Inspector’s lifetime. A rebuilt scene gets a new id even with the same name, so compare selected fields when testing repeated runs.
The per-entity query helpers (getEntity, getEntityPosition,
hasComponent, getComponentData) take a name or an entity id. A name
resolves the first active entity of the active scene. An id resolves one
entity anywhere on the scene stack, dormant and inactive included, destroyed
excluded. The id may be a number (getEntities()[].id) or a string
(snapshot().scenes[].entities[].id, an event log targetId). A string is
matched as a name first, so a name wins over an id with the same spelling.
All snapshot entity counts and per-scene lists exclude destroyed entities and
include dormant or inactive ones. Each world-entity snapshot reports name,
optional key, generation and pooled. Use generation to distinguish
different lives of a reused entity.
Engine snapshots include fixedStepIndex and interpolationAlpha. Scene
snapshots include elapsed and fixedElapsed from SceneTime, plus
physics.elapsed from their physics world (0 without physics). Elapsed
readings are seconds; compare timestamps from the same clock.
The event log records bus, entity and scene events for events.getLog() and
events.waitFor(). Each entry’s source is "bus", "entity" (an
entity.emit, with targetId set to the emitting entity’s id) or "scene"
(a scene.emit). 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(); // falseevents.waitFor(pattern, { withinFrames?, source? }) returns the earliest
retained match without consuming it. Repeated waits can return the same entry.
Clear the log before an action when checking for a new occurrence:
await inspector.drive(async ({ events, input }) => { events.clearLog(); await Promise.all([ events.waitFor("player:jumped", { withinFrames: 30 }), input.hold("Space", 30), ]);});The game must emit the event the probe names. withinFrames must be a
non-negative integer, even when history matches. Zero checks history only
and rejects immediately if unmatched. Positive deadlines count real frames
from registration in automatic or manual playback. An event during the
deadline frame satisfies the wait; otherwise it rejects when that frame
completes. A frozen clock needs the caller to advance frames; there is no
wall-clock timeout. Disabling logging or disposing the Inspector rejects
pending waits. Re-enabling logging permits new waits. RegExp patterns preserve
their flags and the caller’s lastIndex.
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.
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, Phase, System } from "@yagejs/core";import type { EngineContext } 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 { readonly phase = Phase.Update; private logger!: Logger;
constructor(private readonly wave: { complete: boolean }) { super(); }
onRegister(context: EngineContext) { this.logger = context.resolve(LoggerKey); }
update(dt: number) { if (this.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: 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.
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:
import type { LogEntry } from "@yagejs/core";
// 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:
// Your crash reporter's upload call.declare function reportCrash(report: { error: unknown; recentLogs: string }): void;
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”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):
/// <reference types="vite/client" />import { Engine, LogLevel } from "@yagejs/core";
// Your telemetry client.declare const telemetry: { send(event: string, data: object): void };
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.
Errors
Section titled “Errors”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:
import { Component } from "@yagejs/core";import type { Entity } from "@yagejs/core";import { ColliderComponent } from "@yagejs/physics";
class KeyRing extends Component { keys = 0;}declare const pad: Entity;declare function openDoor(): void;
// A door pad. The bug: only the player has a KeyRing, so `get` throws// when a crate slides onto the pad.pad.get(ColliderComponent).onTrigger((e) => { if (e.other.get(KeyRing).keys > 0) openDoor();});[yage] ERROR core Trigger handler threw on entity "DoorPad" in scene "level1" Error: Entity "Crate" does not have component KeyRing.Uncaught Error: Entity "Crate" does not have component KeyRing.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 trigger 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. The record lives on the engine’s
ErrorBoundary, so a shipped game can read it without an Inspector:
import { ErrorBoundaryKey } from "@yagejs/core";
const callbackErrors = engine.context .resolve(ErrorBoundaryKey) .getCallbackErrors();console.log(callbackErrors);// [{ kind: "Trigger handler", entity: "DoorPad", scene: "level1",// error: 'Entity "Crate" does not have component KeyRing.' }]With an Inspector installed, inspector.getErrors().callbackErrors returns the
same list.
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.
Coding agents: fetch https://yage.dev/llms.txt first and prefer the Markdown references it links over these HTML pages. This page's Markdown counterpart is /llms/packages/debug.md.