1,286 words
Status: accepted · 2026-08-22
Document and startup ownership is superseded by ADR-0039.
Context
Until this decision the client was one screen: a <Canvas>, a HUD layer over
it, and a single routed overlay for settings. That was correct while there was
one thing to do. Then the game acquired five: solo offline, solo online, the
persistent universe, a planetarium and a cinema player — plus the
ordinary furniture of a product on the web (a front door, settings, an about
page, sign-in, a profile, an identity provider's redirect URI).
Three constraints shaped the answer and none of them is negotiable:
- The
<Canvas>must never live inside a route. R3F builds aWebGPURendererper canvas andcreateRendererawaits a device probe andinit(); a router that owned the whole tree would tear that down and rebuild it on every navigation.render/presentationWatchdog.tsexists because a single mis-timed renderer build produces a black screen with a healthy HUD; doing one per click is not a trade worth considering. - Only one thing may own the camera at a time, and there are now three candidates: the ship's chase rule, the cutscene director (ADR-0010) and the planetarium's observatory.
- A game that is a link has to behave like one. The pitch is that this runs in a browser tab. That is worth very little if the tab's address does not describe what is on screen.
Decision
A persistent shell, with a route table over it, and the mode derived from the path by a pure function.
Appowns the<Canvas>and.hud-layerforever. Every route renders inside the HUD layer, as a sibling of the canvas. No navigation can reach the renderer.- Two route tables, not one. Mode routes (
/,/play/:mode,/planetarium,/cinema/:scene?,/docs/*) decide what owns the camera and what chrome is on screen. Overlay routes (/settings/:section?,/about,/sign-in,/sign-up,/profile,/auth/callback) are dialogs drawn over a mode. A link into an overlay carries the mode's own location asstate.background, so opening settings does not unmount the mode behind it. modeForPath(pathname)is a pure function inapps/game/src/pages/paths.ts, returning one ofmenu | flight | planetarium | cinema | docs. Mode is never held in React state: a reload, a back button and a pasted link therefore land in the same place by construction, and the claim is testable in Node.- The camera has one precedence order, in one place.
GameEngine.#stepresolves it: cutscene, then observatory, then the ship. Each override is a presentation eye handed tobuildScene, which is the seam ADR-0010 already established — so the observatory is a second producer of an existing shape rather than a new mechanism. - The observatory writes nothing canonical. It resolves an address, asks the
world where that is this tick, and returns a pose. No teleport, no clock, no
entity write. Leaving the planetarium is
clear(), and the camera falls back to whoever is next in the order — there is no "restore" step because nothing was taken. - Search parameters are part of the contract.
?at=is the planetarium's subject;?t=and?play=are the cinema player's frame and autoplay. Named once, in the same module as the paths. - The debug overlay is off by default, toggled by
`or the shell bar. The dev dock is the author's instrument anddocs/design/ux.mdspecifies a cockpit that is nothing like it; a first-time visitor should not meet it.
Alternatives considered
A separate build (or entry point) per mode. Cleanest routing, and it throws away the property that makes any of this interesting: the planetarium and the flight modes share one running world, so you can leave a ship in orbit, look at Saturn, and come back to the same state hash. Two builds is also two service worker caches, two catalog fetches and two renderers to get right.
Mode in React state, with the URL as a side effect. Simpler to write and it fails on the first reload: state and address drift, and a pasted link becomes a best-effort suggestion. Deriving from the path makes the drift impossible rather than unlikely.
A router that owns the whole tree, with the canvas inside a layout route. The idiomatic React Router shape. Rejected on constraint 1 — and the failure is not a stutter, it is the black-screen class the presentation watchdog was built to recover from, arriving on purpose several times a minute.
Modal state instead of state.background. Settings as a boolean rather than
a route. It works, and it makes the section un-linkable — "turn off the lens
flare" then costs three sentences of navigation instead of a URL. The background
pattern keeps both properties for about fifteen lines.
A free 6-DoF camera for the planetarium. The Space Engine model. Rejected because it has no unambiguous answer to "look at Jupiter", it needs a keyboard, and its failure mode — lost, at an unknown scale, pointed at nothing — is the one thing a planetarium exists not to do. Orbiting a target is three numbers, works with one finger, and is what makes the mode usable on a phone at all.
Giving the planetarium its own camera path in the renderer. It already has
one: buildScene's eye override. Adding a second would mean LOD, apparent star
brightness, up and flare occlusion each having two ways to be told where the
viewer is — which is exactly the bug ADR-0010's override was introduced to stop.
Consequences
Good.
- One build, one world, one renderer. Switching modes is a route change and the simulation does not notice.
- Every mode, every scene frame and every planetarium subject is a URL. The verification loop for cinematic work — pause, seek to frame 1150, look — is now a link anyone can open.
- The camera's precedence lives in six lines of
#stepand nowhere else. A new producer is a newnull-able field, in the order. - The planetarium's guarantee is checkable rather than asserted: a test compares
world.stateHash()across a session of flying around. - Reserving
/auth/callbacknow is free; changing a registered redirect URI later is a coordinated deploy with an identity provider.
Costs, honestly.
ErrorBoundary'sclassNamestyles its fallback, not a wrapper. Every mode must therefore claimpointer-events-autoitself, because.hud-layerispointer-events: noneso the scene stays reachable. Getting this wrong is silent: the hit target at every pixel is the canvas and the mode simply ignores input. It cost an afternoon once and it is written down here so it costs nothing next time.- Effects that "run once" must reconcile, not latch. A ref-guarded
opened.currentlooks right and breaks the moment React re-runs effects while the ref survives — the cleanup clears the observatory's target and the guard refuses to set it again. The rule is to compare against the state's actual owner, which is idempotent by construction. - A cold-loaded overlay has no background and renders over the menu. That is
the honest answer — a fresh tab at
/settingshas no session behind it — but it does mean an overlay route is not, on its own, a description of a full screen. - The mode routes are not covered by the Node route test: each drives a live
engine, and a test that stubbed a renderer, a worker pool and a camera would
be asserting against the stub. What is testable without a browser —
modeForPath, the link builders, the dock's layout algebra, the gesture arithmetic — is tested, and the boundary is drawn deliberately.