# Seeing your game: autopilot, `vp shot`, film strips and the visual checks

`bun test` proves the rules and the netcode. This is how you **look** at the game without a
person at the keyboard: your own seat played by your CPU, the game fast-forwarded frame by frame
by a script, screenshots and film strips of it, and checks that read those pictures for you.

## Contents

1. Autopilot: your CPU plays your seat
2. `vp shot`: scripted play and pictures
3. Film strips: movement in one picture
4. The visual checks and the sound log
5. What `vp check` adds
6. Scripted play by hand: `__vp` on the `vp dev` page
7. Every model at once: `vp gallery`
8. Pitfalls

## 1. Autopilot: your CPU plays your seat

`input.autopilot` is true when a CPU should drive **this** player's seat: your own bot brain
plays it, while the camera, HUD, sounds and first-person view stay yours. `vp shot`, `vp check`
and `vp dev ?autopilot=1` turn it on (never the site). It's how your first-person camera, your
HUD and your own-player code get tested headless, where nobody is at the keyboard.

Every template already does it, and it's two lines in any game: `intent()` says "nothing, a CPU
has this one", and the core falls back to the seat's bot.

```ts
// game.ts
private intent(): Intent | null {
  if (this.ctx.input.autopilot) return null;      // your CPU plays your seat
  return readIntent(this.ctx.input, { out: this.read });
}

// core.ts
constructor(link: MinigameLink, frame: GameFrame, private readonly intent: () => Intent | null) { … }
const it = role === 'local' ? (this.intent() ?? this.bot(p.id).update(dt, p, this)) : this.bot(p.id).update(dt, p, this);
```

Read it every frame (a script can switch it on and off mid-game). Keep everything else the same
as for a person: the view follows your body, the HUD shows your numbers, your sounds play. A game
without the intent pattern does whatever it does with your input instead:
`input.autopilot ? this.bot(you).update(dt, me, world) : input.move()`.

## 2. `vp shot`: scripted play and pictures

```sh
bunx vp shot                       # the default: your seat on autopilot, strips and shots of play
bunx vp shot shots.ts              # your own script
bunx vp shot --phone               # on a phone held sideways (844×390, touch, a notch)
bunx vp shot shots.ts --phone      # your script on a phone
```

It builds the game, plays it in a muted headless browser on a **fast-forwarded clock** (the game
only moves when the script says, one 60 Hz frame at a time, as fast as the machine goes), takes
`title.png` (the title card), presses Ready and any setup's Start, then runs the script. Pictures
and `report.json` land in `.vp/shots/`; open each picture and look at it. Flags: `--players N`,
`--seed N` (the same seed plays the same game), `--mode session|minigame`, `--autopilot` (your seat
on autopilot from the start), `--size 1280x720`, `--phone` (or `--touch`), `--no-play` (stay on the
title card).

**On a phone** (`--phone`) the screen is 844×390 CSS px with the touch controls and a notch
(`--portrait`: held upright, 390×844), and each picture of play says how much of the screen the HUD
covers, piece by piece:
`HUD on the phone's screen: play-1.png 12% (div.bar 7%, div.hearts 3%)`. A phone player sees the
game through what's left, so think of a good mobile game: a few small pieces hugging the edges and
corners, the middle clear. Past 18% it's a ⚠. Make it fit with CSS under `html.vp-phone`: shrink panels,
sink round gauges half past the screen's edge, put text on one line, and hide what a phone player
can't use (key hints, a desktop-only panel). Check both ways up: people hold phones upright too.
Open the pictures and look: the number doesn't see a panel that's in the way of the action. The
pictures (on any screen) show the site's corner at the top right as players see it: its buttons,
or the ☰ on a phone.

A script is a module whose default export gets `t`:

```ts
// shots.ts
import type { Shots } from '@voxelparty/sdk/test';

export default async (t: Shots) => {
  await t.hold('up', 800);                       // W for 0.8 s of play
  await t.look(240, 0);                          // turn right (mouse look, no pointer lock needed)
  await t.shot('corner');                        // .vp/shots/corner.png
  await t.press('action');                       // jump
  await t.strip('jump', 8, 700);                 // 8 frames over 0.7 s, one picture
  await t.autopilot(true);                       // your CPU takes over
  await t.until('game.core.world.fighters.size >= 4', 10_000);
  await t.camera({ at: [0, 30, 24], look: [0, 0, 0] });   // a wide photo…
  await t.shot('overview');
  await t.camera(null);                          // …and the game's camera back
  console.log(await t.eval('game.core.world.scores()'));
};
```

