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

Court · the 3D website · page architecture

One renderer, twenty-five pages: how the HTML shell and the three.js scene share a page

Every page runs one shared boot script; three pages also run a script that decides, before any heavy code is requested, whether to open a WebGL room.

The scene's internals are on the three.js build page; the bytes each decision costs are on the performance page.

Court Cam: the overhead view of the real sitting room, one of the four textures the scene refreshes
One of the four live frames. The page owns the button that names it; the scene owns the texture it becomes.

Every page on helenthecatlive.com is a static HTML file with two module scripts at the bottom. One of them, src/site/boot.ts, runs on all twenty-five pages and owns the chrome: the header, the bag drawer, the newsletter form, the JSON-LD graph, the YouTube facades. The other is page-specific and, on the three pages that have a room, is the only thing that knows WebGL exists. What follows is the seam between the two: how a page decides whether to open the scene, what the HTML shows while it waits, what happens when it fails, and how the scene and the page talk to each other once it is up. The scene's own internals (loading, textures, the quality loop) are on the three.js build page.

Twenty-five entries, one config

vite.config.ts — the pages map (abridged) and the build block
const pages = {
  main: resolve(__dirname, "index.html"),
  about: resolve(__dirname, "about/index.html"),
  shop: resolve(__dirname, "shop/index.html"),
  youtube: resolve(__dirname, "youtube/index.html"),
  checkout: resolve(__dirname, "checkout/index.html"),
  success: resolve(__dirname, "success/index.html"),
  notFound: resolve(__dirname, "404.html"),
  build: resolve(__dirname, "build/index.html"),
  buildCourt: resolve(__dirname, "build/3d-court/index.html"),
  liveCams: resolve(__dirname, "live-cat-cams/index.html"),
  press: resolve(__dirname, "press/index.html"),
  // ... 25 in all
};

export default defineConfig({
  plugins: [pruneDist(), preloadThreeChunk(), injectAnalytics()],
  build: {
    target: "es2022",
    sourcemap: false,
    cssMinify: true,
    rollupOptions: {
      input: pages,
      output: { manualChunks(id) { /* three / three-ltc split */ } },
    },
  },
});

Each entry is an ordinary HTML file in the repository with <script type="module" src="/src/..."> tags, and Vite rewrites those into hashed chunks under /assets/. Because every page imports boot.ts, the shared code lands in a handful of small chunks that every page fetches once and then has cached for a year: on the current build they are boot (22 kB), courtHints, quality, escape and bag (under 3 kB each). The three.js runtime is not among them. It is reached only by a dynamic import() that the page-specific script decides whether to make.

The gate: one function, used in two places

src/site/quality.ts — isPhoneShell()
export function isPhoneShell() {
  if (window.matchMedia("(min-width: 900px) and (pointer: fine)").matches) return false;
  const saveData = Boolean((navigator as Navigator & { connection?: { saveData?: boolean } }).connection?.saveData);
  const narrow = window.innerWidth < 820;
  const coarse = window.matchMedia("(pointer: coarse)").matches;
  return narrow || coarse || saveData;
}
src/main.ts — the decision, at the top of the file
const canvas = document.querySelector("canvas#court") as HTMLCanvasElement | null;
const overlay = document.querySelector("#overlay") as HTMLElement | null;
if (!canvas || !overlay) throw new Error("court shell missing");
const phone = isPhoneShell();
if (phone) document.body.classList.add("is-phone-shell");
const courtMod = phone ? null : import("./court");

A fine pointer at 900 px or wider is a desktop, full stop. Anything else is a phone if the window is under 820 px, or the pointer is coarse, or the browser has asked for reduced data. On a phone courtMod is null and the file that imports three.js is never requested; the is-phone-shell class on <body> hides the canvas and shows the HTML camera frame. The same test is inlined into the built HTML by the preloadThreeChunk() plugin so the <link rel="modulepreload"> for the three.js chunks is only added on machines that will use them. Two copies of one rule is the price of running one of them before any module has loaded; if they ever disagree, a phone preloads 283 kB of compressed JavaScript it will never execute.

What the overlay is doing while the scene boots

The #overlay element sits over the canvas and carries everything that is HTML: the splash card, the top bar, the camera dock, the title block, the inspect panel on the shop, the fallback camera grid. Its class list is the state machine.

