---
name: headless-threejs-game-qa
description: Check a three.js / WebGL game headlessly on a weak or shared machine - software-GL Chromium via Playwright, render only at screenshot moments, freeze the simulation clock to capture exact animation frames, drive real UI buttons, read audio/diagnostic state, and avoid the reruns that cost the most time. Use when you must prove an in-game change works (animation, sound, UI order) without a GPU or a human watching.
---

# Headless in-engine QA for three.js games

Confirmed on a three.js RTS (Vite dev server, Playwright, Chromium with SwiftShader) on 2026-10-21, 10-25 and 10-28: command UI, villager/tree checks, animation contact frames, unison audio, formation sidesteps. One run takes ~20-50 s once the traps below are handled.

## Setup

- **Own dev server on a free port** (e.g. 5181) so you never disturb a teammate's server: `npx vite --port 5181 --strictPort --host 127.0.0.1 --force`. On WSL `/mnt/c` the dev server does not reliably see edits made after it started: restart it (kill by the PID listening on that port, never `pkill -f` with a pattern also present in your own command line).
- Playwright pairs with a specific Chromium build; if that build is missing, point `executablePath` at an installed one. Flags: `--use-angle=swiftshader --enable-unsafe-swiftshader --renderer-process-limit=2 --num-raster-threads=1`, plus `--autoplay-policy=no-user-gesture-required` when you need to verify sound.
- Viewport 960x540 is enough; 1920x1080 under software GL runs ~1 fps.

## Render only when you take a picture

Software GL is the CPU cost. Intercept the module that renders and gate the draw call:

```js
await page.route('**/src/world.ts*', async q => { const res = await q.fetch(); let body = await res.text();
  const old = 'this.renderer.render(this.scene,this.camera)';
  body = body.replace(old, 'window.__DRAW__&&(window.__DRAW__=false,' + old + ')');
  await q.fulfill({response: res, body}); });
const shot = async n => { await frames(2); await page.evaluate(() => { window.__DRAW__ = true; window.__GAME__.world.render(); });
  await page.screenshot({path: `${out}/${n}.png`}); };
```

The game loop (simulation, animation sync) keeps running; only drawing is skipped. Expose a small debug handle (`window.__GAME__ = {getState, world, ...}`) in the game.

## Exact animation frames

Wall clock is useless under software GL (simulation may run at 0.1x). Measure everything in **simulation time**:

1. Trigger the action through the real UI button (`page.locator('button[data-...]').click()`), so the test covers the wiring too.
2. Pause the game. If pause opens a menu overlay, hide it for captures only (`style.visibility='hidden'`) and resume through its own button afterwards; clicking pause again is blocked by the overlay.
3. Set `state.time = start + t` for each wanted moment and render. This works when the presentation seeks clips from simulation time (recommended for synchronized/group actions).
4. **Step the clock forward only.** Audio/event consumers treat a rewind as a restart and drop in-flight groups; that cost a rerun. Do the audio check on a clean unpaused run first, then the frame captures on a second trigger after any cooldown.

## Frame the subject

Set the camera target to the units' centroid, zoom in, then verify by projecting the centroid to NDC (`target.clone().set(x,y,z).project(camera)`); deselect units after clicking so selection panels do not cover them. Arrange units facing roughly toward the camera so the action reads.

## Fixtures and assertions

- Remove or freeze opponents (QA fixture only) so units stay idle long enough.
- Assert state, not just pixels: per-unit presentation action (e.g. `shieldBang`, `strafeLeft`), audio diagnostics (`enabled`, `unlocked`, `loaded`, `errors`, `playedByCue`), toast text, asset source of the loaded model.
- Audio: persisted volume settings can load as muted; seed them with `addInitScript(localStorage.setItem(...))`.
- Write `report.json` with results + page errors next to the screenshots; stitch crops into one sheet for review.

## Rerun killers (each cost a full run once)

| Symptom | Cause | Fix |
|---|---|---|
| All frames show the pause menu | pause opens a modal | hide overlay for captures, resume via its button |
| Sound count 0 | clock rewound after paused captures | audio run first, captures second, forward only |
| Units at screen edge / under panels | target clamped, selection panel open | project to NDC, deselect after click |
| New code not running | dev server started before the edit | restart server, `--force` |
| Right-click on ground selects a tree | picking resolves canopy | use the game's move command API |
| State read misses a new building | visual created next frame | wait a frame |

## Cleanup

Stop your dev server by PID, close the browser, and leave no background jobs. Report what was actually observed (counts, actions, screenshots), and say what was not checked (e.g. a human listening test).
