How Helen Gets Made A making-of, not the live show Watch her live

Court · the 3D website · quality checks

Testing a WebGL site without a GPU: the checks that run before a deploy

A type-check, a three.js scene built and walked under Node, two headless boots under a watchdog, and one headed capture on a real GPU; the first two block the deploy.

The room they test is on the shop layout page; the deploy they gate is on the hosting page.

Helen, the reference every screenshot is scored against
Point one of the twelve-point bar: it has to be recognisably her. Everything after that can be scripted.

A WebGL site has a testing problem: the thing that matters is a rendered frame, and the machine running the tests usually cannot render one. The repository behind helenthecatlive.com answers that with four checks that need no GPU and one that does, and refuses to deploy unless the first two pass. Each is a short script. All of them are here, with what they caught.

The build is a check

package.json — scripts
{
  "build": "tsc --noEmit && vite build",
  "check-shop": "node scripts/check-shop-room.mjs",
  "qa": "node scripts/qa-shots.mjs",
  "deploy": "bash scripts/deploy.sh"
}

tsc --noEmit runs before Vite on every build, so a type error stops the deploy before a byte is uploaded. Vite then bundles the twenty-five pages and runs the pruneDist() plugin, and the deploy script refuses to continue if dist/ref or dist/clips still exists afterwards: the 83 MB of reference photography that once shipped by accident cannot come back silently. That plugin is on the three.js build page; the deploy script and its preflight are on the hosting page.

The room check: three.js under Node, no browser

The shop room is built by an ordinary function that takes a THREE.Scene and a decal manifest and returns the array of sellable objects. Nothing in it touches a canvas, so it runs under Node. The check bundles that function with esbuild, executes it, and walks the scene graph it produced.

scripts/check-shop-room.mjs — the assertions (the bundled entry, abridged)
import * as THREE from "three";
import { buildShopRoom } from "./src/shopRoom";
import { DECOR_KEYS } from "./src/shopCourt";
import manifest from "./public/merch/decal/decals.json";
import catalog from "./public/data/catalog.json";

const decals = new Map(Object.entries(manifest));
const scene = new THREE.Scene();
const pickables = buildShopRoom(scene, decals);

let fail = 0;
// 1. every catalog product stands somewhere in the room
const bySlug = new Map();
for (const p of pickables) bySlug.set(p.userData.slug, (bySlug.get(p.userData.slug) || 0) + 1);
for (const c of catalog.map((c) => c.slug)) if (!bySlug.get(c)) { say("MISSING FROM ROOM: " + c); fail++; }
// 2. nothing in the room sells a slug the catalog does not have
for (const [slug] of bySlug) if (!catalogSlugs.includes(slug)) { say("NOT IN CATALOG: " + slug); fail++; }
// 3. every decal key on any mesh resolves to artwork or a known decor portrait
scene.traverse((o) => { const k = o.userData.decalKey; if (k) keys.add(k); });
for (const k of keys) if (!decals.has(k) && !DECOR_KEYS.includes(k)) { say("UNRESOLVABLE DECAL KEY: " + k); fail++; }
for (const k of decals.keys()) if (!keys.has(k)) { say("MANIFEST ENTRY UNUSED: " + k); fail++; }
// 4. the bug this file exists for: an object tagged as one product wearing another's art
for (const p of pickables) {
  const worn = new Set();
  p.traverse((o) => { if (o.userData.decalKey) worn.add(o.userData.decalKey); });
  for (const k of worn) if (k !== p.userData.slug) { say("MISLABELLED: object sells " + p.userData.slug + " but displays " + k); fail++; }
  if (worn.size === 0) { say("NO ARTWORK: " + p.userData.slug); fail++; }
}
// 5. set dressing is never clickable
for (const p of pickables) if (p.userData.decor) { say("DECOR IS CLICKABLE"); fail++; }
// 6. every manifest file exists on disk
for (const info of decals.values()) if (!existsSync(join("public", info.file))) fail++;
process.exit(fail ? 1 : 0);

Five things about that script are worth more than its forty lines suggest. It imports DECOR_KEYS from the scene module rather than keeping its own copy, so the list cannot drift. It fails on an unused manifest entry as well as a missing one, which catches a decal that was renamed on one side only. It checks the artwork on each object against the product the object sells, which is the bug two wall frames had for months: a photograph of the cat, tagged as a print that looked nothing like it, so the click opened the wrong product. It runs as part of deploy.sh, after the build and before the archive is made, so a room that disagrees with the catalog never leaves the machine. And its first version was worthless: the decor check was written in a way that could not fail and it never tested the mislabelling it was written for. That was found in review, and the fix was verified by breaking it deliberately: injecting one mislabelled tee produces MISLABELLED: object sells royal-portrait-tee but displays monogram-tee and FAIL (2), reverting returns PASS. A check that has never been seen to fail has not been seen to work.

Output on the current room
$ npm run check-shop
pickable objects: 20
distinct products: 16
  royal-portrait-print       x1
  softly-gold-frame          x1
  rescued-queen-print        x1
  royal-portrait-mug         x1
  ...
  royal-crest-tee            x3
  royal-crest-tote           x2
decorative frames (not clickable): 6
PASS

Screenshots at two widths, with a software GPU

