Skip to content

Driving a Running Game

Some questions about a game are only answerable by playing it. Can the player still clear that gap after the speed change? Does the door open when the lever is held for a second? YAGE answers those from a script: the Inspector freezes the clock, feeds synthetic input, advances exact frames, and hands the state back.

This guide is about picking the right mechanism and writing a run that still works after the next balance change. The mechanisms themselves are documented in the Debug Tools guide (the Inspector, the frozen clock, inspector.drive) and in Scenario Lab (scenarios, controls, yage-lab test).

SituationUse
One question about the game as it is runningone inspector.drive() call on the game page
A bug whose situation you can build from nothinga scenario file, rerun with yage-lab test --scenarios <file>
Rerunning the same probe while iterating on a fixmove it out of the console into a scenario file
State only the real game reaches: progression, saves, the room graphthe Inspector on the game page
Tuning a number by feel, with someone watchinga lab scenario with controls
Behavior someone accepted and wants kept truea scenario committed next to the code it exercises

A scenario written to reproduce a bug mid-session is throwaway. Mixing it in with the scenarios the project keeps makes both the lab’s sidebar and a yage-lab test run mean less. Put it where the project’s yage-lab.scenarios globs in package.json do not reach — a scratch/ directory is the shape to copy — and rerun it while you iterate:

Terminal window
npx yage-lab test --scenarios scratch/gap.scenario.ts

Delete it at the end of the session, or promote it deliberately by moving the file next to the code it exercises, where the project’s normal glob finds it. Keep scratch files out of git.

inspector.drive(fn, opts?) freezes the clock, hands your callback awaitable play verbs, and reports the run as one object. It restores the clock to the state it found after releasing synthetic input. If an input-release callback throws during cleanup, the drive rejects and cleanup stops: the clock stays frozen and its lease remains held. Fix the callback and restart the game before running another drive.

const run = await window.__yage__.inspector.drive(async (ctx) => {
const i = window.__yage__.inspector;
ctx.input.keyDown("KeyD");
const frames = await ctx.until(() => (i.getEntityPosition("player")?.x ?? 0) > 950, {
maxFrames: 240,
});
ctx.input.clearAll();
return { frames };
}, { maxFrames: 900 });
run.framesUsed; // frames the whole run issued
run.state; // { keys, actions, scenes } at the moment the run ended
// `ok` discriminates the result. Narrow on it before reading further: `value`
// is on the success branch, `error` and `timedOut` on the failure one.
if (!run.ok) throw new Error(run.error);
run.value.frames;

state is read before the run releases what the callback left held, so it reports the keys that were genuinely down at the end rather than the empty set cleanup leaves behind.

opts.maxFrames bounds the run. The budget is checked before each frame-advancing call, and once it is spent the run ends with ok: false and timedOut: true. It works by throwing inside the context, so a callback that catches every exception and keeps going defeats it. Omit maxFrames and a default of 10,000 frames applies; pass Infinity for no cap.

A scripted run — hold Right for 30 frames, jump, step 45 — only works while the tuning constants hold. A run given rules keeps working after a balance change, and reaches situations too long to script. The shape is a while loop with an if chain for priority, continue to restart it, and input.whileHolding for a key that stays down across everything inside:

src/levels/gauntlet.scenario.ts
import { Component, Scene } from "@yagejs/core";
import { RigidBodyComponent } from "@yagejs/physics";
import { defineScenario } from "@yagejs-tools/lab";
// This game's own scene, ground sensor and level queries.
declare class GauntletScene extends Scene { readonly name = "gauntlet"; }
declare class GroundProbe extends Component { readonly grounded: boolean; }
declare function atExit(body: RigidBodyComponent): boolean;
declare function gapAhead(body: RigidBodyComponent, distance: number): boolean;
declare function overTarget(body: RigidBodyComponent): boolean;
declare function enemyAhead(body: RigidBodyComponent, distance: number): boolean;
export default defineScenario({
scene: () => new GauntletScene(),
async drive(ctx) {
const player = ctx.scene.findByKey("player");
if (!player) throw new Error("the scene has no player");
const body = player.get(RigidBodyComponent);
const ground = player.get(GroundProbe); // this game's own component
await ctx.input.whileHolding(["KeyD"], async () => {
while (ctx.framesUsed < 900 && !atExit(body)) {
if (ground.grounded && gapAhead(body, 48)) {
await ctx.input.whileHolding(["Space"], () => ctx.step(6));
continue;
}
if (ground.grounded && overTarget(body)) {
await diveAttack(ctx, body, ground);
continue;
}
if (enemyAhead(body, 120)) {
await ctx.input.tap("KeyJ", 3);
continue;
}
await ctx.step(1);
}
});
},
});