Classes on #overlay and body, and who sets them
ClassMeaning
body.is-phone-shellSet synchronously before anything loads. Canvas hidden, camera frame shown, room never requested.
#overlay.is-readySet by createCourt() after the first frame is rendered. The CSS fades the poster image out at this point: body.court-page:has(#overlay.is-ready) .court-poster { opacity: 0 }.
#overlay.is-bootedSet by dismissSplash() in main.ts once the scene resolved and the splash has been on screen for its minimum. Hides the splash card, shows the title block.
#overlay.is-fallbackSet on a thrown error or on the fail timer. Shows the HTML camera grid and a status line that says the room did not load and the cameras still work.
html.reducedprefers-reduced-motion. No splash hold, no camera tween, no hover scale; the scene jumps to each position.
src/main.ts — the boot block
const seenCourt = !reduced && sessionStorage.getItem("htcl-court-seen") === "1";
// Home ships a still of the court under the chrome, so nothing waits on WebGL:
// the page is readable at first paint and the room fades in whenever it is ready.
const posterFirst = document.body.dataset.page === "home" && !!document.querySelector("[data-court-poster]");
const SPLASH_MIN_MS = phone || reduced || seenCourt || posterFirst ? 0 : 400;
const COURT_FAIL_MS = posterFirst ? 45000 : 25000;

if (phone) {
  dismissSplash();
} else {
  if (posterFirst) dismissSplash();
  void (async () => {
    const t0 = performance.now();
    const failTimer = window.setTimeout(() => {
      if (api) return;
      console.warn("[court] timeout", Math.round(performance.now() - t0));
      showCourtFallback(new Error("court-timeout"));
    }, COURT_FAIL_MS);
    try {
      if (!courtMod) return;
      const { createCourt } = await courtMod;
      api = await createCourt(canvas, overlayEl);
      await splashHold;
      window.clearTimeout(failTimer);
      dismissSplash();
      api.arrive();
      console.log("[court] ready", Math.round(performance.now() - t0));
    } catch (err) {
      window.clearTimeout(failTimer);
      console.error("[court] fail", err);
      await splashHold;
      showCourtFallback(err);
    }
  })();
}

Three timings are in that block. A first-time desktop visitor sees the splash card for at least 400 ms so it does not flash; a returning visitor in the same session (sessionStorage) gets no hold. The home page since 2 September 2026 is poster-first: a 1600-pixel WebP screenshot of the seated court (109 kB, captured from a real GPU by scripts/capture-court-poster.mjs) is painted under the chrome at first paint, the splash is dismissed immediately, and the live room fades in over the still whenever it is ready. That is why the home page's fail timer is 45 seconds and every other room's is 25: with a picture already on screen there is no rush to give up. The commit that did it (f110429, 2 September) also moved shadows and area lights behind a discrete-GPU check and added the boot phase timers below.

src/court.ts — the phases createCourt() logs on the way up
phase("renderer");        // WebGLRenderer, scaler, colour space, tone mapping
await yieldFrame();
phase("lights+feeds");    // lights built, four camera textures created (poster only)
buildSittingRoom(scene, { courtSeat: !isAbout });
phase("sittingRoom");
// ... screen, dust, controls
phase("screen+dust");
phase("pre-env");
upgradeEnv();             // PMREM environment, before compile
phase("env");
await compileScene(renderer, scene, camera, "court");
phase("compiled");
renderer.render(scene, camera);
phase("first-render");
tick();
overlay.classList.add("is-ready");
void decorateCourt();     // portraits, then a second compile pass
if (!isAbout) window.setTimeout(() => startCamFeeds(handles), 120);
void helenMeshPromise.then((mesh) => { if (mesh) bindHelen(mesh); });

yieldFrame() is a setTimeout(0), not a requestAnimationFrame, so the boot still advances in a background tab. The order is the point: the room is standing, lit, compiled and drawn before the cameras start fetching and before the cat is bound, so the first thing a visitor sees is complete and the two things that arrive over the network arrive into a finished scene. Why the environment is built before the compile pass, and what happened when it was not, is on the three.js build page.

How the scene and the page talk

