Skip to content

Testing

YAGE ships test utilities in @yagejs/core that let you run game logic in isolation without a browser, a canvas, or any rendering infrastructure.

import {
createTestEngine,
createMockScene,
createMockEntity,
advanceFrames,
} from "@yagejs/core";
UtilityReturnsPurpose
createTestEngine(config?, plugins?)Promise<Engine>Headless engine, already started, with the plugins you pass installed. No renderer, audio, or input plugins unless you pass them
createMockScene(name?){ scene, context }Scene with a working ECS world, no engine
createMockEntity(name?){ entity, scene, context }Entity spawned into a mock scene
advanceFrames(engine, n, dtMs?)voidTick the game loop n times. dtMs defaults to 1000 / 60

createTestEngine is async — it starts the engine for you, so await it and don’t call engine.start() yourself. The mock helpers return an object: take the piece you need with destructuring (const { entity } = createMockEntity()).

Test a single component by attaching it to a mock entity and calling its methods directly. Lifecycle methods such as update(dt) are called the same way:

import { describe, it, expect } from "vitest";
import { Component, createMockEntity } from "@yagejs/core";
class HealthComponent extends Component {
current: number;
constructor({ max }: { max: number }) {
super();
this.current = max;
}
get isDead(): boolean {
return this.current === 0;
}
takeDamage(amount: number): void {
this.current = Math.max(0, this.current - amount);
}
}
describe("HealthComponent", () => {
it("clamps health to zero", () => {
const { entity } = createMockEntity();
const health = entity.add(new HealthComponent({ max: 100 }));
health.takeDamage(150);
expect(health.current).toBe(0);
expect(health.isDead).toBe(true);
});
});

entity.add() returns the component you passed in, so you can attach it and keep the reference in one line.

A system has one tick method, update(dt). The phase field decides when the engine calls it, and a test calls it directly. Systems read the active scene from the SceneManager, which a mock scene has no engine to provide. Register a stand-in that reports your scene as the active one:

import { describe, it, expect } from "vitest";
import {
Component,
createMockScene,
Phase,
System,
SceneManagerKey,
type SceneManager,
} from "@yagejs/core";
class VelocityComponent extends Component {
y = 0;
}
class GravitySystem extends System {
readonly phase = Phase.FixedUpdate;
private readonly gravity: number;
constructor(config: { gravity: number }) {
super();
this.gravity = config.gravity;
}
update(dt: number): void {
const scene = this.use(SceneManagerKey).active;
if (!scene) return;
for (const entity of scene.findEntities()) {
if (!entity.has(VelocityComponent)) continue;
entity.get(VelocityComponent).y += this.gravity * dt;
}
}
}
describe("GravitySystem", () => {
it("accelerates entities with a velocity", () => {
const { scene, context } = createMockScene();
context.register(SceneManagerKey, {
active: scene,
} as unknown as SceneManager);
const system = new GravitySystem({ gravity: 980 });
system._setContext(context);
system.onRegister?.(context);
const velocity = scene.spawn("ball").add(new VelocityComponent());
system.update(0.016); // one fixed step at 60fps
expect(velocity.y).toBeCloseTo(980 * 0.016);
});
});

The engine calls _setContext and then onRegister when it registers a system. A test that constructs the system itself has to do both: without the context, this.use(...) has no container to resolve services from, and without onRegister a system that defines the hook never runs its setup.

In a game, a system reaches the engine through a plugin’s registerSystems, and registration has to happen before engine.start():

import { Engine } from "@yagejs/core";
const engine = new Engine();
engine.use({
name: "gravity",
version: "1.0.0",
registerSystems: (scheduler) =>
scheduler.add(new GravitySystem({ gravity: 980 })),
});
await engine.start();

For tests that need multiple systems working together, run a headless engine. Construct your scene yourself and push it onto the engine’s scene stack:

import { describe, it, expect, beforeEach, afterEach } from "vitest";
import {
Component,
Entity,
Scene,
createTestEngine,
advanceFrames,
Transform,
} from "@yagejs/core";
import type { Engine } from "@yagejs/core";
class MoveRight extends Component {
private readonly transform = this.sibling(Transform);
update(dt: number): void {
this.transform.translate(100 * dt, 0);
}
}
class Player extends Entity {
setup(): void {
this.add(new Transform());
this.add(new MoveRight());
}
}
class GameScene extends Scene {
readonly name = "game";
onEnter(): void {
this.spawn(Player, { key: "player" });
}
}
describe("Player movement integration", () => {
let engine: Engine;
beforeEach(async () => {
engine = await createTestEngine();
});
afterEach(() => {
engine.destroy();
});
it("moves the player after 10 frames", async () => {
const scene = new GameScene();
await engine.scenes.push(scene);
const player = scene.findByKey<Player>("player")!;
const startX = player.get(Transform).position.x;
advanceFrames(engine, 10);
expect(player.get(Transform).position.x).not.toBe(startX);
});
});

scenes.push() is async: it loads the scene’s preload list, then runs onEnter, then plays a transition if one is configured. Await it before asserting on what the scene spawned.

findByKey needs the entity to have been spawned with a key. For an entity class whose setup() takes no parameters, that’s this.spawn(Player, { key: "player" }). When setup(params) does take parameters, pass both: this.spawn(Player, params, { key: "player" }). Without a key, look the entity up by name with scene.findEntity("player"), or by tag with scene.findEntitiesByTag("player"). scene.spawn() also returns the entity directly, so a test that spawns its own entities can just keep the reference.

createTestEngine starts the engine, and plugins have to be registered before start, so pass them as the second argument: createTestEngine(undefined, [new InspectorPlugin()]) gives a test an Inspector, read with engine.context.resolve(InspectorKey).

advanceFrames is synchronous — it calls the engine’s game loop the specified number of times with a fixed ~16.7ms delta, which the loop converts to ~0.0167s for update(dt). Pass a third argument to change the per-frame delta: advanceFrames(engine, 1, 100) ticks one 100ms frame. No setTimeout or requestAnimationFrame involved.

The engine ticks an entity’s processes every frame, so a test engine plus advanceFrames runs them exactly as a game does. Set the per-frame delta with the third argument to control how much time elapses:

import { describe, it, expect, beforeEach, afterEach } from "vitest";
import {
createTestEngine,
advanceFrames,
Process,
ProcessComponent,
Scene,
} from "@yagejs/core";
import type { Engine } from "@yagejs/core";
class GameScene extends Scene {
readonly name = "game";
}
describe("Process timing", () => {
let engine: Engine;
beforeEach(async () => {
engine = await createTestEngine();
});
afterEach(() => {
engine.destroy();
});
it("fires the callback after the delay", async () => {
const scene = new GameScene();
await engine.scenes.push(scene);
const processes = scene.spawn("timer").add(new ProcessComponent());
let fired = false;
processes.run(
Process.delay(0.1, () => {
fired = true;
}),
);
advanceFrames(engine, 1, 50); // 0.05s elapsed — not enough
expect(fired).toBe(false);
advanceFrames(engine, 1, 60); // 0.11s total — past the 0.1s delay
expect(fired).toBe(true);
});
});

For the interpolation math alone, no engine is needed. Process.delay and the Tween factories return a Process, and _update(dt) advances it by dt seconds — the same call the engine makes each frame:

import { Tween, easeLinear } from "@yagejs/core";
it("tweens alpha to zero", () => {
const target = { alpha: 1 };
const tween = Tween.to(target, "alpha", 0, 0.2, easeLinear);
tween._update(0.1); // halfway
expect(target.alpha).toBeCloseTo(0.5);
tween._update(0.1); // complete
expect(target.alpha).toBeCloseTo(0);
expect(tween.completed).toBe(true);
});

Call engine.destroy() in afterEach. It stops the game loop, tears down every scene and system, destroys the plugins, and clears the event bus. Leaking engines across tests causes flaky failures.

createTestEngine, createMockScene, and createMockEntity all reset the entity ID counter when they run, so IDs are predictable within each test. Two tests that build the same entities in the same order see the same IDs.

