agents/openai.yaml
interface:
display_name: "Three.js QA Release"
short_description: "Verify and release Three.js games"
default_prompt: "Use $threejs-qa-release to verify this game's changed behavior and gather current-run evidence proportional to the task."
references/playtest-bot.md
# Bot Playtest
Automated playtests drive the game through scripted real input and measure whether it actually plays: objective progression, player responsiveness, softlock windows, and error-free runtime. A game that renders beautifully but cannot be progressed by a scripted sweep is not release-ready. Use this for release-ready gameplay claims and difficulty/fairness verification; the canvas inspector proves the game renders, the bot proves it plays.
## Prerequisites
- `window.__THREE_GAME_DIAGNOSTICS__` publishing frame, score/objective, complete/fail state, and player position every update.
- `window.__THREE_GAME_TEST_HOOKS__` with at least `seed()` and `setState()` so runs are reproducible (scaffold games ship both). Await both; `setState(name)` must acknowledge `{ state: name }` after applying it and throw on unknown states.
- All gameplay randomness routed through the seeded RNG — otherwise bot metrics are noise.
## Setup
Copy the packaged template and adapt it:
```bash
cp tests/bot-playtest.template.ts tests/bot-playtest.spec.ts
npx playwright test tests/bot-playtest.spec.ts
```
Adapt `INPUT_SCRIPT` to the game's controls and level layout: an endless runner bot holds forward and switches lanes on a cadence; an arena game sweeps the play space; a tower defense bot clicks a build pad and starts waves. Game-specific hooks (e.g. `forceWave()`) can set up later states, but separately exercise actual player controls so hooks cannot hide broken input.
## Metrics And What They Mean
- `framesAdvanced` — the loop survived the whole run; a stall here is a crash or frozen loop.
- `distanceTravelled` — input responsiveness; near-zero under held keys means broken input mapping.
- `scoreAfter - scoreBefore` and `stepOfFirstScore` — objective progression and how quickly a naive player finds it. If a scripted sweep never scores, the objective is unreachable, unreadable, or broken.
- `softlockWindows` — sampling windows where frames advanced but held input produced neither motion nor progress. Repeated windows indicate stuck-on-geometry, dead input states, or unrecovered fail states.
- Time-to-first-fail (games with fail states) — add a scripted "reckless" run that seeks hazards and assert the fail state triggers and the retry path restores play; a game that cannot be failed has no pressure, and a fail state that cannot be retried is a release blocker.
- Console/page errors — must be empty for the full run.
## Headless WebGL Caveats
- Always launch Chromium with `channel: 'chromium'` (the scaffold config and `inspect-threejs-canvas.mjs` do). Playwright's default headless is `chromium_headless_shell`, which ships no GPU backend and silently falls back to SwiftShader (CPU). This is a launch-config bug, not a headless limitation: on the same 1024x1024 scene the shell reports `ANGLE (Google, ... SwiftShader driver)` and renders at 32 fps, while `channel: 'chromium'` reports `ANGLE (Apple, ANGLE Metal Renderer: Apple M3 Pro)` and renders at 127 fps — ~4x, from one line of config.
- Verify the GPU before reporting any FPS; never assume it. `inspect-threejs-canvas.mjs` records a `gpu` block (`renderer`, `vendor`, `softwareRendered`) in its JSON report — check it. If `softwareRendered` is true, the run fell back to CPU and its FPS/frame-time numbers are not performance evidence; pixel, budget, and functional checks are still valid. Fix the fallback with `npx playwright install chromium` rather than caveating the number.
- Run Playwright suites with `workers: 1` for WebGL games (the scaffold config does). Parallel contexts still contend for the GPU, and the frame-time collapse makes game time drift from wall time, flaking timed phases and screenshot baselines.
- Headless FPS on a verified real GPU is still not a phone. Treat it as a desktop-GPU signal and validate mobile targets on real hardware.
## Difficulty And Fairness Signals
For games with fail states, run the bot at two skill levels (e.g. reaction delay 0ms vs 300ms between script steps) and compare survival time and score. If the delayed bot survives as long as the fast one, difficulty pressure is decorative; if even the fast script cannot survive the first threat, the opening is unfair. Report both runs when difficulty tuning is in scope.
## Reporting
Include in the QA evidence: the JSON report attachment (steps, frames, score progression, distance, softlock windows, errors), the seed used, and pass/fail per assertion. Report the bot playtest decision like the visual harness decision: added / extended / skipped with reason.
references/release-checks.md
# Release Checks
What to check beyond the QA pass in this skill's `SKILL.md`, and the traps that repeatedly ship broken games.
Apply the full list for a release or a complete game. For a narrow change select the affected behavior and shared risks, and reuse the lead's checks from the same code/asset revision. Mobile checks apply when mobile is a supported target. Changes to animated models need the motion pass in `visual-test-harness.md`.
## Mobile
- Touch controls emit game intents, not just visual press states.
- Pointer release, cancel, and blur cannot leave a control stuck down.
- Safe areas respected; touch targets reachable and separated.
- Page scroll does not steal gameplay input.
- Orientation change and resize preserve canvas and HUD.
- DPR and frame time acceptable on the mobile tier.
- Desktop input still works unless it was intentionally removed.
## Production release
- Production build passes, and the production preview or static server is the thing actually tested.
- Vite `base` and asset URLs match the target host; public assets load under the static-hosting assumption.
- Debug GUI, diagnostics overlays, verbose logging, and test shortcuts are gated or removed from the player-facing build.
- Bundle and large assets reviewed.
- No API keys in client code, checked-in files, built assets, or browser-visible environment.
- Deployment command or static artifact location and browser support assumptions documented.
## Performance evidence
When draw calls, asset counts, shaders, shadows, or post-processing changed: renderer calls, triangles, geometries, textures; FPS or frame time where available; DPR cap and post/shadow settings; physics engine, timestep, body and collider counts, active sensors, CCD bodies when physics changed. Measure during active gameplay, not the idle view, and compare before/after when performance work was the request.
## Traps
These are the failures that actually reach players:
- Dev server tested, production build shipped untested.
- Static host base path breaks every asset.
- Debug UI visible to players.
- Mobile UI passes a screenshot but the controls do not work.
- Canvas is non-blank but the wrong app is running on that port.
- Physics looks right on screen while collision proxies, sensors, or restart cleanup were never exercised.
- Screenshots are title or idle views rather than active play.
- A capture was labeled with a requested state that its hook never applied, or old inspector reports were reused as current-run evidence.
- A premium claim with no scorecard and no renderer diagnostics.
- A generation API key or a temporary provider URL left in client code.
references/visual-test-harness.md
# Visual Test Harness
Screenshot baselines are worth adding when the visual state is valuable enough to protect and deterministic enough to compare — not for every prototype.
**Add or extend** when the user asked for premium/AAA/showcase/release-ready quality, when HUD or responsive text fit has regressed before, when imported assets must be proven visible in-game, when a signature scene is worth protecting, or when every release needs desktop/mobile active-play evidence. For a narrow fix, protect the affected state; do not re-run unrelated release coverage just because the existing game is premium.
**Defer** for exploratory prototypes, intentionally random scenes that cannot be seeded quickly, and images dominated by particles or noise where masking would hide the actual assertion. If the only question is "is the canvas non-blank", the canvas inspector already answers it. Say which way you went and why.
## States worth capturing
Two to five high-value states: `active-play-desktop` (player, objective, threat, reward, HUD all visible), `active-play-mobile` (same under mobile viewport and touch controls), `pause-or-settings` (layout, safe areas, text fit), `fail-or-retry`, and `hero-asset` (imported or generated asset in real lighting at real camera distance).
Title-only screenshots are only useful when title/menu work is the change.
## Determinism contract
Scaffold games ship a working implementation in `src/game/Game.ts` (`installTestHooks`), typed in `src/vite-env.d.ts`, with a seeded RNG in `src/utils/random.ts`. Keep the hooks real as the game evolves — the template fails loudly when the hooks object is missing, because silent no-op hooks capture a live animating scene and then every rerun diffs. Non-scaffold games implement the same contract:
```ts
window.__THREE_GAME_TEST_HOOKS__ = {
seed: setGameSeed,
async setState(name: string) {
if (!supportedStates.has(name)) throw new Error(`Unknown test state: ${name}`);
await loadAssetsForState(name);
await enterGameState(name);
return { state: name };
},
setPausedForScreenshot: setSimulationPaused,
setReducedMotion: setReducedMotion,
hideDebugUi: hideDebugUi,
};
```
The example's helpers are project-owned implementations, not placeholders to copy as no-ops. `setState` returns `{ state: name }` synchronously or through a Promise only after applying the requested state. Unknown states throw. Await `seed()` and `setState()`, and assert the acknowledgment. Named captures also require `setPausedForScreenshot` to stop simulation/state transitions immediately while rendering continues. The inspector fails explicit state captures when this contract is missing or broken.
Before a baseline: unpause a previously frozen scene, seed randomness, apply and await the state, then immediately freeze simulation so it cannot advance to a different state during capture setup. Stabilize particles and noise, disable camera shake / hitstop / time-dependent post, hide debug overlays and FPS meters, and wait for fonts and rendered frames. These visual hooks must apply their changes while paused, without needing a gameplay tick. The entire preparation phase is bounded, including hooks, fonts, and frames. Use fixed viewport profiles and mask dynamic UI only where the masked area is not part of the acceptance criteria.
## Playwright
Generated games include `tests/visual-regression.template.ts`. Copy it to `tests/visual-regression.spec.ts` when the project is ready:
```bash
npx playwright test tests/visual-regression.spec.ts --update-snapshots
npx playwright test tests/visual-regression.spec.ts
```
Thresholds: low `maxDiffPixelRatio` for stable UI and menu states, slightly higher for WebGL antialiasing and post-processing variation, never so high that a real layout or asset failure slips through.
Run WebGL suites with `workers: 1` and the full `chromium` channel — see `playtest-bot.md`, both matter more than they look.
## Asset visibility
For generated or imported assets, assert the path is loaded or present in diagnostics, screenshot it in active gameplay rather than a showroom, and check scale, orientation, bounds, material readability, and collision proxy. Provider URLs and API keys stay out of baseline paths and client code.
## Motion Evidence
For substantial animated gameplay, rig, or clip changes, capture a short unpaused sequence at the real gameplay camera. Cover at least a complete relevant motion cycle, locomotion start/stop and clip crossfades, plus attack/impact/recovery when combat is present. Record Playwright video (`recordVideo` on the context, close the context to finalize it), the runner's video tool, or a timed frame sequence with animation diagnostics. Do not use the paused screenshot hook for this pass.
Inspect for frozen rigs, collapsing or stretching limbs, foot sliding relative to world displacement, root-motion double application, snapping transitions, looping attacks, and hit/contact events that disagree with the visible motion. Note clip names, durations, mixer action changes, and event times alongside observed defects. Test active movement and interruption through real input, not only a forced pose. Rigid-body-only games need checks of their actual physics/motion, not an invented skeleton audit.
Declare motion files with the current-run capture manifest described in the director's `references/evidence-manifest.md`. File existence alone cannot establish good motion. Keep the detailed visual decision, states covered, commands, paths, thresholds, masks, motion findings, and flake risks in the lead's consolidated evidence artifact.
scripts/inspect-threejs-canvas.mjs
#!/usr/bin/env node
import { mkdir, writeFile } from 'node:fs/promises';
import { existsSync, realpathSync } from 'node:fs';
import { createRequire } from 'node:module';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
// Starting-point render budgets (see threejs-aaa-graphics-builder
// references/technical-art.md). Over-budget rows are reported, not fatal.
const RENDER_BUDGETS = {
desktop: { calls: 300, triangles: 750_000, geometries: 300, textures: 60 },
mobile: { calls: 150, triangles: 300_000, geometries: 200, textures: 40 },
};
const USAGE =
'Usage: inspect-threejs-canvas.mjs [--url URL] [--out DIR] [--mobile] [--wait MS] [--state NAME] [--seed N] [--run-id ID]\n' +
' --state requires setState(NAME) to return or resolve {state: NAME}; unknown states must throw.\n' +
' Named captures also require setPausedForScreenshot: stop simulation immediately, keep rendering.\n' +
' --seed requires a seed(N) hook; both hooks are awaited before capture.\n' +
' Preparation, including --wait, hooks, fonts and render frames, has a 10000ms deadline.\n' +
' State names and run IDs use 1-128 letters, digits, dots, underscores or hyphens, starting with a letter or digit.';
function validateIdentifier(value, flag) {
if (typeof value !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(value)) {
throw new Error(`${flag} must be a safe 1-128 character identifier starting with a letter or digit`);
}
}
export function parseArgs(argv) {
const args = {
url: 'http://127.0.0.1:5188',
out: 'artifacts/canvas-inspection',
mobile: false,
wait: 750,
state: null,
seed: undefined,
runId: null,
help: false,
};
for (let i = 0; i < argv.length; i += 1) {
const value = argv[i];
const takeValue = () => {
const next = argv[++i];
if (typeof next !== 'string' || !next.trim() || next.startsWith('--')) {
throw new Error(`Missing value for ${value}`);
}
return next;
};
if (value === '--url') args.url = takeValue();
else if (value === '--out') args.out = takeValue();
else if (value === '--mobile') args.mobile = true;
else if (value === '--wait') args.wait = Number(takeValue());
else if (value === '--state') args.state = takeValue();
else if (value === '--seed') args.seed = Number(takeValue());
else if (value === '--run-id') args.runId = takeValue();
else if (value === '-h' || value === '--help') args.help = true;
else {
throw new Error(`Unknown argument: ${value}`);
}
}
if (args.state !== null) validateIdentifier(args.state, '--state');
if (args.runId !== null) validateIdentifier(args.runId, '--run-id');
if (args.seed !== undefined && !Number.isSafeInteger(args.seed)) {
throw new Error('--seed must be a safe integer');
}
if (!Number.isFinite(args.wait) || args.wait < 0 || args.wait > 2_147_483_647) {
throw new Error('--wait must be finite non-negative milliseconds within the timer range');
}
if (!['http:', 'https:'].includes(new URL(args.url).protocol)) {
throw new Error('--url must use http or https');
}
return args;
}
export async function loadDependency(name, cwd = process.cwd()) {
const project = path.resolve(cwd);
const resolvers = [createRequire(import.meta.url), createRequire(path.join(project, 'package.json'))];
let missing;
for (const require of resolvers) {
let resolved;
try {
resolved = require.resolve(name);
} catch (error) {
if (error.code !== 'MODULE_NOT_FOUND') throw error;
missing = error;
continue;
}
// Import outside the catch: a broken installed package is not a missing dependency.
const dependency = await import(pathToFileURL(resolved).href);
// createRequire can select a CommonJS entrypoint with only a default export.
return { ...dependency.default, ...dependency };
}
throw new Error(
`Missing inspector dependency "${name}". Install @playwright/test and pngjs in the game project ` +
`(${project}), then run the inspector from that directory.`,
{ cause: missing },
);
}
async function runPreparation(page, { state = null, seed, timeoutMs = 10_000, wait = 0 }, capture) {
if (state !== null) validateIdentifier(state, '--state');
if (seed !== undefined && !Number.isSafeInteger(seed)) throw new Error('--seed must be a safe integer');
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > 2_147_483_647) {
throw new Error('Preparation timeout must be positive milliseconds within the timer range');
}
if (!Number.isFinite(wait) || wait < 0 || wait > 2_147_483_647) {
throw new Error('--wait must be finite non-negative milliseconds within the timer range');
}
if (!capture && state === null && seed === undefined) return { requestedState: null, appliedState: null };
const message = `${capture ? 'Capture preparation' : 'Test hooks'} did not finish within ${timeoutMs}ms`;
const deadline = Date.now() + timeoutMs;
let hostTimer;
try {
// A host-side deadline also covers a stalled evaluate call or blocked browser event loop.
return await Promise.race([
new Promise((_, reject) => {
hostTimer = setTimeout(() => reject(new Error(message)), timeoutMs);
}),
page.evaluate(async ({ state, seed, capture, wait, deadline, message }) => {
const hooks = window.__THREE_GAME_TEST_HOOKS__;
const namedCapture = capture && state !== null;
if ((state !== null || seed !== undefined) && !hooks) {
throw new Error('--state/--seed requires window.__THREE_GAME_TEST_HOOKS__');
}
if (state !== null && typeof hooks.setState !== 'function') {
throw new Error('--state requires a setState function');
}
if (seed !== undefined && typeof hooks.seed !== 'function') {
throw new Error('--seed requires a seed function');
}
if (namedCapture && typeof hooks.setPausedForScreenshot !== 'function') {
throw new Error('--state capture requires a setPausedForScreenshot function that stops simulation immediately and keeps rendering');
}
let expired = false;
let timer;
let settleTimer;
let frameId;
const checkDeadline = () => {
if (expired || Date.now() >= deadline) throw new Error(message);
};
const step = async (operation) => {
checkDeadline();
const result = await operation();
checkDeadline();
return result;
};
try {
return await Promise.race([
new Promise((_, reject) => {
timer = setTimeout(() => {
expired = true;
reject(new Error(message));
}, Math.max(0, deadline - Date.now()));
}),
(async () => {
if (namedCapture) await step(() => hooks.setPausedForScreenshot(false));
if (seed !== undefined) await step(() => hooks.seed(seed));
if (state !== null) {
const acknowledgement = await step(() => hooks.setState(state));
if (!acknowledgement || typeof acknowledgement !== 'object' ||
Array.isArray(acknowledgement) || acknowledgement.state !== state) {
throw new Error(`setState(${JSON.stringify(state)}) must acknowledge {state: ${JSON.stringify(state)}}`);
}
// Freeze in this evaluation immediately after setup, before any settling or render wait.
if (namedCapture) await step(() => hooks.setPausedForScreenshot(true));
}
if (capture) {
if (namedCapture && typeof hooks.setReducedMotion === 'function') {
await step(() => hooks.setReducedMotion(true));
}
if (namedCapture && typeof hooks.hideDebugUi === 'function') {
await step(() => hooks.hideDebugUi(true));
}
if (wait > 0) await step(() => new Promise((resolve) => { settleTimer = setTimeout(resolve, wait); }));
if (document.fonts) await step(() => document.fonts.ready);
await step(() => new Promise((resolve) => {
frameId = requestAnimationFrame(() => {
if (!expired) frameId = requestAnimationFrame(resolve);
});
}));
}
return { requestedState: state, appliedState: state };
})(),
]);
} finally {
expired = true;
clearTimeout(timer);
clearTimeout(settleTimer);
if (frameId !== undefined) cancelAnimationFrame(frameId);
}
}, { state, seed, capture, wait, deadline, message }),
]);
} finally {
clearTimeout(hostTimer);
}
}
export async function applyTestHooks(page, args = {}) {
return runPreparation(page, args, false);
}
export async function prepareCapture(page, args = {}) {
return runPreparation(page, args, true);
}
const round = (value, digits) => Number(value.toFixed(digits));
// Objective pixel statistics used as "Measured Evidence" in the visual
// scorecard. Computed on a coarse luminance grid so cost stays trivial.
function computePixelMetrics(png) {
const stepX = Math.max(1, Math.floor(png.width / 160));
const stepY = Math.max(1, Math.floor(png.height / 90));
const cols = Math.floor(png.width / stepX);
const rows = Math.floor(png.height / stepY);
const luminance = new Float64Array(cols * rows);
const bucketCounts = new Map();
let samples = 0;
for (let gy = 0; gy < rows; gy += 1) {
for (let gx = 0; gx < cols; gx += 1) {
const offset = ((gy * stepY) * png.width + gx * stepX) * 4;
const r = png.data[offset];
const g = png.data[offset + 1];
const b = png.data[offset + 2];
luminance[gy * cols + gx] = 0.2126 * r + 0.7152 * g + 0.0722 * b;
const key = `${r >> 4},${g >> 4},${b >> 4}`;
bucketCounts.set(key, (bucketCounts.get(key) ?? 0) + 1);
samples += 1;
}
}
const sorted = Array.from(luminance).sort((a, b) => a - b);
const mean = sorted.reduce((sum, v) => sum + v, 0) / sorted.length;
const p5 = sorted[Math.floor(sorted.length * 0.05)];
const p95 = sorted[Math.floor(sorted.length * 0.95)];
let entropy = 0;
let dominant = 0;
for (const count of bucketCounts.values()) {
const p = count / samples;
entropy -= p * Math.log2(p);
dominant = Math.max(dominant, count);
}
let edges = 0;
let checked = 0;
for (let gy = 0; gy < rows - 1; gy += 1) {
for (let gx = 0; gx < cols - 1; gx += 1) {
const i = gy * cols + gx;
const dx = Math.abs(luminance[i] - luminance[i + 1]);
const dy = Math.abs(luminance[i] - luminance[i + cols]);
if (Math.max(dx, dy) > 12) edges += 1;
checked += 1;
}
}
return {
colorBuckets: bucketCounts.size,
colorEntropyBits: round(entropy, 2),
edgeDensity: round(edges / checked, 3),
luminance: {
mean: round(mean, 1),
p5: round(p5, 1),
p95: round(p95, 1),
contrast: round(p95 - p5, 1),
},
dominantColorShare: round(dominant / samples, 3),
nonBackgroundShare: round(1 - dominant / samples, 3),
};
}
// Playwright's default headless is chromium_headless_shell, which ships no GPU
// backend and silently falls back to SwiftShader (CPU). Every frame-time and FPS
// number measured that way is software-rendered fiction. channel:'chromium' runs
// the full Chromium build in new headless mode against the real GPU.
async function launchBrowser() {
const { chromium } = await loadDependency('@playwright/test');
try {
return await chromium.launch({ channel: 'chromium' });
} catch {
console.error(
'warning: channel:"chromium" is unavailable, falling back to the bundled headless shell.\n' +
' Rendering will be software (SwiftShader) and any FPS/frame-time evidence is invalid.\n' +
' Fix with: npx playwright install chromium',
);
return chromium.launch();
}
}
// Records which GPU actually rasterized the run, so a software fallback can never
// masquerade as performance evidence again. Reuses the game's own context when it
// is WebGL rather than allocating a second one.
async function readGpuInfo(page) {
const info = await page.evaluate(() => {
const canvas = document.querySelector('canvas');
if (!canvas) return null;
let gl = null;
try {
gl = canvas.getContext('webgl2') ?? canvas.getContext('webgl');
} catch {
gl = null;
}
if (!gl) return null;
const debug = gl.getExtension('WEBGL_debug_renderer_info');
return {
renderer: debug ? gl.getParameter(debug.UNMASKED_RENDERER_WEBGL) : gl.getParameter(gl.RENDERER),
vendor: debug ? gl.getParameter(debug.UNMASKED_VENDOR_WEBGL) : gl.getParameter(gl.VENDOR),
};
});
if (!info?.renderer) {
return { renderer: null, vendor: null, softwareRendered: null };
}
return {
...info,
softwareRendered: /swiftshader|llvmpipe|software|basic render/i.test(info.renderer),
};
}
function checkRenderBudget(renderer, mode) {
if (!renderer) return null;
const budget = RENDER_BUDGETS[mode];
const rows = Object.entries(budget).map(([metric, limit]) => {
const actual = renderer[metric];
return {
metric,
actual: typeof actual === 'number' ? actual : null,
limit,
ok: typeof actual === 'number' ? actual <= limit : null,
};
});
return {
tier: mode,
note: 'starting-point budget; adjust per game and document overrides',
rows,
withinBudget: rows.every((row) => row.ok !== false),
};
}
async function sampleCanvas(page, mode) {
const { PNG } = await loadDependency('pngjs');
const locator = page.locator('canvas').first();
const rect = await locator.boundingBox();
if (!rect || rect.width < 32 || rect.height < 32) {
return { ok: false, reason: 'canvas-too-small', rect };
}
const buffer = await locator.screenshot();
const png = PNG.sync.read(buffer);
let min = 255;
let max = 0;
let alphaPixels = 0;
const colors = new Set();
const stride = Math.max(1, Math.floor((png.width * png.height) / 4096));
for (let pixel = 0; pixel < png.width * png.height; pixel += stride) {
const offset = pixel * 4;
const r = png.data[offset];
const g = png.data[offset + 1];
const b = png.data[offset + 2];
const a = png.data[offset + 3];
min = Math.min(min, r, g, b);
max = Math.max(max, r, g, b);
if (a > 0) alphaPixels += 1;
colors.add(`${r >> 4},${g >> 4},${b >> 4},${a >> 6}`);
}
const variance = max - min;
const diagnostics = await page.evaluate(() => {
const canvas = document.querySelector('canvas');
return {
drawingBuffer: canvas
? { width: canvas.width, height: canvas.height }
: null,
game: window.__THREE_GAME_DIAGNOSTICS__ ?? null,
};
});
const ok = alphaPixels > 256 && (variance > 8 || colors.size > 3);
return {
ok,
reason: ok ? 'nonblank' : 'low-variance',
rect,
drawingBuffer: diagnostics.drawingBuffer,
alphaPixels,
variance,
colorBuckets: colors.size,
metrics: computePixelMetrics(png),
renderBudget: checkRenderBudget(diagnostics.game?.renderer ?? null, mode),
diagnostics: diagnostics.game,
};
}
export async function inspectPage(page, args) {
const consoleErrors = [];
const pageErrors = [];
const mode = args.mobile ? 'mobile' : 'desktop';
const baseName = args.state ? `${mode}-${args.state}` : mode;
const report = {
url: args.url,
mode,
state: null,
requestedState: args.state ?? null,
appliedState: null,
runId: args.runId ?? null,
seed: args.seed ?? null,
screenshotPath: null,
gpu: null,
result: null,
consoleErrors,
pageErrors,
};
page.on('console', (message) => {
if (message.type() === 'error') consoleErrors.push(message.text());
});
page.on('pageerror', (error) => pageErrors.push(error.message));
try {
await page.goto(args.url, { waitUntil: 'networkidle' });
await page.waitForSelector('canvas', { state: 'visible', timeout: 10_000 });
const applied = await prepareCapture(page, args);
report.state = applied.appliedState;
report.appliedState = applied.appliedState;
report.gpu = await readGpuInfo(page);
report.result = await sampleCanvas(page, mode);
const screenshotPath = path.join(args.out, `${baseName}.png`);
await page.screenshot({ path: screenshotPath, fullPage: true });
report.screenshotPath = screenshotPath;
if (report.gpu.softwareRendered) {
console.error(
`warning: this run rasterized on ${report.gpu.renderer} (software). Pixel and budget ` +
'checks remain valid; any FPS or frame-time reading from it does not.',
);
}
} catch (error) {
report.result = { ok: false, reason: 'capture-failed', error: error instanceof Error ? error.message : String(error) };
}
return report;
}
async function main() {
const args = parseArgs(process.argv.slice(2));
if (args.help) {
console.log(USAGE);
return;
}
await mkdir(args.out, { recursive: true });
const { devices } = await loadDependency('@playwright/test');
const browser = await launchBrowser();
let report;
try {
const context = await browser.newContext(args.mobile
? { ...devices['iPhone 13'], userAgent: undefined }
: { viewport: { width: 1280, height: 720 }, deviceScaleFactor: 1 });
report = await inspectPage(await context.newPage(), args);
} finally {
await browser.close();
}
const baseName = args.state ? `${report.mode}-${args.state}` : report.mode;
await writeFile(path.join(args.out, `${baseName}.json`), `${JSON.stringify(report, null, 2)}\n`);
console.log(JSON.stringify(report, null, 2));
if (!report.result.ok || report.consoleErrors.length > 0 || report.pageErrors.length > 0) {
process.exitCode = 1;
}
}
if (process.argv[1] && existsSync(process.argv[1]) &&
import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
}
SKILL.md
---
name: threejs-qa-release
description: "Verify and release Three.js browser games: playtest QA, automated bot playtests, mobile and responsive checks, production builds, static-hosting base paths, debug gating, bundle review, screenshots, visual regression baselines, canvas-pixel inspection with measured metrics, and release risk reports."
---
# Three.js QA Release
Prove the game works the way a player will meet it, then prepare a shippable build with its known risks.
Resolve `<this-skill-dir>` and local references from the actual loaded skill file; resolve sibling skills beside it before using runner-discovered alternatives. Run the inspector from the game project with its npm dependencies installed.
## References
| File | Read it when |
| --- | --- |
| `references/release-checks.md` | mobile verification, production release, performance evidence, or release-failure traps |
| `references/visual-test-harness.md` | screenshot baselines, visual regression, UI or generated-asset regression protection |
| `references/playtest-bot.md` | release-ready gameplay claims, difficulty and fairness checks, or a loop never driven by scripted input |
## QA pass
For a complete game use the full pass below. For narrow edits select checks covering the affected behavior, states, and target viewports. Reuse valid specialist evidence from the same code revision; the lead owns one consolidated pass. Repeat only after relevant changes, failures, or unresolved concerns. An explicit desktop-only scope does not require adding mobile gameplay.
1. Install dependencies, run build and typecheck, start the dev or preview server.
2. Open the browser target and capture console, page, and network errors.
3. Confirm non-blank, visually varied canvas pixels.
4. Capture active play on each target viewport (desktop and mobile by default), not just the title screen.
5. Exercise the main input, objective progression, fail and retry, and whatever changed most recently.
6. Check HUD text fit, safe areas, touch targets, and responsive layout.
7. When audio changed: user-gesture unlock, SFX triggers, ambience loop start and stop, pause and restart cleanup, mute and volume, decode errors.
8. Decide on a visual test harness. For premium, release-ready, UI-heavy, or generated-asset work a harness is usually worth it; say so either way.
9. Run the bot playtest (`tests/bot-playtest.template.ts` in scaffold games) for release-ready gameplay claims and report its metrics JSON.
10. When animation changed, capture a short unpaused sequence and inspect locomotion, clip transitions, feet, rig deformation, and attack/contact timing using `references/visual-test-harness.md`.
Screenshots alone do not cover gameplay changes.
## Canvas inspector
```bash
node <this-skill-dir>/scripts/inspect-threejs-canvas.mjs --url http://127.0.0.1:5188 --state active-play --run-id pass-1
```
`--mobile` selects mobile emulation. `--state <name>` (with optional `--seed <n>`) awaits the game's test hooks before capture. The state hook must acknowledge `{ state: name }`, and `setPausedForScreenshot` must stop simulation immediately while rendering continues. Capture freezes the acknowledged state before settling; the complete preparation phase has a timeout. Missing hooks, no-op results, unknown states, and mismatched acknowledgements fail. Reports retain `state` and add `requestedState`, `appliedState`, and `runId`. Scaffold games have their own copy plus `npm run inspect:canvas`.
Use a fresh `--run-id` for each verification pass and a separate `--out` directory. Declare expected viewport/state pairs before capture in the director's `references/evidence-manifest.md` format, then run its checker with `--manifest`. Include all requested states; do not remove a failing slot to make the manifest pass. Omitting `--state` performs only a current-view canvas check.
The JSON carries a `metrics` block (color entropy, edge density, luminance contrast, dominant-color share) and a `renderBudget` comparison against tier budgets. These are the Measured Evidence for the visual scorecard in `threejs-aaa-graphics-builder/references/visual-scorecard.md`; over-budget rows need a documented tradeoff, and blank-canvas or error conditions exit non-zero.
## Release pass
Inspect package scripts, Vite config, base path, and public assets → gate debug UI, logging, and test helpers → run the production build and preview it on a static server → check the built output on target viewports → review bundle and large assets → document the deploy command, host assumptions, and residual risks.
## Report
Lead with the result and unresolved defects. Put the detailed commands, manifest, captures, motion evidence, controls exercised, issues fixed, and deployment notes in the project's evidence report. Include the harness decision and bot metrics when in scope. Return the artifact path to the lead; passing pixels and acknowledged state hooks do not establish aesthetic quality or successful gameplay by themselves.