Skip to content

Scenario Lab

Reloading a whole game to look at one enemy is slow. The scenario lab (@yagejs-tools/lab) puts that enemy on screen by itself, in a real engine with your game’s plugins, and gives you sliders for the numbers you want to feel out.

Scenarios live in *.scenario.ts files, sitting next to the code they exercise. The lab finds them wherever they are, lists them in a browsable tree, and rebuilds the scene whenever you move a control. Add a drive function to a scenario and it becomes a test: yage-lab test plays every scenario in a headless browser and exits non-zero if one fails.

Terminal window
npm install -D @yagejs-tools/lab

The lab is a development tool — nothing it exports reaches your game bundle. It uses your existing @yagejs/core, @yagejs/renderer, @yagejs/debug and Vite installs as peer dependencies.

For yage-lab test you also need Playwright and a browser binary. Skip this if you only want the panel:

Terminal window
npm install -D @playwright/test && npx playwright install chromium
  1. Write the harness. init reads your package.json and prefills lab/harness.ts with a plugin for every @yagejs/* package you depend on:

    Terminal window
    npx yage-lab init
  2. Write a scenario beside the code it exercises, in a file ending .scenario.ts:

    src/entities/shapes.scenario.ts
    import { Transform, Vec2 } from "@yagejs/core";
    import { GraphicsComponent } from "@yagejs/renderer";
    import { control, defineScenario } from "@yagejs-tools/lab";
    export default defineScenario({
    controls: {
    count: control.int(4, { min: 1, max: 16 }),
    },
    setup(scene, c) {
    for (let i = 0; i < c.count; i++) {
    const shape = scene.spawn(`shape-${i}`);
    shape.add(new Transform({ position: new Vec2(80 + i * 60, 200) }));
    shape.add(
    new GraphicsComponent().draw((g) => {
    g.circle(0, 0, 24).fill({ color: 0x38bdf8 });
    }),
    );
    }
    },
    });
  3. Open the lab. It starts on port 5210 and opens a browser window:

    Terminal window
    npx yage-lab

You get a scenario list on the left, the canvas beside it, and this scenario’s controls under the canvas. Drag count and the scene rebuilds with the new value.

Every scenario in a project shares one harness, so they all run against the same plugins your game uses. A scenario that declared its own plugin set would drift from the game and prove nothing about it.

lab/harness.ts
import { Engine } from "@yagejs/core";
import { RendererPlugin } from "@yagejs/renderer";
import { PhysicsPlugin } from "@yagejs/physics";
import { InputPlugin } from "@yagejs/input";
import { defineHarness } from "@yagejs-tools/lab";
export const WIDTH = 800;
export const HEIGHT = 480;
export default defineHarness({
width: WIDTH,
height: HEIGHT,
engine: () => new Engine({ debug: true }),
plugins: ({ container }) => [
new RendererPlugin({ width: WIDTH, height: HEIGHT, container }),
new PhysicsPlugin({ gravity: { x: 0, y: 980 } }),
// Copy the game's own action map in, so scenarios drive the same actions.
new InputPlugin({ actions: { jump: ["Space"] } }),
],
});

width and height size the canvas, and default to 800×480. container is the element the renderer mounts into, already sized for you.

The file lives at lab/harness.ts under your Vite root. The path is a convention rather than a setting — there is no flag for it, and .mts, .js and .mjs work too.

A scenario either builds a situation from nothing or mounts a Scene your game already has. Declaring both, or neither, fails to compile.

setup runs against a blank scene, and again on every rebuild:

src/entities/ball.scenario.ts
export default defineScenario({
describe: "Bodies falling onto a floor. Raise bounce and watch again.",
controls: {
count: control.int(3, { min: 1, max: 12, label: "balls" }),
bounce: control.number(0.6, { min: 0, max: 0.95, step: 0.05 }),
},
setup(scene, c) {
for (let i = 0; i < c.count; i++) {
const ball = scene.spawn(`ball-${i}`, { key: `ball-${i}` });
// ...
}
},
// Only on this form — the scene form takes both from the Scene itself.
layers: [{ name: "background" }, { name: "entities" }],
preload: [textures.ball],
});

The list shows this under entitiesball, taken from where the file sits — see Where scenarios come from. describe prints under the title as a sentence about what to look at.

src/levels/cave.scenario.ts
export default defineScenario({
scene: (c) => new CaveScene({ torches: c.torches }),
controls: { torches: control.int(3, { min: 0, max: 8 }) },
});

This is the form that keeps a lab honest: the scenario shows the same scene the game ships, not a copy of it that can drift.

A file can export as many scenarios as you like, as named exports. They nest under the file in the list, and — the reason to put them together — they share whatever else the file declares:

src/entities/slime.scenario.ts
const health = { hp: control.int(30, { min: 1, max: 200 }) };
function arena(scene: Scene) {
// Floor, walls and lighting both scenarios need.
}
export const idle = defineScenario({
controls: health,
setup(scene, c) {
arena(scene);
spawnSlime(scene, { hp: c.hp });
},
});
export const chase = defineScenario({
name: "Chasing the player",
controls: health,
setup(scene, c) {
arena(scene);
spawnSlime(scene, { hp: c.hp, target: spawnPlayer(scene) });
},
});

That lists as entitiesslimeidle and Chasing the player. An export’s name is its label unless the scenario sets name, and anything the file exports that is not a scenario — health, arena — is ignored.

Controls are plain data. A scenario file that declares them pulls in no runtime engine code.

control.number(0.6, { min: 0, max: 1, step: 0.05, label: "bounce" })
control.int(3, { min: 1, max: 12 })
control.boolean(true, { label: "outline" })
control.select("green", ["green", "sky", "amber"])

number renders a slider and steps by 0.01 unless told otherwise; int steps by whole numbers; boolean is a checkbox; select is a dropdown. min and max default to a range that contains the starting value. select infers literal types, so setup sees "green" | "sky" | "amber" rather than string, with no as const at the call site.

Every control change rebuilds the scene from scratch, which means setup has to be re-runnable. In practice this pushes you toward entities and scenes that take their numbers as parameters — which is worth doing anyway.

Some values are fields on a component, set nowhere near a constructor. onMounted runs after the scene is on the stack, on every rebuild, and can reach them:

src/feel/pulse.scenario.ts
export default defineScenario({
scene: () => new PulseScene(),
controls: {
amplitude: control.number(0.4, { min: 0, max: 1, step: 0.05 }),
rate: control.number(3, { min: 0.5, max: 12, step: 0.5 }),
},
onMounted(scene, c) {
const pulse = scene.findByKey("disc")?.get(Pulse);
if (!pulse) return;
pulse.amplitude = c.amplitude;
pulse.rate = c.rate;
},
});

A drive function turns a scenario into something that plays itself and checks the result. The panel grows a Run button for it, and yage-lab test runs every one of them.

src/entities/ball.scenario.ts
export default defineScenario({
setup(scene, c) { /* ... */ },
async drive({ scene, step, expect }) {
const ball = scene.findByKey("ball-0");
if (!ball) throw new Error("the scenario spawned no ball-0");
const transform = ball.get(Transform);
const startY = transform.position.y;
// Two seconds of gravity, issued frame by frame.
await step(120);
expect(transform.position.y).toBeGreaterThan(startY);
},
});

