Client shell

2,040 words

How the browser app is put together: the canvas, the modes, the camera, the dock. The design intent of each mode is in the design bible. The decisions are ADR-0011 and ADR-0012.


One canvas, for the life of the session

Root is a server-rendered shell hydrated by Astro. PageShell renders the mode and overlay routes inside its .hud-layer; GameLoader adds the persistent App beside it. App owns the canvas, engine and visual overlays outside every route. React Router handles links after hydration, so navigation preserves the renderer. ADR-0039.

Home and docs are readable before the runtime starts. Initial documentation arrives through request-scoped props; subsequent links fetch the existing JSON content. Renderer startup, pipeline and device failures show a graphics notice and stop the interactive runtime while leaving the page shell usable. WebGPU is preferred; WebGL is attempted with floating-point render-target support. Standard output remains available when extended output is unsupported. The notice can be dismissed until the page reloads. Dismissal leaves graphics stopped and does not write a preference.

A tab that restarts with an unfinished graphics-session marker shows the same notice before loading the engine. An orderly navigation clears the marker; Try graphics again clears it and reloads. The marker lives in session storage, so this guard depends on the browser retaining that storage through the interruption. It does not identify which GPU operation caused a crash.

.hud-layer is pointer-events: none so the scene stays reachable. Mode chrome opts back in with pointer-events-auto. ErrorBoundary's className styles its fallback, not a wrapper — nothing between a mode and the layer turns pointer events back on. Getting this wrong is silent: the hit target at every pixel is the canvas.

The four edges the operating system keeps

The document is 100dvh, overflow: hidden, and cannot scroll: the viewport meta carries viewport-fit=cover so the canvas runs to the physical edges of the display, maximum-scale=1 so the browser's pinch does not race the planetarium's, and interactive-widget=resizes-content so a soft keyboard does not push a fixed layout off screen. 100vh is not the visible height on iOS Safari — it is the height the page would have with the toolbars hidden — so nothing in the interface is sized in vh or vw.

viewport-fit=cover hands the insets back as env(safe-area-inset-*), which index.css names once as --safe-top/right/bottom/left and spends in exactly one place: as the four offsets on .hud-layer. The layer is therefore the containing block of every absolutely positioned piece of chrome in the interface, so every readout, panel, dialog and menu is already clear of the notch and the home indicator — including one written later that never heard of any of this. Percentages inside the layer therefore mean "of the safe area", which is what the calc(100% − …) width caps are.

Offsets, not padding, and the difference is the whole mechanism. The first version of this used padding, on the reading that an absolutely positioned element resolves inset-0 against its ancestor's padding box. It does — but the padding box is the border box minus the border, so its edge is the outside of the padding, not the inside. Padding on the layer shrinks its content box, which nothing here uses, and moves no absolutely positioned child by a pixel. The same trap applies one level down: hud-bleed pads the insets back so its contents land in the safe area, and that reaches an in-flow child only — the boot overlay's corner readout is laid out with flex items-end and a margin rather than absolute bottom-3 left-3 for exactly this reason.

Surfaces that are picture rather than chrome opt back out with hud-bleed: a cutscene's blackout, a dialog's scrim, the boot cover, and the transparent surface a mode listens for drags on. Each would otherwise stop at the safe area and show a band of live scene above the notch, or refuse a drag that started in the 44 px a landscape phone keeps at each side. hud-bleed offsets back out and pads back in, so its own children stay inside the safe area — and because it sets all four offsets it must not be paired with inset-0. The bottom nav bar on a phone is the fifth case and takes hud-bleed-bottom, which bleeds the ground without the padding, because the padding has to land on the row that draws it.

Canonical state does not live in React. Components consume snapshots from apps/game/src/state/engineStore.ts — a zustand store holding the world status, the planetarium's observer, the presentation switches and the cutscene playhead, republished at 8 Hz by one sampler. Subscribe to the narrowest slice you need. useEngine((s) => s.status) is a new object every sample and never bails out of a re-render; a selector returning a primitive or a stable slice does, and several fields want useShallow. A wide snapshot read widely is worse than the props it replaced.

Two polls remain outside it, because neither is a field read: the travel survey (hud/useTravelTargets.ts) is a star sweep, and the sky labels' refresh is geometry over the camera and the viewport.

What is drawn goes through engine.presentation, a stance stack. A mode pushes on mount and releases on unmount, a panel's override is another push, and release() restores whatever was underneath rather than a literal — see apps/game/src/engine/presentation.ts for why that is a stack and not a table keyed by mode.

A component that reads mutable state (an engine, a metrics buffer) must opt out of React Compiler with 'use no memo'. The compiler assumes derived values are pure functions of props; a stable reference whose contents change every frame otherwise renders once and freezes. See apps/game/src/hud/PerfPanel.tsx. This is not license to hand-write useMemo.


Mode is a function of the path

Five modes, each answering "who owns the camera" differently:

