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).
Which mechanism
Section titled “Which mechanism”| Situation | Use |
|---|---|
| One question about the game as it is running | one inspector.drive() call on the game page |
| A bug whose situation you can build from nothing | a scenario file, rerun with yage-lab test --scenarios <file> |
| Rerunning the same probe while iterating on a fix | move it out of the console into a scenario file |
| State only the real game reaches: progression, saves, the room graph | the Inspector on the game page |
| Tuning a number by feel, with someone watching | a lab scenario with controls |
| Behavior someone accepted and wants kept true | a 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:
npx yage-lab test --scenarios scratch/gap.scenario.tsDelete 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.
The shape of one run
Section titled “The shape of one run”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 issuedrun.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.
Play by rules, not by a fixed script
Section titled “Play by rules, not by a fixed script”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:
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 gotFrame 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.
Clicking the user interface
Section titled “Clicking the user interface”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 runhit.path[0]?.type; // "UIText": the label, drawn on top of the buttonhit.path.some((node) => node.type === "UIButton"); // trueclick, 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.
Stepping without drawing every frame
Section titled “Stepping without drawing every frame”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:
alldraws every frame. Omittingrenderkeeps the renderer’s current drawing setting, which defaults to enabled.lastdraws 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.noneskips 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.
Renderer control and custom passes
Section titled “Renderer control and custom passes”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 timeconst canvas = renderer.captureCanvas(); // capture once; drawingEnabled stays falserender() 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.
Screenshots
Section titled “Screenshots”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 bydrive,stepanduntilare 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.pressqueues 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 readingisJustPressedafter the step returnsfalse— the end-of-frame clear has already run. Read the edge through a component that recorded it during the frame.inspector.input.tapmakes 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
constorletin 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__.readyand 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. Usetime.stepUntil/time.stepAsync, or the drive context’sstep/until. The synchronousinspector.input.hold/tap/fireActionstep throughtime.stepand carry the same limit.time.setDelta(ms)is milliseconds.setDelta(30)means 30ms per frame, not 30 frames per second.inspector.input.mouseDown/mouseUpcarry no coordinates and click no button. They act at the last position passed tomouseMove, or at(0, 0)when there was none. Over a@yagejs/uibutton they still only write engine input state, so itsonClicknever runs. To click the user interface, useinspector.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.