A run rebuilds the scene first, takes the clock over for its duration, and restores the play state afterwards. Frames come from the run itself, so a scenario that reports 120 frames used 120 frames — wall-clock speed and the panel’s speed slider change nothing about the result.

step and until can set the simulated milliseconds for each frame in one call. The setting does not affect later calls:

await step(90, { dtMs: 1000 / 90 });
await until(() => probe.settled, {
maxFrames: 180,
dtMs: 1000 / 90,
});

What the context gives you:

MemberWhat it does
sceneThe scene being driven. findByKey("ball-0") reaches what the scenario spawned.
controlsThe control values the run started with.
step(frames?, { dtMs? })Advances frames, one at a time. dtMs defaults to the clock’s fixed step.
until(predicate, { maxFrames?, dtMs? })Steps until the predicate holds, resolving with the number of frames it took. Rejects after 600 frames by default.
expect(value)Jest-style assertions, from @vitest/expect.
inputSynthetic keyboard, mouse, pointer, gamepad and action input.
eventsThe engine’s event log, including waitFor.
capture(label?)Screenshots the canvas into the run’s result.

A run finishes as fast as the browser can issue frames, so a 300-frame drive shows you its end state and none of the motion. Select real time beside the panel’s Run button to watch it play: one driven engine frame per browser animation frame. Browsers pause animation frames in a background tab, so a run in real time waits until the tab is focused again. yage-lab test never paces a run.