| `t.` | |
|---|---|
| `wait(ms)` | let `ms` of the game's time pass |
| `shot(name?)` | a screenshot, checked (section 4); returns its path |
| `strip(name, frames = 6, ms = 1000)` | a film strip (section 3) |
| `press(key)` `hold(key, ms?)` `release(key?)` | a key: an action (`'action'`, `'up'`…), a code (`'KeyR'`, `'ShiftLeft'`) or a character (`'r'`); `hold` without `ms` holds until `release` |
| `move(x, z, ms?)` | walk like `input.move()`: x right, z towards the camera |
| `look(dx, dy)` | mouse movement in px: what `input.look()` reads |
| `click(x, y, button?)` `mouse(button?, ms?)` `point(x, y)` | the mouse, CSS px: the game's button and whatever HTML is there (menu buttons work) |
| `autopilot(on?)` | your CPU plays your seat (section 1) |
| `play()` | past the title card: Ready, then any setup's Start (already done unless `--no-play`) |
| `camera(pose \| null)` | hold the camera at `{ at, look, fov? }` (world coordinates, arrays) after every update, whatever the game does with it, or give it back; it plays one frame, so the next `shot` already shows it (HTML the game places by its own camera, like name tags, stays where the game put it) |
| `eval(code)` `until(code, ms?)` | run code inside the game (`game`, `ctx`, `link`, `engine` in scope), or wait until it's truthy |
| `join()` `leave(pid?)` | a CPU drops in, someone leaves (sessions) |
| `lint()` `sounds()` `warn(text)` | the HUD check now, the sound log so far, a warning of your own |

Because the clock is the game's, a script is exact and repeatable: `hold('up', 800)` is 800 ms
of play whatever the machine, and the same seed gives the same pictures. The input is real
keyboard and mouse events inside the game's frame, so it goes through your normal input code.

## 3. Film strips: movement in one picture

A screenshot can't show a jump arc, a knockback, a camera that lags, an animation that pops, or
a hit that has no feedback. `t.strip('jump', 8, 700)` takes 8 frames evenly over 0.7 s of play
and tiles them into **one** PNG, three to a row, each numbered with its time (`3 +200ms`). Read
it like a comic: does the body rise and fall smoothly, does the camera follow, does the hit
flash, do the particles last long enough? Strip what you just tuned, every time: a jump, a dash,
a hit, a death, a pickup, the first second after GO.

## 4. The visual checks and the sound log

Every `t.shot` (and every `vp check` screenshot) is checked, and each finding is a ⚠ with the
picture's name. They're warnings, not failures, and each one is meant to be fixed:

- **The 3D view is blank:** black, white, one flat colour, or so little detail it's sky or a wall
  filling the view (checked with the HTML hidden). The camera is inside something, looking the
  wrong way, not placed yet, or there's nothing to see.
- **The 3D view is frozen:** two pictures of play seconds apart are the same.
- **Z-fighting:** two meshes draw a face in the same place, facing the same way, so it flickers
  between them (stripes of grass on a rock wall). On `mats.solid` and `mats.actor` the SDK settles
  most of it by itself: of two things with faces in one plane, the smaller is always drawn in front
  (a drawer shut in its cabinet, a sign on a wall), and copies in one InstancedMesh go by their
  index. So the ⚠ only names what that can't settle: two meshes about the same size, or one mesh
  whose faces overlap. It names both (material, size, where they start) and the box where they
  fight. Nearly always two volumes that share blocks: build them as one Volume, or place them side
  by side (a volume `w` wide at x 0 ends where the next starts, at x `w`, not `w - 1`). Things
  that cross (boards nailed over each other): give each its own depth, a centimetre or two apart.
  A decal meant to lie on a face: give its material `polygonOffset`, or mark the mesh
  `userData.vpOverlapOk = true`. From a script: `t.zfights()`.
- **HUD text cut off** by its box (`overflow: hidden`; an ellipsis on purpose is fine), **partly
  off screen**, **on top of other text**, or **smaller than 10 px**.
- **HUD panels on top of each other** (one hides the other, text and all) and **HUD under the
  site's corner** (top right, `var(--vp-corner-w)` × `var(--vp-corner-h)`), on every screen.
- **On a phone** (`--phone`, and `vp check`'s phone runs): text **under the touch controls**
  (`[data-vp-touch]`) or in the **notch's safe area**: keep HUD inside `env(safe-area-inset-*)`.
  And a **crowded HUD**: more than 18% of the screen painted over the game (panels, bars, icons,
  text; not the touch controls or a menu). From a script: `(await t.lint(true)).cover`.
- **No sounds of the game's own** played (only the frame's Ready and countdown).
- **Your seat stands idle** (autopilot runs): the game never read `input.autopilot`, so nobody
  played your seat and the pictures show a player standing still. Read it where you read your
  input (section 1).
