# Play sessions

Driving a running game to answer a question about it: hold keys, advance
frames, read the state back. `debug: true` on the engine publishes
`window.__yage__`, which carries `ready` and the Inspector that `DebugPlugin`
installs. Anything that steps the clock — `drive`, `time.freeze`,
`time.stepUntil` — needs `DebugPlugin`, which supplies the clock control. With
only `InspectorPlugin` installed, those calls throw while the readouts still
work.

The full Inspector surface is in `llms/packages/debug.md`, and the scenario lab
in `llms/tools/lab.md`. This page covers which mechanism to reach for and how
to write the run.

## Pick a 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 a person watching                      | a lab scenario with `controls`                                 |
| Behavior a person 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.
- Rerun it while iterating with `yage-lab test --scenarios scratch/gap.scenario.ts`.
- Delete it at the end of the session, or promote it deliberately: move the
  file next to the code it exercises, where the project's normal glob finds it.
- Keep scratch files out of git.

## Wait for the game before driving it

```ts yage-context="browser,playwright"
await page.waitForFunction(() => window.__yage__ !== undefined);
await page.evaluate(() => window.__yage__.ready);
await page.waitForFunction(
  () => window.__yage__.inspector.getSceneStack().length > 0,
);
```

`window.__yage__` is published as `start()` begins, so finding it says nothing
about how far boot got. `ready` settles when `start()` finishes, and rejects
with the error that stopped a failed boot instead of leaving a poller to time
out.

The host pushes the first scene after `await engine.start()`, so `ready` does
not cover it. Poll for the scene rather than stepping to it — the clock is
running at that point, and `step`/`stepUntil` throw on a clock that is not
frozen.

## One drive call, on the game page

`inspector.drive(fn, opts?)` freezes the clock, hands the 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.

```ts yage-context="browser"
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, x: i.getEntityPosition("player")?.x };
  },
  { maxFrames: 900 },
);

run.framesUsed; // frames the whole run issued
run.state; // { keys, actions, scenes } at the moment the run ended
run.captures; // screenshots ctx.capture() took, as { label, dataUrl }

// `ok` discriminates the result, so narrow before reading the rest: `value`
// exists only on the success branch, `error` and `timedOut` only on the failure
// one.
if (!run.ok) throw new Error(run.error);
run.value.frames;
```

The context carries `step(frames?, { dtMs?, render? })`, `until(predicate, { maxFrames?,
dtMs?, render? })`, `input`, `events`, `capture(label?)` and a live `framesUsed`. Read
`framesUsed` off the context — it is a getter, and a destructured copy stays at
the value it had when the run started.

`state` is read before the run releases what the callback left held, so it
reports the keys that were actually down at the end. `opts.maxFrames` bounds
the whole run: the budget is checked before each frame-advancing call, and once
spent the run ends with `ok: false` and `timedOut: true`. The budget unwinds by
throwing inside the context, so a callback that catches every exception and
carries on defeats it. Omit `maxFrames` and a default of 10,000 frames applies;
pass `Infinity` for none.

A drive releases all synthetic input when it ends, so a hold does not carry
into the next call. A sequence that depends on a key staying down belongs in
one drive.

## The Inspector verbs on their own

`drive` is the clock control and the input verbs bundled with cleanup. Reach
past it when a run spans several evaluated calls, or when you want the clock
frozen while a person looks at the screen:

```ts yage-context="browser"
declare function enemyDown(): boolean; // this game's own check

const { time, input } = window.__yage__.inspector;

time.freeze(); // nothing advances until thaw()
time.step(1); // synchronous: one frame, no microtask draining
await time.stepAsync(30); // drains between frames
await time.stepUntil(() => enemyDown(), { maxFrames: 240 });
time.setDelta(16.667); // milliseconds per simulated frame
time.thaw();
```

`time.isFrozen()` reports the flag; `time.isAdvancing(withinMs)` reports
whether the loop actually ticked, which separates a frozen clock from a stalled
one.

`inspector.input` mirrors the drive context's verbs synchronously:
`keyDown`, `keyUp`, `mouseMove`, `pointerDown`, `gamepadAxis`, `clearAll`.
Its `hold`, `tap` and `fireAction` step through the synchronous `time.step`, so
they advance frames without draining microtasks — the drive context's versions
are the awaitable ones. Nothing here releases input for you; pair every hold
with a release, or call `clearAll()`.

`inspector.input` never reaches a `@yagejs/ui` element: its verbs write engine
input state, and a UI primitive receives clicks as renderer events on its own
container. `inspector.pointer` dispatches real pointer events at the canvas,
which does reach one — a build menu, a pause screen, a confirm dialog.