ctx.input presses keys and buttons for you. Calls that advance frames are async; calls that only change state are not:

async drive({ input, step, scene, expect }) {
const probe = scene.findByKey("player")!.get(InputProbe);
// Hold a key across an exact number of frames.
await input.hold("Space", 3);
// Or drive the frames yourself to read a one-frame edge.
input.keyDown("Space");
await step(1);
expect(probe.jumpJustPressed).toBe(true);
await step(1);
expect(probe.jumpJustPressed).toBe(false);
input.keyUp("Space");
// Actions, when the harness declares InputPlugin.
await input.fireAction("jump", 2);
}

The full set: keyDown, keyUp, mouseMove, mouseDown, mouseUp, pointerMove, pointerDown, pointerUp, gamepadButton, gamepadAxis, pressAction, releaseAction and clearAll are synchronous; tap, hold and fireAction advance frames and are async.

Terminal window
npx yage-lab test

The command starts the same dev server, opens headless chromium, visits every scenario, and prints a line each:

PASS drop drive 120f 240ms
PASS shapes smoke 60f 41ms
FAIL enemies/slime drive 12f 88ms
expected 1 to be 2 // Object.is equality
2/3 passed, 1 failed

A scenario with a drive is driven. A scenario without one is mounted and stepped 60 frames, so even a project that has written no drives gets a smoke test that catches a scene which throws on the way up.

A scenario fails when its drive throws or an assertion fails, when the engine records a callback error — a setup that threw, a component update that threw — when the page outlives --timeout, or when the page reports an uncaught error. A scenario file the lab could not load fails the run too, and so does a --scenarios glob that matched nothing.

An assertion failure prints the full values it compared. Long strings and serialized objects are not shortened.

Useful flags:

FlagEffect
--scenarios <globs>Comma-separated patterns, so a run covers one scenario or one folder.
--timeout <ms>How long one scenario may take. Default 30000.
--screenshots <dir>Write a PNG per scenario there, plus one per capture(label).
--screenshot-view <content|camera>Choose drawn content or the camera’s virtual viewport. Default content. Requires --screenshots.

Put scenario files next to the code they exercise. The lab searches **/*.scenario.ts under your Vite root, skipping node_modules and dist, so colocation needs no setup. There is no lab folder to move things into.

The one thing worth configuring is where ids are measured from. Ids drop the directory prefix your patterns share, so pinning the search to src/ keeps them short:

package.json
{
"yage-lab": {
"scenarios": ["src/**/*.scenario.ts"]
}
}

With that, src/entities/slime.scenario.ts gets the id entities/slime. Left at the bare ** default there is no shared prefix, so the same file would be src/entities/slime — it works, but the src/ rides along on every link and filename.

An id is the file’s path plus, for a named export, its name. Every / in it is a level in the sidebar:

File and exportIdListed under
entities/slime.scenario.ts, defaultentities/slimeentities › slime
entities/slime.scenario.ts, idleentities/slime/idleentitiesslime › idle
ui/hud/bar.scenario.ts, defaultui/hud/baruihud › bar

A file holding several scenarios becomes the group they sit in. A file holding one keeps the file’s own place, rather than nesting a lone child under a group named after it.