Mode Path Camera Code
menu /, and any unknown path the observatory, drifting pages/HomePage.tsx
flight /play/:mode the ship's chase rule flight/
planetarium /planetarium?at=… the observatory planetarium/, packages/devtools/src/observatory.ts
cinema /cinema/:scene?t=&play= the cutscene director cinema/
docs /docs/* the observatory, on the wing's framing docs/

The documentation is one route with a splat, not a table of pages. Its addresses mirror the repository's directory tree, so enumerating them here would be a second copy of scripts/docs/wings.mjs that nothing keeps in step; the mode reads the path and the manifest decides whether it names anything. The masthead pushes a presentation stance once for the whole visit and only re-aims the observatory between wings — releasing and re-pushing per wing hands the camera back to whatever is underneath for a frame, which reads as a cut to the ship's chase view in the middle of a navigation.

The current mode is modeForPath(resolvedLocation(location).pathname) in pages/paths.ts. It is not React state. A reload, a back button, and a pasted link land in the same place by construction. The same rule covers everything else the URL carries — the planetarium subject (?at=) and the cinema frame (?t=).

When a dialog is open, it records the mode's location in location.state.background and ModeRoutes renders at that. resolvedLocation is the one function that resolves it. Every link that stays inside a dialog, and every control that closes one, has to agree — pages/useOverlay.ts is that half.

Do not give AnimatePresence mode="wait" over the overlay routes, and key it on the dialog's surface (overlaySurface in pages/paths.ts), not its pathname. mode="wait" can leave a closed dialog's scrim in the DOM at opacity: 0 with pointer-events: auto. Keying on the pathname makes every settings tab a fresh entrance and stacks scrims.

Do not guard a "run once" effect with a ref. React re-runs effects; refs survive. Reconcile against the state's actual owner instead (observatory.target?.address === wanted).


One producer of the camera

In GameEngine.#step the order is cutscene, then observatory, then the controlled player. The player arm resolves a ship or character. Each arm hands a presentation eye to buildScene. No arm may depend on a later one resolving, and only the last needs a player.

The character's first-person and third-person poses resolve before render origin and terrain selection. The third-person boom sweeps visible ground and support disks. The drawn-ground correction belongs to the camera and avatar presentation; it does not move canonical contact. The browser's pointer-lock adapter sends input to the character controller and never writes the Three.js camera. ADR-0047 records that boundary.

The lens follows the same order through the same code. engine.lens is a getter choosing the cinematic lens, then an active fixed galaxy instrument's declared lens, then flightLens, and everything that composes a frame — CameraRig, the flare, the warp streaks, the sky labels, the terrain predicate — reads it rather than holding a copy. A picture composed through one lens and measured through another is the bug class this closes, which is why a consumer reads the lens instead of being pushed an angle once a frame: a private copy is a second producer kept in step only by nobody forgetting the call (ADR-0017).

With one exception, and it is the precedence rule again. The observatory's framing solver reads framingLens() — the flight lens alone — rather than the composed one. It is the arm that produces a camera only when the cutscene arm is null, so solving a standoff against a script's lens is the arm depending on the one it is the fallback for; and focus and frameTarget store the distance they solve, so the error outlives the scene that caused it. Measured: focusing Earth from the console while tng-intro plays parks the camera 29.8 Mm out against the 20.8 Mm the flight lens asks for, permanently.

The planetarium does not write canonical state. The observatory resolves an address, asks the world where that is at the snapshot's presentation instant, and returns a pose. No teleport, no clock, no entity write, no save. Explicit Lock to walk activation leaves the planetarium for solo play before normal character controls take over. observatory.test.ts compares world.stateHash() across a session of flying around; that test is the design promise. See planetarium.

The split between pure arithmetic and a live world is the same one the cinematic director uses, and for the same reason — the math is testable in Node:

  • packages/rendering/src/observer.ts — orbit camera as arithmetic: drag, zoom, log-space easing, framing, phase angles. No world, no addresses.
  • packages/rendering/src/surfaceStance.ts — the second arm, on the same terms: a stance on the ground to an offset and an orientation, the logarithmic height scrub, the horizon pitch. It is handed the ground radius rather than sampling one, because packages/rendering cannot reach surfaceRadius — which is also what lets the descent probe walk a stance down a heightfield with no world in the loop.
  • packages/devtools/src/observatory.ts — the same camera bound to a live world, and the one place that knows which arm holds it: a stance is nullable rather than a flag beside the orbit state. Exposed on the harness. Its sample does touch the world, because it is following something that moves.
  • packages/devtools/src/orbitPaths.ts — orbit traces. A trace is relative to its primary and re-anchored to now (or a moon's trace is an open corkscrew). Each point is placed with the body's own radius (or render compression draws the curve at a different depth from the planet).

The dock

Two panes, a field of floating panels, and the IR menu. apps/game/src/dock/layout.ts owns which zone a panel is in; dock/floating.ts owns where a floating one sits. Both are pure and property-tested. React DnD is the gesture only.

Every known panel is in exactly one zone, exactly once. Move a panel through the layout helpers, never by splicing an array at a call site. Use the updater form of the setter — one gesture can deliver two drops, and two moves composed against the same captured snapshot discard the first.


Sessions

openSession in packages/devtools builds the world, the ship, the pool, the store, and the harness, in the one order that works. A host passes adapters in. It does not construct them.

Worker construction lives in apps/game/src/engine/browserWorker.ts. Tasks are typed and versioned; the pool owns dispatch, cancellation, and instrumentation.