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.
Install
Section titled “Install”npm install -D @yagejs-tools/labThe 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:
npm install -D @playwright/test && npx playwright install chromiumGet started
Section titled “Get started”-
Write the harness.
initreads yourpackage.jsonand prefillslab/harness.tswith a plugin for every@yagejs/*package you depend on:Terminal window npx yage-lab init -
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 });}),);}},}); -
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.
The harness
Section titled “The harness”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.
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.
Writing scenarios
Section titled “Writing scenarios”A scenario either builds a situation from nothing or mounts a Scene
your game already has. Declaring both, or neither, fails to compile.
Building a situation
Section titled “Building a situation”setup runs against a blank scene, and again on every rebuild:
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 entities › ball, taken from where the file
sits — see Where scenarios come from. describe
prints under the title as a sentence about what to look at.
Mounting a scene you already have
Section titled “Mounting a scene you already have”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.
Several scenarios in one file
Section titled “Several scenarios in one file”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:
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 entities › slime › idle 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
Section titled “Controls”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.
Reaching values you cannot pass in
Section titled “Reaching values you cannot pass in”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:
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; },});Driving a scenario
Section titled “Driving a scenario”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.
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:
| Member | What it does |
|---|---|
scene | The scene being driven. findByKey("ball-0") reaches what the scenario spawned. |
controls | The 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. |
input | Synthetic keyboard, mouse, pointer, gamepad and action input. |
events | The 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.
Synthetic input
Section titled “Synthetic input”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.
Running scenarios as a test
Section titled “Running scenarios as a test”npx yage-lab testThe 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 failedA 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:
| Flag | Effect |
|---|---|
--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. |
Where scenarios come from
Section titled “Where scenarios come from”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:
{ "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.
The id is the tree
Section titled “The id is the tree”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 export | Id | Listed under |
|---|---|---|
entities/slime.scenario.ts, default | entities/slime | entities › slime |
entities/slime.scenario.ts, idle | entities/slime/idle | entities › slime › idle |
ui/hud/bar.scenario.ts, default | ui/hud/bar | ui › hud › 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.
Putting a scenario somewhere else
Section titled “Putting a scenario somewhere else”When a scenario belongs under a heading its file’s location does not give it,
title sets the path directly:
export const king = defineScenario({ title: "Bosses / Act 1 / Slime King", setup(scene) { /* ... */ },});That lists under Bosses › Act 1 › Slime King. The id stays
entities/slime/king, so links and --scenarios keep pointing at the file.
The panel
Section titled “The panel”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.
Keys go to the game
Section titled “Keys go to the game”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.
Sharing a lab
Section titled “Sharing a lab”yage-lab build writes a static site — the panel, every scenario, and your
game’s assets — that any static host can serve:
npx yage-lab build --out-dir dist-labUseful for showing a mechanic to someone who does not have the repo checked out.
Running it from your own Vite config
Section titled “Running it from your own Vite config”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:
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.
Driving the lab from code
Section titled “Driving the lab from code”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 uplab.scenarios.map((s) => s.id); // every id on the pageawait 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.
Limits
Section titled “Limits”- 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. --scenariosselects 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.