Test that components handle error conditions gracefully:

import { createMockEntity } from "@yagejs/core";
it("does not crash when destroying an already-destroyed entity", () => {
const { entity } = createMockEntity();
entity.destroy();
expect(() => entity.destroy()).not.toThrow();
});

Avoid Math.random() in test code. If the system under test uses randomness, seed it or mock the random source so results are reproducible.

The utilities above give you deterministic, headless verification — you tick the engine by exact frame counts and assert against the resulting state. Other checks need a live game: inspecting a running dev build from the browser console, confirming that a whole scene reaches the renderer with the entities and layers you expect, or letting an AI coding agent verify a scene it just generated. The Inspector covers those.

The Inspector is a runtime introspection and control API. DebugPlugin installs it, and InspectorPlugin from @yagejs/core installs it without the debug overlay. Construct the engine with debug: true and it is published as window.__yage__.inspector during engine.start(). It sees the same ECS state your tests see, but against a live running game.

import { Engine, InspectorPlugin } from "@yagejs/core";
import { RendererPlugin } from "@yagejs/renderer";
const engine = new Engine({ debug: true });
engine.use(new RendererPlugin({ width: 800, height: 600 }));
engine.use(new InspectorPlugin()); // or DebugPlugin, which installs one too
await engine.start();

Once the engine is running, query it from the browser console (or from any code that has access to window):

// Full engine state — frame count, scene stack, entity counts, errors
window.__yage__.inspector.snapshot();
// All entities in the active scene, as plain snapshot objects
const all = window.__yage__.inspector.getEntities();
// Client-side filter: every enemy in the scene
const enemies = all.filter((e) => e.tags.includes("enemy"));
// Look up a specific entity by name
const player = window.__yage__.inspector.getEntity("player");
// Inspect a single component's state
const spriteState = window.__yage__.inspector.getComponentData(
"player",
"SpriteComponent",
);
// Or address one specific entity by its id, when several share a name
const lantern = all.filter((e) => e.name === "lantern")[2];
if (lantern) {
const lightState = window.__yage__.inspector.getComponentData(
lantern.id,
"LightSource",
);
}
MethodPurpose
snapshot()Full engine snapshot — frame, scene stack, entity/system counts, errors, plus per-scene entity detail under scenes, camera, and input state
getSceneStack()All scenes with their pause state and entity counts
getEntityCount()Total entities across the stack, excluding destroyed entities and including inactive ones
getEntities()Every entity in the active scene — id, name, tags, components, position
getEntity(nameOrId)One entity, addressed by name or by entity id
getEntityPosition(nameOrId)Shortcut: Transform position for that entity
hasComponent(nameOrId, componentName)Boolean: does that entity have this component?
getComponentData(nameOrId, componentName)Reflected public fields and getters from the live component. Typed unknown, and null when nothing is inspectable
getSystems()Every registered system, with phase/priority/enabled flags
getErrors()Recorded failures from the error boundary (callbackErrors) — system, component, and callback throws, most recent last. The same list is engine.context.resolve(ErrorBoundaryKey).getCallbackErrors() without an Inspector

The four per-entity helpers take either address form. A name resolves the first active entity with that name in the active scene, so a deactivated entity reads as absent under its name. An entity id resolves one entity anywhere on the scene stack, dormant and inactive ones included and destroyed ones excluded — the same population getEntityCount() counts. The id is a number from getEntities()[].id or a string from snapshot().scenes[].entities[].id; both spellings work. A string is matched as a name first, so a name wins over an id with the same spelling.

getEntities() returns plain EntitySnapshot objects carrying class-name strings in components (not class references), so filtering by component type is a client-side .filter() on e.components.includes("Health"). The same applies to tags — filter client-side rather than expecting a built-in getEntitiesByTag.