The scene module never touches the DOM outside its canvas, and the page never reaches into the scene. They exchange CustomEvents on window, and the page owns every piece of text.

The events, and which side fires them
EventFrom → to, and payload
helen-cam-statusScene → page. { id, live } per camera, fired when a texture is created and whenever a refresh succeeds or fails. The page flips the dock button's data-live and rewrites the status line ("3 cameras are live", "The cameras are resting right now").
helen-shop-hoverScene → page. { slug, x, y } or { slug: null }. The page draws the price tag as HTML at that screen point; the scene re-fires it every frame the hover persists because the camera drifts.
helen-shop-pickScene → page. { slug } on a click that was not a drag. The page opens the inspect dialog with the catalog entry.
htcl-bagPage → page. Fired after every localStorage write to the bag so the drawer and the header count repaint.
api.arrive(), api.home(), api.pause(), api.resume()Page → scene, the only direction that uses a function call. The object createCourt() / createShop() resolves to.

Keeping the text on the page side is what makes the phone shell and the fallback grid possible without a second copy of anything: the same catalog, the same status strings and the same dock buttons render whether or not a scene is behind them.

Power: stop rendering when nobody is looking

src/shopMain.ts — the room stops when it is covered or hidden
function syncShopPower() {
  if (!api) return;
  const covered = window.scrollY > window.innerHeight * 0.92;
  if (document.hidden || covered) api.pause();
  else api.resume();
}
document.addEventListener("visibilitychange", syncShopPower);
window.addEventListener("scroll", syncShopPower, { passive: true });

The shop page has a plain product list under the room. Scroll past 92 % of the viewport and the render loop stops; come back and it resumes. The journal stages on the build pages go further: they are still lifes, so each one keeps a count of frames it is owed, gets two frames on boot, resize, visibility and asset load, and the loop exits when every count is zero.

src/site/journalStage.ts — render on demand
function invalidate(stage?: LiveStage, frames = 2) {
  for (const s of stage ? [stage] : stages) s.dirty = Math.max(s.dirty, frames);
  if (!raf) raf = requestAnimationFrame(tick);
}

// Textures and models resolve after a stage is built; repaint when they land.
const priorOnLoad = THREE.DefaultLoadingManager.onLoad;
THREE.DefaultLoadingManager.onLoad = () => {
  priorOnLoad?.();
  invalidate();
};

function tick() {
  let owed = 0;
  for (const stage of stages) {
    if (!stage.live || stage.dirty <= 0) continue;
    stage.renderer.render(stage.scene, stage.camera);
    stage.dirty -= 1;
    owed += stage.dirty;
  }
  raf = owed > 0 ? requestAnimationFrame(tick) : 0;
}

Stages are built lazily by an IntersectionObserver the first time they scroll into view, and boot.ts only imports the stage module at all when the page contains a canvas[data-stage]. Six pages do: the home page's story section, the four build pages, and the plain cameras page. The earlier version re-rendered every visible stage on every animation frame, which on a laptop meant the fan running for a page of static pictures.

The first-visit lesson

src/site/courtHints.ts mounts a small note the first time a browser sees a room ("Drag to look around the 3D room", the click line for that page, and "The gold arrow takes you to more about Helen below") and a gold arrow that points at the plain content under the canvas. Dismissing it writes htcl-court-lesson to localStorage. On a phone the first line changes to say the room exists on a larger screen. The click line is passed in by the page: the shop says "Hover any piece for its price. Click to buy. The full list is below"; the home page says to pick a camera. It is a paragraph of HTML, and it exists because a dark room with nothing labelled is a room people leave.

Where the assistants were

Copying the shape

One shared boot script on every page, one page-specific script that owns the decision to load anything heavy, a dynamic import() as the only path to the heavy code, a class-based state machine on an overlay so CSS does the showing and hiding, and events rather than references between the scene and the page. None of that is specific to three.js; it is the same shape a page with a map or a chart should have. The piece most worth stealing is the fail timer: a scene that has not resolved in twenty-five seconds is treated as a failure, the plain version takes over, and the visitor is told what happened in one sentence.

What each of those decisions costs in bytes is the performance page. The scene the shell hands off to is the three.js build; the shop's version of it is the shop layout. Plain version: the 3D website.