Ids are also how a scenario is named from outside the page — in a link (?scenario=entities/slime/idle) and in --scenarios — so they are worth keeping stable.

When a scenario belongs under a heading its file’s location does not give it, title sets the path directly:

src/entities/slime.scenario.ts
export const king = defineScenario({
title: "Bosses / Act 1 / Slime King",
setup(scene) { /* ... */ },
});

That lists under BossesAct 1 › Slime King. The id stays entities/slime/king, so links and --scenarios keep pointing at the file.

Each column scrolls on its own and the page never does, so a project with a hundred scenarios still leaves the canvas exactly where it was.

  • Scenarios — the tree, on the left, nested by the ids above. The filter box above it matches a scenario’s title, the group names in it, and its file path. Clicking a group heading folds the group away; a filter opens whatever groups hold a match, and clearing it restores the folds. Which groups you left folded is remembered between reloads.
  • Controls — under the canvas, or in a column beside it once you press → right. Under the canvas four are visible at once and the rest scroll; beside it the whole column scrolls, so a scenario with a dozen controls fits next to the scene it tunes. copy JSON puts every current value on the clipboard as one JSON object — paste it into the code you are tuning, or into a prompt.
  • Clock — play, pause, step one frame or ten, and a speed slider from 0.05× to 4×. Speed changes how often a frame is issued, never the delta the frame reports, so a game’s own slow-motion effects still look like themselves. The real time checkbox beside Run plays a driven run at one engine frame per browser animation frame.
  • Errors — anything the engine’s error boundary recorded, plus a scene that failed to build. A game loop that stopped is called out separately: nothing short of a page reload brings it back.
  • The URL — the scenario, its control values, the speed and the play state all live in the query string. Editing a scenario file reloads the page, and the URL is what puts you back where you were. It also makes a scenario at a particular setting a link you can share.

Click the canvas and it takes keyboard focus. While it has focus the browser stops scrolling on space, the arrow keys, page up and down, and home and end — holding space to charge a jump does not move the page. The game receives those keys either way; only the browser’s own scrolling is dropped.

yage-lab build writes a static site — the panel, every scenario, and your game’s assets — that any static host can serve:

Terminal window
npx yage-lab build --out-dir dist-lab

Useful for showing a mechanic to someone who does not have the repo checked out.

The CLI merges the lab into your vite.config.ts for you, so most projects never need this. If you want a config that serves the lab itself, add the plugin:

vite.lab.config.ts
import { defineConfig } from "vite";
import { yageLab } from "@yagejs-tools/lab/vite";
export default defineConfig({
plugins: [
yageLab({
scenarios: ["src/**/*.scenario.ts"],
harness: "lab/harness.ts",
title: "My game — lab",
}),
],
});

The plugin answers the dev server’s root URL with the lab page, so a config carrying it serves the lab rather than the game.

mount publishes its API on globalThis.__yageLab__ before the first scenario is built, which makes the browser console a usable control surface:

const lab = window.__yageLab__;
await lab.ready; // the first scenario is up
lab.scenarios.map((s) => s.id); // every id on the page
await lab.show("enemies/slime");
await lab.setControl("count", 8);
await lab.clock.step(30);
const result = await lab.run({ pace: "frame" });
const shot = await lab.capture("camera");

lab.run() defaults to immediate playback and content captures. Pass captureView: "camera" when a drive’s capture(label) calls need the virtual viewport. DriveResult includes warnings beside captures.

Wait on ready rather than on the property existing: the API is published before the engine starts, so a page whose boot failed still has one. ready rejects with whatever stopped it.

  • One harness per project. A game that wants a physics-free harness for its UI scenarios has no way to ask for one yet.
  • A scenario runs with its declared control values under yage-lab test. There is no flag for driving it at a different setting.
  • --scenarios selects files, not scenarios. A pattern that names a file holding several scenarios runs all of them.
  • Laying bodies out from a control needs care. Twelve bodies that fit at one radius spawn intersecting at a larger one, and physics pushes them apart hard enough that the scenario stops showing what it was built to show. Cap the control at a value that still fits.