To assert what is actually rendered — not just the entity origin or the component’s reflected state — read the render facet off a world-scene snapshot (published by RendererPlugin under facets.render). Each graphical component carries a derived facet with world-space bounds and component-local visible, computed on demand from the live display object. SplitTextComponent also reports per-glyph visibility and the visible substring, so a typewriter reveal is verifiable from the public API instead of reaching into Pixi:

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"),
);
const split = label?.components.find(
(c) => c.type === "SplitTextComponent",
)?.facets?.render as SplitTextRenderFacet | undefined;
expect(split?.visibleText).toBe("Hel");
expect(split?.glyphs.filter((g) => g.visible)).toHaveLength(3);

See the debug guide for the full facet shape.

inspector.time.getFrame() reads the real game-loop frame count during both automatic playback and manual stepping. Snapshot, event-log and logger frame numbers share that count. Compare with a baseline taken before the action:

await inspector.drive(async ({ step }) => {
const before = inspector.time.getFrame();
await step(3);
expect(inspector.time.getFrame() - before).toBe(3);
});

A drive owns the clock until it completes. Use its step, until and input methods; raw inspector.time mutations reject during the drive. For custom tools, inspector.time.acquire() returns an exclusive lease with the same controls and a release() method. Acquisition and release do not change the clock’s frozen state. Raw stepAsync and stepUntil reserve control for their whole operation, so await each before issuing another.

events.waitFor returns the earliest retained match without consuming it. Clear the log before an action when testing a new occurrence, and advance frames while the wait is pending:

await inspector.drive(async ({ events, input }) => {
events.clearLog();
const [hit] = await Promise.all([
events.waitFor("player:jumped", { withinFrames: 30 }),
input.hold("Space", 30),
]);
expect(hit.type).toBe("player:jumped");
});

The game must emit the event the test names. withinFrames counts real frames from registration. An event during the deadline frame satisfies the wait; otherwise the wait rejects when that frame completes. A frozen clock needs the test to issue those frames. Zero checks history only. Disabling logging or disposing the Inspector rejects pending waits.

World-entity snapshots include name, optional authored key, generation and pooled. Compare generation to distinguish lives of a reused entity. All entity counts exclude destroyed entities and include dormant and inactive ones, as do the per-scene entity lists.

Scene ids identify instances for the Inspector’s lifetime. Rebuilding a scene creates a new id, so compare selected state when testing repeated runs in one engine. Entity names can repeat; name-based queries select the first active match.

snapshot().fixedStepIndex counts fixed steps started, and interpolationAlpha reports the game loop’s fraction toward its next fixed step. Each scene snapshot has elapsed and fixedElapsed from SceneTime, plus physics.elapsed from its physics world (0 without physics). These elapsed values are seconds. Use the same clock for both ends of a duration assertion because frame time, fixed time and physics time can differ.

The Inspector is especially useful when an AI coding agent is authoring gameplay against YAGE. The two approaches are complementary, not redundant:

  • Headless tests (createTestEngine + advanceFrames) give deterministic correctness guarantees — ideal for unit-testing components or verifying that ten frames of simulation produce a specific outcome.
  • The Inspector gives a live readout of a running game — ideal for checking that a brand-new scene really created its entities, cameras, and layers the way the agent intended, without having to read the canvas.

A typical workflow is: generate the code → run it in the dev server with debug: true → use the Inspector to confirm the expected entities exist and have the expected components → only then write a targeted test against the specific behaviour you care about. Checking the live scene first catches the “I misunderstood what spawn() needs” class of bugs in seconds instead of minutes.

  • Names are not unique keys. If two entities share a name, the name form returns the first active one it encounters. Pass the entity id instead to reach a specific one.
  • Mutation is scoped to debug tooling. The Inspector can freeze time, inject input, and step frames, but it is not a general-purpose world editor. Use normal engine APIs for structural scene changes.
  • Time and input control need their plugins. InspectorPlugin gets you the queries; inspector.time.* throws without DebugPlugin installed, and inspector.input.* throws without InputPlugin.
  • A development tool. Production builds should leave out DebugPlugin and InspectorPlugin, so the game runs without an Inspector, and construct Engine without debug: true to avoid exposing window.__yage__.

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/patterns.md.