Four things make that loop work.

A maneuver is an ordinary async function. It awaits its own frames, so it needs no state machine and no phase counter — the sequence reads top to bottom:

import type { DriveContext } from "@yagejs-tools/lab";
async function diveAttack(
ctx: DriveContext,
body: RigidBodyComponent,
ground: GroundProbe,
) {
await ctx.input.whileHolding(["Space"], async () => {
await ctx.step(4);
await ctx.until(() => body.velocityY > -20); // rising to the apex
await ctx.input.whileHolding(["KeyS", "KeyJ"], () =>
ctx.until(() => ground.grounded, { maxFrames: 60 }),
);
});
}

Nest whileHolding rather than tracking which keys are down. whileHolding(codes, fn) presses codes, runs fn, and then restores the hold state it found on entry, including when fn throws. A code already down when it starts is left alone at both ends, so an inner call that repeats one of the outer call’s codes does not drop it on the way out, and the outer hold is still down when the maneuver returns. Never call input.clearAll() inside a maneuver — it releases the caller’s keys along with the maneuver’s own.

The call resolves with whatever fn returned, so a hold can wrap a verb that reports something: whileHolding(codes, () => ctx.until(pred)) gives back the frames it took.

Bound the loop with ctx.framesUsed. One iteration that runs a maneuver can spend 60 frames, so for (let f = 0; f < 900; f++) bounds iterations rather than game time. framesUsed counts every frame the run issued, nested maneuvers included. Read it off the context rather than destructuring it: it is a getter, and a destructured copy stays at the value it had when the run started.

The sensors are yours. gapAhead, enemyAhead, overTarget and atExit are raycasts and queries written next to the game they read, and GroundProbe is that game’s own component — one that raycasts down each frame and exposes a grounded field. The engine supplies the loop verbs and the frame budget. What counts as a gap is specific to the game, and components that already answer that need no extra probe code.

The example is a lab scenario, where ctx.scene reaches the entities the scenario spawned. The same loop shape runs in an inspector.drive on the game page, with two differences. There is no scene, so state comes from inspector.getEntityPosition, inspector.getComponentData, or an extension the game registers with inspector.addExtension. And the context is a different type: annotate a helper written for the game page with InspectorDriveContext from @yagejs/core, since the lab’s DriveContext also requires scene, controls and expect. A helper meant for both takes only what it uses: { step, until, input }.

A run stopped by its budget unwinds the callback, so its return value is lost. Assign anything you want to see either way to a variable in the enclosing scope, which keeps its last value:

declare function atExit(): boolean; // this game's own sensor
const i = window.__yage__.inspector;
let lastX = 0;
const run = await i.drive(async (ctx) => {
while (!atExit()) {
lastX = i.getEntityPosition("player")?.x ?? lastX;
await ctx.step(1);
}
}, { maxFrames: 600 });
// run.timedOut === true, run.value === undefined, lastX === how far it got

Frame budgets come from the game’s own numbers

Section titled “Frame budgets come from the game’s own numbers”

Derive the cap from tuning constants rather than guessing at a wait. A 900px gap crossed at 300px/s takes 3 seconds, which is 180 frames at 1/60 — so wait on the predicate and cap it a little above the derived number:

import type { RigidBodyComponent } from "@yagejs/physics";
import type { DriveContext } from "@yagejs-tools/lab";
async function crossGap(ctx: DriveContext, body: RigidBodyComponent) {
await ctx.until(() => body.positionX > 900, { maxFrames: 240 });
}

The predicate decides when the run moves on; the cap only decides when to give up. A fixed frame count instead fails again every time someone changes the run speed. until resolves with the frames it took, which is usually the measurement worth reporting.

To press a button in the game’s user interface, such as a menu or a HUD, use inspector.pointer. Its verbs dispatch real pointer events at the canvas, and the renderer runs its normal hit test on each one, as it does for a person’s click. So the button’s own onClick runs. Whatever would stop a person’s click also stops a click from inspector.pointer: a panel drawn over the button receives the click, and a disabled button receives none.

