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.
Test Utilities
Section titled “Test Utilities”import { createTestEngine, createMockScene, createMockEntity, advanceFrames,} from "@yagejs/core";| Utility | Returns | Purpose |
|---|---|---|
createTestEngine(config?) | Promise<Engine> | Headless engine, already started. No renderer, audio, or input plugins |
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?) | void | Tick 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()).
Unit Testing a Component
Section titled “Unit Testing a Component”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 { createMockEntity } from "@yagejs/core";import { HealthComponent } from "../HealthComponent";
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.
Unit Testing a System
Section titled “Unit Testing a System”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 { createMockScene, Phase, System, SceneManagerKey, type SceneManager,} from "@yagejs/core";import { VelocityComponent } from "../VelocityComponent";
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():
const engine = new Engine();engine.use({ name: "gravity", version: "1.0.0", registerSystems: (scheduler) => scheduler.add(new GravitySystem({ gravity: 980 })),});await engine.start();Integration Testing with a Full Engine
Section titled “Integration Testing with a Full Engine”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 { createTestEngine, advanceFrames, Transform } from "@yagejs/core";import type { Engine } from "@yagejs/core";import { GameScene } from "../GameScene";import { Player } from "../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. A test that needs a plugin builds the engine by hand instead:
new Engine(), engine.use(...), then await engine.start().
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.
Testing Processes and Tweens
Section titled “Testing Processes and Tweens”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,} from "@yagejs/core";import type { Engine } from "@yagejs/core";import { GameScene } from "../GameScene";
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);});Testing Tips
Section titled “Testing Tips”Always Destroy the Engine
Section titled “Always Destroy the Engine”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.
Entity ID Reset
Section titled “Entity ID Reset”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.
Error Boundary Testing
Section titled “Error Boundary Testing”Test that components handle error conditions gracefully:
it("does not crash when destroying an already-destroyed entity", () => { const { entity } = createMockEntity(); entity.destroy();
expect(() => entity.destroy()).not.toThrow();});Keep Tests Deterministic
Section titled “Keep Tests Deterministic”Avoid Math.random() in test code. If the system under test uses randomness,
seed it or mock the random source so results are reproducible.
Runtime Inspection
Section titled “Runtime Inspection”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. Construct the engine
with debug: true and it attaches itself to window.__yage__ during
engine.start(). It sees the same ECS state your tests see, but against a
live running game.
const engine = new Engine({ debug: true });engine.use(new RendererPlugin({ width: 800, height: 600 }));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, errorswindow.__yage__.inspector.snapshot();
// All entities in the active scene, as plain snapshot objectsconst all = window.__yage__.inspector.getEntities();
// Client-side filter: every enemy in the sceneconst enemies = all.filter((e) => e.tags.includes("enemy"));
// Look up a specific entity by nameconst player = window.__yage__.inspector.getEntityByName("player");
// Inspect a single component's stateconst spriteState = window.__yage__.inspector.getComponentData( "player", "SpriteComponent",);API Reference
Section titled “API Reference”| Method | Purpose |
|---|---|
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 |
getEntities() | Every entity in the active scene — id, name, tags, components, position |
getEntityByName(name) | First active entity with that name. Destroyed and deactivated entities are skipped |
getEntityPosition(name) | Shortcut: Transform position for a named entity |
hasComponent(name, componentName) | Boolean: does the named entity have this component? Both arguments are strings |
getComponentData(name, componentName) | The component’s serialize() result, or its reflected public state when it defines none. Typed unknown, and null for a component with nothing serializable |
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 |
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
persisted component 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.
The Agent Feedback Loop
Section titled “The Agent Feedback Loop”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.
Known Limitations
Section titled “Known Limitations”- Names are not unique keys. If two entities share a name,
getEntityByNamereturns the first active one it encounters. Either use unique names, or iterategetEntities()and filter byid. - 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.
debug: truegets you the queries;inspector.time.*throws withoutDebugPlugininstalled, andinspector.input.*throws withoutInputPlugin. debug: trueonly. The Inspector is a development tool — production builds should constructEnginewithoutdebug: trueto avoid exposingwindow.__yage__.