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?)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?)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 { 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.

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

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.

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

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:

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. 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, 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.getEntityByName("player");
// Inspect a single component's state
const spriteState = window.__yage__.inspector.getComponentData(
"player",
"SpriteComponent",
);
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
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 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, getEntityByName returns the first active one it encounters. Either use unique names, or iterate getEntities() and filter by id.
  • 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: true gets you the queries; inspector.time.* throws without DebugPlugin installed, and inspector.input.* throws without InputPlugin.
  • debug: true only. The Inspector is a development tool — production builds should construct Engine without debug: true to avoid exposing window.__yage__.