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 and releases every synthetic input afterwards, so a run leaves
no key held:
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 > 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:
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:
const i = window.__yage__.inspector;let lastX = 0;const run = await i.drive(async (ctx) => { while (!atExit()) { lastX = i.getEntityPosition("player").x; 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:
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.
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.
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. They act at the last position passed tomouseMove, or at(0, 0)when there was none.- A second
drive()while one is in flight throws. Await the running one.