const i = window.__yage__.inspector;
// "build-menu" has one surface, and its buttons sit directly under the root.
const { ui } = i.snapshotScene("build-menu");
const button = ui?.root.children.find((node) => node.type === "UIButton");
if (!button) throw new Error("build-menu has no button");
const hit = i.pointer.click(button.id); // the button's onClick has run
hit.path[0]?.type; // "UIText": the label, drawn on top of the button
hit.path.some((node) => node.type === "UIButton"); // true

click, down, up and move dispatch events, and hitTest reports what is under the target without dispatching anything. click, down and up take { button } and use the left button by default. A drive context has the same verbs as ctx.pointer, both in inspector.drive and in a lab scenario’s drive. The calls are synchronous and do not advance a frame, so none of them needs await.

Aim at a node id rather than at pixels. A target is either the id of a node in the Inspector’s user-interface snapshot, or a point in virtual-space pixels. An id resolves to the centre of the node’s bounds, its box on screen, so a layout change that moves the button moves the click with it. The box covers the element as it is drawn, so a scaled or rotated button is clicked in its middle, and an element scaled to 0 cannot be clicked by id: the call throws, naming the node. The node’s layout field is measured from its parent node and gives no position on the canvas. The nodes are the tree under ui.root in a scene snapshot, which inspector.snapshot().scenes lists and inspector.snapshotScene(name) returns. In a scene with several surfaces, ui.root is a wrapper node whose children are the surfaces’ root nodes, and the wrapper itself cannot be clicked. A lab scenario reads the snapshot from window.__yage__.inspector too, because its drive context has no snapshot method.

Look through the whole path. Every verb returns what the hit test found. path lists the node under the point and then each node that contains it, innermost first, as { id, type }. path is empty when the point is over no node. A button’s text label is a node of its own, drawn on top of the button, so path[0] is the label and the button comes after it. Check for the button anywhere in path, as the example does. The result also carries point, the virtual-space point the verb aimed at, and consumed. consumed is true when the point is over a consume surface, where a press fires no gameplay action. UI vs gameplay clicks describes consume surfaces.

Every verb needs RendererPlugin, and the four that dispatch also need a rendered frame. The renderer cannot deliver a pointer event before it has drawn a frame. hitTest needs no frame. Before the first click in a scenario, or on a page that has not played a frame yet, step one frame. Each verb throws when a requirement is missing, with a message that says what to do.

The handler runs before the call returns. The renderer delivers the event synchronously, so a button’s onClick has already run when click returns. The engine applies the same press to its input state at the next frame, so step one frame before checking an action.

inspector.input has pointer verbs of its own, for gameplay input. They write engine input state directly, need InputPlugin, and work without a renderer. They reach no user-interface element. inspector.pointer drives one mouse pointer, so use inspector.input for a touch or pen pointer, or for several pointers at once. Its pointerMove and pointerDown take a pointer id and type, and its pointerUp takes the id.

Use render: "last" when a script needs the final image of a batch. Simulation, input, layout, camera transforms and ticker-driven animation still advance on every frame. Canvas drawing and requested render-target draws wait until the batch completes.

const i = window.__yage__.inspector;
i.time.freeze();
await i.time.stepAsync(200, { render: "last" });
const image = await i.capture.dataURL();

time.stepAsync, time.stepUntil, ctx.step and ctx.until accept render: "all" | "last" | "none" alongside their existing options:

  • all draws every frame. Omitting render keeps the renderer’s current drawing setting, which defaults to enabled.
  • last draws once after successful completion, without spending another simulation frame. A zero-frame call draws nothing. A thrown callback or a predicate timeout does not force a final draw.
  • none skips drawing until an explicit render or capture.

Set the policy on inspector.drive for a bot that makes many one-frame steps:

const run = await inspector.drive(async ({ step, capture }) => {
for (let frame = 0; frame < 200; frame++) await step(1);
await capture("after-run");
}, { render: "none" });

A drive’s last policy draws once when the whole callback succeeds, rather than once per step(1). The policy remains active while the drive releases held input. Its final draw follows successful input cleanup; a release callback that throws prevents that draw. A step’s explicit render option overrides the drive policy for that call. An explicit capture always draws the current stage and flushes requested render targets, including lighting, without advancing time. The drawing setting used before stepping is restored even when a step fails.