```ts yage-context="browser"
const inspector = window.__yage__.inspector;
const { pointer } = inspector;
const menu = inspector.snapshot().scenes[0]?.ui?.root;
const first = menu?.children[0];
if (!first) throw new Error("the first scene has no user interface");

pointer.hitTest({ x: 160, y: 90 }); // what is under this point
const hit = pointer.click(first.id); // centre of that node's bounds
hit.path.some((node) => node.type === "UIButton"); // did a button take it
```

A click by id needs `bounds` on the snapshot node, which the renderer fills in:
the box the element covers on screen, scale and rotation included. An element
drawn with no area, such as one scaled to 0, throws.
`layout` beside it is Yoga's parent-relative box and locates nothing on screen.
Every verb needs `RendererPlugin`. The four verbs that dispatch also need one
rendered frame; `hitTest` does not. Each guard throws and names what to do.
They drive one primary mouse pointer; a touch pointer or a second finger stays
with `inspector.input`.

The handler has already run when `click` returns, because delivery is
synchronous. Engine input state reflects the press one frame later, so step a
frame before asserting on an action.

## Playing by rules instead of by a fixed script

A fixed script — hold Right 30 frames, jump, step 45 — only works while the
tuning constants hold. A run that reads the game each frame and picks the next
input 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 the
maneuvers inside:

```ts yage-group="gauntlet"
// 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);
      }
    });
  },
});
```

**A maneuver is an ordinary async function** that awaits its own frames. It
needs no state machine and no per-frame phase counter, because the frames it
spends are the ones it awaits:

```ts yage-group="gauntlet"
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`, 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. Do not 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`, not a loop counter.** 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, including frames spent inside a nested maneuver. Use the context's
step verbs during a drive; direct `inspector.time` mutations are blocked while
the drive owns the clock.

**The sensors are game code.** `gapAhead`, `enemyAhead`, `overTarget` and
`atExit` are raycasts and queries written next to the game they read, and
`GroundProbe` is the 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 a game whose
components already answer that needs no extra probe code.

The example above 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 (below). And the context is a different type: annotate a helper
meant for the game page with `InspectorDriveContext` from `@yagejs/core`, since
the lab's `DriveContext` also requires `scene`, `controls` and `expect`. A
helper that has to work on both takes the members it uses:
`{ step, until, input }`.

Capture a game-specific final state in a plain variable in the enclosing scope
rather than in the callback's return value. A budget that stops the run unwinds
the callback, so the return value is lost while the variable keeps its last
assignment:

```ts yage-context="browser"
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 from the game's own numbers

Derive the cap from tuning constants instead of guessing a sleep. 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:

```ts
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. Waiting a fixed number of frames instead re-fails every time someone
changes the run speed. `until` resolves with the frames it took, which is the
measurement worth returning.

## Reading the state back

```ts yage-context="inspector"
inspector.getEntityPosition("player"); // { x, y } | undefined
inspector.getComponentData("player", "Health"); // reflected fields and getters
inspector.getSceneStack(); // scene snapshots, bottom to top
inspector.getInputState(); // { keys, actions, mouse, pointers, gamepad }
inspector.snapshotJSON(); // whole world, sorted, for diffing
inspector.events.getLog(); // bus, entity and scene events
await inspector.events.waitFor("enemy:hit", { withinFrames: 60 });

// Same-named entities are told apart by id.
const lanterns = inspector.getEntities().filter((e) => e.name === "lantern");
const third = lanterns[2];
if (third) inspector.getComponentData(third.id, "Health");
```

`getComponentData` reflects a component's enumerable fields and public
getters, so any component is readable without diagnostic code of its own.
`getInputState()` is the cheap way to check what is held — `snapshotJSON()`
walks every scene and entity.

`getComponentData`, `getEntity`, `getEntityPosition` and `hasComponent` take a
name or an entity id. A name reads the first active entity with that name; an
id reads exactly the entity `getEntities()` or `snapshot()` handed that id out
for, wherever it sits on the scene stack.

`events.waitFor` has to be started before the frames that satisfy it, because
the run is the only thing issuing frames:

```ts yage-context="inspector"
await inspector.drive(async (ctx) => {
  const hit = ctx.events.waitFor("enemy:hit", { withinFrames: 60 });
  await ctx.step(60);
  await hit;
});
```

## 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.

```ts yage-context="browser"
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:

```ts yage-context="inspector"
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

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

```ts yage-context="engine"
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.

## 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
measurements and the frames behind them. A frozen clock means the image is the
exact frame that was stepped to.

```ts yage-context="inspector"
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.dataURL()` / `pngBase64()` / `png()`, the
`RendererPlugin` requirement, and hiding the HUD before an image you intend to
compare are in `llms/packages/debug.md`.

## Game-specific probes that survive a reload

Helpers defined in an evaluated snippet are gone after the next page load.
Register them as an inspector extension instead, from a module the production
build drops:

```ts yage-group="probe" yage-file="dev/probe.ts"
// src/dev/probe.ts — imported only under a build flag the release drops.
import {
  Component,
  type Engine,
  type Entity,
  InspectorKey,
} from "@yagejs/core";

// This game's own world model and components.
declare class World {
  rooms: { id: string; exits: string[] }[];
}
declare class Inventory extends Component {
  grant(itemId: string): void;
}
declare class Movement extends Component {
  runSpeed: number;
}

export function registerProbe(engine: Engine, world: World, player: Entity) {
  engine.context.resolve(InspectorKey).addExtension("probe", {
    roomGraph: () => world.rooms.map((r) => ({ id: r.id, exits: r.exits })),
    grantKey: () => player.get(Inventory).grant("brass-key"),
    setRunSpeed: (v: number) => {
      player.get(Movement).runSpeed = v;
    },
  });
}
```

```ts yage-group="probe" yage-file="main.ts" yage-context="engine"
/// <reference types="vite/client" />
// src/main.ts — call it once the world exists.
import type { Entity } from "@yagejs/core";

declare const world: { rooms: { id: string; exits: string[] }[] }; // the game's world
declare const player: Entity; // the game's player entity

if (import.meta.env.DEV) {
  const { registerProbe } = await import("./dev/probe.js");
  registerProbe(engine, world, player);
}
```

Read it back from a drive or the console:

```ts yage-context="browser"
const probe = window.__yage__.inspector.getExtension<{
  roomGraph(): { id: string; exits: string[] }[];
  grantKey(): void;
  setRunSpeed(v: number): void;
}>("probe");
```

A namespace can only be registered once; a second `addExtension("probe", …)`
throws. `getExtension` returns `undefined` when nothing registered the
namespace, which is what a production build leaves.

## The scenario lab

`@yagejs-tools/lab` mounts one scene, entity or mechanic in a real engine with
the game's own plugins, and gives every scenario the same verbs plus `scene`,
`controls` and `expect`. A probe worth keeping moves from a drive call into a
scenario's `drive` with little edited. Four things differ:

- A scenario's `drive` returns `void`. Assert with `expect` inside the callback
  rather than returning a measurement.
- `input.fireAction` holds the action down for the whole span here, while
  `inspector.input.fireAction` pulses it once per frame. A hold-to-charge move
  behaves differently under each.
- `input.pressAction` / `input.releaseAction` exist only on the lab's context.
- A scenario's own `drive` gets no frame budget — `yage-lab test` and the
  panel's Run button own that timeout. `maxFrames` applies to an ad-hoc
  `__yageLab__.drive()` call.

`__yageLab__.drive(fn, opts?)` runs a callback against whatever scene is
mounted, without rebuilding it, which is how to explore a scenario from the
console before writing its `drive`. `yage-lab test` runs every scenario
headless and exits non-zero when one fails.

## One call per question

Set up, drive, verify and return one verdict in a single evaluated call. The
alternative — one call to press a key, another to step, another to read the
result — pays a round trip per line and loses everything the previous call
declared.

```ts yage-context="browser,playwright"
const verdict = await page.evaluate(async () => {
  const i = window.__yage__.inspector;
  const probe = i.getExtension<{ setRunSpeed(v: number): void }>("probe")!;
  const results = [];
  for (const speed of [200, 300, 400]) {
    probe.setRunSpeed(speed);
    const run = await i.drive(async (ctx) => {
      ctx.input.keyDown("KeyD");
      return await ctx.until(
        () => (i.getEntityPosition("player")?.x ?? 0) > 900,
        {
          maxFrames: 400,
        },
      );
    });
    results.push({ speed, ok: run.ok, frames: run.framesUsed });
  }
  return results;
});
```

Return plain data — numbers, strings, plain objects. An `Entity` or a
`Component` does not survive the trip out of the page. A parameter sweep
belongs in one call, as above, not one call per value.

## Traps

- **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 `EarlyUpdate` drain and both edges are
  true for code running inside that frame, so a press sent as a pair needs no
  delay between the two. 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 `const` or `let` typed straight into the browser console stays declared
  in the page**, so running the same lines twice fails with a redeclaration
  error. Wrap console work in `(async () => { … })()`. Code inside a
  `page.evaluate(async () => { … })` callback is already function-scoped and
  needs no wrapper.
- **A page reload discards everything the 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 or any other promise chain queued during it stays queued.
  Use `time.stepUntil` / `time.stepAsync`, or the drive context's `step` /
  `until`. The sync `inspector.input.hold` / `tap` / `fireAction` step through
  `time.step` and carry the same limit; the drive context's versions are
  awaitable and drain between frames.
- **`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.
  Releasing the last button on a touch pointer drops it from `getPointers()`; a
  mouse pointer stays.
- **A second `drive()` while one is in flight throws.** Await the running one.
