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 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 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
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 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:

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.

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 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. They act at the last position passed to mouseMove, or at (0, 0) when there was none.
  • A second drive() while one is in flight throws. Await the running one.