Keep the stage visible: hiding it also hides UI focus scopes. Pointer targeting stays current during suppressed frames. Pointer dispatch still requires one initial drawn frame; call time.step(1) before starting a no-draw run on a page that boots frozen.

RendererPlugin.drawingEnabled controls automatic canvas and YAGE render-target drawing outside an Inspector drive too. It defaults to true.

import { RendererKey } from "@yagejs/renderer";
const renderer = engine.context.resolve(RendererKey);
renderer.drawingEnabled = false;
renderer.render(); // draw current state once, without advancing time
const canvas = renderer.captureCanvas(); // capture once; drawingEnabled stays false

render() and captureCanvas() flush deferred target requests before the stage. captureCanvas() returns an HTML canvas. Setting drawingEnabled back to true resumes automatic drawing.

Targets created through renderer.createRenderTarget() follow this policy. Keep calling render() or renderIfNeeded() when the target needs drawing; a suppressed request stays pending and renderIfNeeded() returns false. Deferred targets draw in creation order. Create a source target before any target that samples its texture. Pass dependsOn: [sourceTarget] when creating the dependent target so it waits while its input remains pending, including when the source is hidden. Request both draws even when the first request is deferred. Merely creating or invalidating a target does not request a draw. Destroying a target cancels its deferred request.

Direct Pixi drawing calls bypass the policy. Use YAGE render targets for custom recurring passes so captures can flush them. Texture baking, such as createTexture(), remains immediate. Draw callbacks run only when drawing; keep gameplay updates in components or systems.

Inside a drive, ctx.capture(label?) renders the current stage and records it in the run’s captures as { label, dataUrl }, so one call returns both the numbers and the frames behind them. A frozen clock means the image is the exact frame you stepped to.

declare function doorOpen(): boolean; // this game's own check
const run = await inspector.drive(async (ctx) => {
await ctx.until(() => doorOpen(), { maxFrames: 240 });
await ctx.capture("door-open");
});
run.captures; // [{ label: "door-open", dataUrl: "data:image/png;base64,..." }]

The standalone inspector.capture API, its RendererPlugin requirement, and hiding the HUD before an image you intend to compare are covered in Debug Tools.

  • A hidden or unfocused tab suspends requestAnimationFrame, so a game left to play on its own stops advancing. Frames issued by drive, step and until are direct calls and advance either way. Never wait on wall-clock time for progress.
  • Real DOM key events apply at the next frame’s drain, and their edges are gone by the time that frame ends. page.keyboard.press queues a keydown and a keyup; both apply at the next drain and both edges are true for code running inside that frame, so a press sent as a pair needs no delay. Nothing drains while the clock is frozen and no frame is stepped, and reading isJustPressed after the step returns false — the end-of-frame clear has already run. Read the edge through a component that recorded it during the frame. inspector.input.tap makes a press land inside the frame it steps, without the queue; it is not a way to read the edge back, because it returns after that frame’s clear. A blurred or hidden tab discards whatever is still queued.
  • A top-level const or let in a snippet you evaluate stays declared in the page, so running the same snippet twice fails with a redeclaration error. Wrap each one in (async () => { … })().
  • A page reload discards everything those snippets declared. Reload in one call, then await window.__yage__.ready and probe in the next.
  • Vite’s hot module replacement can swap a module mid-run. Reload the page before trusting a measurement taken across an edit.
  • inspector.time.step() is synchronous and drains no microtasks, so a scene transition queued during it stays queued. Use time.stepUntil / time.stepAsync, or the drive context’s step / until. The synchronous inspector.input.hold / tap / fireAction step through time.step and carry the same limit.
  • time.setDelta(ms) is milliseconds. setDelta(30) means 30ms per frame, not 30 frames per second.
  • inspector.input.mouseDown / mouseUp carry no coordinates and click no button. They act at the last position passed to mouseMove, or at (0, 0) when there was none. Over a @yagejs/ui button they still only write engine input state, so its onClick never runs. To click the user interface, use inspector.pointer, described in Clicking the user interface.
  • A second drive() while one is in flight throws. Await the running one.

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/play-sessions.md.