system
400.wtf

400.wtf / system / docs · docs/ADR/0002-persistent-background-runtime.md

ADR-0002 — Persistent background runtime across navigation

Status: Accepted (documents the existing decision, commit fbb781d)

2026-10: No longer applies to the site. The black hole left the site shell in the civic redesign (DS-0) and is parked in packages/black-hole (plan §6). Still describes the runtime for when it returns as its own project.

Context

The black-hole background is the site’s identity. Astro view transitions swap the DOM on navigation, unmounting Vue islands — a naïvely mounted Three.js scene would be destroyed and rebuilt (300k-particle buffers, shader compile, context creation) on every route change, producing a visible blink and wasted work. Additionally, the site preserves scrollY across swaps (Layout.astro) so the camera choreography reads as one continuous space.

Problem

Keep one living WebGL scene — clock, camera, GPU buffers, canvas — alive across page navigations, while Vue components mount/unmount around it.

Decision

A module-level registry (Map<string, PersistentEntry> in composables/blackHole/engine.ts) owns runtimes keyed by name. The runtime owns the renderer and its <canvas>; Vue components borrow it: on mount they attach() the existing canvas into their container and subscribe; on unmount they detach() and release(). A subscriber count stops the rAF loop at zero and resumes on next mount. Scene state is never serialized because the simulation is stateless-GPU (ADR-0004) — “state” is the clock plus camera position, which persist in the module.

Alternatives considered

  • Recreate per page: simplest; unacceptable blink + rebuild cost; breaks continuity, which is the product.
  • transition:persist on the island: Astro can persist DOM subtrees across swaps. Attractive, but couples the canvas’s survival to Astro’s persistence semantics and to the island staying structurally identical across layouts; the module registry keeps survival independent of the router. Worth re-testing periodically as Astro matures.
  • Fully headless singleton (no Vue involvement): mount the canvas once in Layout.astro outside the swap region and never detach. Fewer moving parts, but loses per-page control (which page shows/hides/configures the background) and Vue-side reactivity to scroll/size.
  • Serialize state + rebuild: unnecessary given statelessness; strictly worse.

Tradeoffs

  • (+) Zero-cost navigation; the effect nobody else’s portfolio has.
  • (+) Subscriber counting stops rendering when nothing displays it.
  • (−) Module-level mutable state: invisible to Vue devtools, survives HMR oddly in dev.
  • (−) The renderer is intentionally never disposed — a deliberate leak of one context.
  • (−) Failure modes (context loss, attach to detached DOM) are the app’s to handle; context loss is currently unhandled (rendering.md R2).

Consequences

  • Anything needing cross-page continuity should join this pattern (registry key per concern) rather than inventing another.
  • The Vue adapter (usePersistentBlackHole) is the single gathering point for site state → frame input; engine API v2 (rendering.md R3) formalizes it.
  • Never introduce a full page reload on internal navigation; it resets the world.

Future evolution

  • Engine API v2 (FrameInput object, setQuality, setPalette) — planned.
  • If a second persistent runtime ever appears (e.g., audio), extract the registry mechanics into a tiny shared helper; until then it stays local.