scripts/qa-shots.mjs — boot, wait, read the canvas (abridged)
const url = process.env.QA_URL || "http://127.0.0.1:5177/";
const sizes = [
  { name: "desktop", w: 1440, h: 900 },
  { name: "mobile", w: 390, h: 844 },
];
const watchdog = setTimeout(() => { console.error("qa-shots watchdog: exiting"); process.exit(2); }, 50000);
browser = await chromium.launch({ args: ["--use-gl=swiftshader"], timeout: 15000 });

for (const size of sizes) {
  const page = await browser.newPage({ viewport: { width: size.w, height: size.h } });
  const errors = [];
  page.on("pageerror", (err) => errors.push(String(err.message || err)));
  await page.goto(url, { waitUntil: "domcontentloaded", timeout: 15000 });

  let booted = false;
  try {
    await page.waitForFunction(() => {
      const overlay = document.querySelector("#overlay");
      const status = document.querySelector("[data-status]")?.textContent || "";
      return Boolean(overlay?.classList.contains("is-booted") && !status.includes("needs WebGL"));
    }, { timeout: 12000 });
    booted = true;
  } catch {
    errors.push("boot-timeout");
  }

  // preserveDrawingBuffer is off, so the canvas must be read in the frame it is drawn.
  const dataUrl = await Promise.race([
    page.evaluate(() => document.querySelector("canvas#court")?.toDataURL("image/png") || ""),
    new Promise((_, reject) => setTimeout(() => reject(new Error("toDataURL-timeout")), 2500)),
  ]);
  // ... write <size>.png, record status text, bytes, errors
}
writeFileSync("qa-output/score.json", JSON.stringify({ ts, url, results }, null, 2));
qa-output/score.json — 2 September 2026, against the local preview build
{
  "ts": "2026-09-02T01:54:42.491Z",
  "url": "http://127.0.0.1:4177/",
  "results": [
    { "size": "desktop", "status": "Pick a camera. The painting stays.", "bytes": 28701,
      "errors": ["toDataURL-timeout"], "booted": true },
    { "size": "mobile",  "status": "The court is being prepared.",        "bytes": 1572,
      "errors": [], "booted": true }
  ]
}

Read that result the way the script's author would. Desktop booted, the status line is the court's real one, and the canvas read timed out, which with a software renderer and preserveDrawingBuffer: false means the read did not land in a drawn frame; the 28 kB PNG it did get is the poster, not the scene. Mobile booted into the phone shell (1.5 kB is a blank canvas, as it should be) with the splash line still showing. A watchdog kills the whole run at fifty seconds so a hung SwiftShader cannot hang the deploy. This script proves that both shells reach is-booted without a page error; it does not prove the room looks right, and the repository does not pretend it does.

The check that needs a GPU

scripts/capture-court-poster.mjs launches Chromium headed, at 1600 × 1000, on a machine with a real GPU, waits for [court] ready on the console, and reads the canvas inside a requestAnimationFrame so the read lands in the frame the court draws. The output is the poster the home page paints under the chrome before WebGL is up, resized to a 1600-pixel WebP at quality 78 and a 48-pixel blurred placeholder. It is a QA step disguised as an asset pipeline: if the seated court does not render on a real GPU, there is no poster and the build is visibly wrong before anyone deploys it. The repository's performance notes from 22 August say, of the dither shader, "it typechecks and bundles, but I had no GPU here to render a frame against"; the poster capture is the step that answers that sentence for every later change.

The bar the screenshots are scored against

QUALITY_BAR.md is a twelve-point list, and the rule at its head is that a slice of work is not done until a screenshot fails fewer than two of the twelve. Paraphrased: it is recognisably this cat, not a generic one; a court, not a game; the live cameras are one wall screen you switch; the light is motivated (candle-warm key, gold bounce); materials feel heavy, gold is metal and plastic sheen is a fail; the camera has manners; the type matches this site's; mobile holds one primary action at 30 fps with a pixel-ratio cap and a fallback if WebGL dies; a stale feed is standby, never a fake live badge; the shop links to the real collection; an empty court still looks finished, with no missing-texture purple, no T-pose, no debug axes; and proof, not vibes: the QA script writes the angles and lists each miss. Several of those became code. The camera clamps on the layout page are point six; the helen-cam-status event that turns a dock button to standby on a failed refresh is point nine; the phone shell is point eight.

Where the assistants were

  1. Type-check before you bundle. tsc --noEmit && vite build. A type error is the cheapest failure there is; make it the first.
  2. Put structure in a function that takes a scene and returns your pickables. Then a Node script can build it, walk it and assert on it with no canvas. Assert both directions of every relationship (every product in the room, every room object in the catalog; every decal key resolved, every manifest entry used).
  3. Break the check once, deliberately, and keep the transcript. A check that has never failed has not been shown to work. Inject one wrong tag, see the message, revert.
  4. Boot both shells headless at two widths, under a watchdog. Wait for your own is-booted class and collect pageerror; do not trust a canvas read from a software renderer.
  5. Render the one frame that matters on a real GPU, and ship it. A poster capture makes a broken scene visible before deploy and gives the page something to paint while WebGL boots.
  6. Chain them in the deploy script and refuse on any non-zero exit. Build, room check, then archive; verify the public URLs after the swap; roll back on a miss.

The deploy these checks gate is on the hosting page. The room the check walks is the shop layout; the numbers the load script produced are the performance ledger. Plain version: the 3D website.