- **Movement in stops and starts** (`vp check`'s autopilot run): at the end it takes your seat back,
  holds right and then up for 1.5 s each, and records the camera and every character frame by
  frame. Anything that moves most of the time but unevenly (stands still, then jumps, several
  times a second) is named: `character 1 (0.99)`. Steady movement scores under 0.1. It's what
  players call buggy movement: draw your own character from a prediction that glides between
  ticks (`ls.predictor()` in a Lockstep game, your local body every frame otherwise), and others
  with `PlayerSync.smooth` or `Reckon`. In a script: `drive.track(true)`, hold, then
  `drive.trackReport()` (through `t.eval`).

The **sound log** counts every sound played, even muted: `SFX.coin ×12` for the shared ones, and
your own by what played them: `SnowballFight.cues (game.js:630) ×37`. A sound that never shows
up never played; one with hundreds of plays per second is stuck in a loop. It also measures
loudness (K-weighted dB, like LUFS, rendered offline): `levels: typical sound -22.4 dB; loops at
their loudest: Camp.light (game.js:88) -27.9`. The typical sound is the median of your own sounds,
each at the loudest `vol` it played; a loop is its steady level as heard where it was loudest. A ⚠
names any `sfx.loop` that came within 3 dB of the typical sound, any sustained patch played with
`sound.play` that loud (an ambient bed belongs in `sfx.loop`), and all loops together going over
it: sounds that keep going should sit well under the hits (`vp docs sound`).

## 5. What `vp check` adds

Besides its bots-only rounds and sessions, `vp check` plays two runs with **your seat on
autopilot**: one at 1280×720 (`pilot-title.png`, `pilot-go.png`, a strip at GO, `pilot-play-1.png`,
`pilot-play-2.png`, then the movement check above), one on a phone held sideways (`phone-title.png`, `phone-play.png`) and one upright (`portrait-title.png`, `portrait-play.png`). They show
what a player sees, first-person code included, and fail on any error like the other runs. All
screenshots get the visual checks. A game that needs more than 4 players (`players.min`, the fewest it
works with) is checked with what it needs.

## 6. Scripted play by hand: `__vp` on the `vp dev` page

The `vp dev` page has the same helpers as `t`, as `window.__vp` (in real time there):

```js
await __vp.autopilot(true)          // or open the page with ?autopilot=1
await __vp.play()                   // or ?autoplay=1: past the title card by itself
await __vp.hold('KeyW', 500); await __vp.look(200, 0); await __vp.click(640, 360)
await __vp.camera({ at: [0, 20, 20], look: [0, 0, 0] })
await __vp.lint()                   // what's wrong with the HUD right now
await __vp.sounds()                 // which sounds played so far
await __vp.eval('game.round')       // anything inside the game
```

Also `?shot=1` (no harness buttons over the game).

## 7. Every model at once: `vp gallery`

Play shows a handful of your models at a time. `vp gallery` shows **all of them**: declare them in a
`gallery.ts` next to `index.ts` (each with a builder that calls your own model code), and it draws
them with the game's engine on numbered pages in `.vp/gallery/` (40 a page, each page at most about
1,536 px so every label stays legible), with turnarounds, silhouettes, motion strips, game-distance
views and textures on request, and checks: broken or empty models, near-duplicates ("#41 and #88
look 94% alike"), budget outliers, floating models, off-style textures. `--diff` shows only what
changed since the last run; `--scene` finds models the game draws that the gallery doesn't
declare; `vp dev ?gallery` browses them live. `vp check` runs its checks whenever there's a
`gallery.ts`.

```ts
// gallery.ts
import type { Gallery } from '@voxelparty/sdk/test';
export default (g: Gallery) => {
  const ids = useGameAssets(TEXTURES, BLOCKS);
  g.group('Toys', { scale: 'shared' });
  for (const t of TOYS) g.add(t.name, () => new Mesh(toyGeometry(ids, t), g.engine.mats.actor), { tags: [t.rarity] });
  g.textures('Textures', TEXTURES);
};
```

**After making or changing any art: `bunx vp gallery`, read every page, fix what's flagged, and
include the pages in your report.** Everything else is in `vp docs gallery`.

## 8. Pitfalls

- **Autopilot does nothing**: the game ignores `input.autopilot` (section 1), so your seat stands
  still in every picture. Or its bot assumes it only ever runs on the host for other players.
- **"Couldn't get into play"**: the title card's button or the setup's Start never showed (a
  custom start screen?). Press your own buttons with `t.click(x, y)` or `t.press(...)`.
- **A script that waits in a loop on the real clock** hangs: inside the game every clock is the
  fast-forwarded one. Use `t.wait` and `t.until`.
- **Pointer lock** is granted in `vp shot` and `vp check` runs the way a browser grants it after a
  click (a headless browser never would), so a game with `pointerLock: true` is locked once `play()`
  presses "Click to play", and `look()` and `click()` reach `readIntent`. A game that locks at
  some other moment has to call `input.lockPointer()` itself, as it would for a player.
- The fast-forwarded clock is exact but not real time: real frame-rate hitches only show in
  `vp check`'s real-time runs and when a person plays.
- **A hitch in `vp check --long` that play doesn't have**: work spread over frames by a time budget
  on `performance.now()` (build a few ms a frame) runs whole in one frame when that clock stands
  still. Measure budgets with `perfNow()` from `@voxelparty/sdk/core`, the real clock
  (`WorldView` and `NavGrid.work` do).
