RFC-036 — God-file route restructure: testable controllers + decomposed 3D scene layers (/fly, then /explore)
Status: Shipped (v0.8; on prod) · 2026-08-05 · Closes: architectural-review R1 · builds on RFC-034 (ascent), RFC-033 (launch/live), ADR-084 (the data.ts decomposition, as the pattern precedent) · Tracking: #440 (WS-A /fly controller) · #441 (WS-B /fly scene) · #443 (WS-C /explore) — all shipped v0.8
Scope note. This RFC establishes the god-file route decomposition pattern — a route's tangled orchestration + 3D scene-build split into (1) a pure, unit-tested controller and (2) a scene layer driven one-way by it, meeting at one seam contract (§4).
/flyis the proving ground (WS-A + WS-B)./explore— the second-largest route file and R1's sibling — applies the same pattern once proven (WS-C, §8). One RFC because the seam contract is designed once and reused.
Why this is an RFC.
src/routes/fly/+page.svelteis the largest, highest-churn, least-protected file in the app — 10,890 lines, a 7,365-line<script>, 74$libimports, 63$state+ 48$derived+ 12$effect, and a ~4,870-lineonMountclosure (lines 2486→7360) that builds both 3D scenes and holds the per-frameonFrame. It is coverage-excluded (blanketsrc/routes/**/+page.svelte), so the phase orchestration — the launch→coast→cruise→cislunar→flyby→descent state machine we debugged all session — has zero unit tests; every fix this session was verified by hand in the browser. The file also documents its own booby-trap (line ~3519): a variable declared below a synchronous use hits a TDZ ReferenceError and "the animate loop would never start, the whole canvas would render blank." Reshaping a prod-live file this size and this fragile binds architectural commitments — the controller/scene seam contract must be designed once, up front, or the two workstreams fight each other. Hence an RFC, not a silent PR.
1 · The problem (measured, not asserted)
| Symptom | Evidence | Consequence |
|---|---|---|
| Phase logic untested | coverage-excluded; only Playwright touches it, and Playwright doesn't gate preflight | Regressions in scene routing are invisible to CI. The earth-orbit routing bug (crewed → heliocentric instead of launch→coast→reentry) was pure phase logic and only a human eye caught it. |
| Ordering-fragile | one ~4,870-line onMount; documented TDZ hazard at ~3519 | A declaration-order slip silently blanks the canvas. This is why debugging meant bolting on temp window.__fly* hooks instead of reasoning about the code. |
| Change-risky | 74 imports, 63 $state, one reactive scope | Any edit risks the whole prod route; there is no seam to change one concern in isolation. |
The two tangled concerns:
- Phase orchestration (~the first 2,486 lines): which scene/act is active, phase derivation, deep-link routing (
?launch/?descent), scrub-position → phase mapping, launch/coast/descent entry+exit,isMoonMission→ cislunar-vs-heliocentric. Pure logic. Currently untested. - 3D scene rendering (the ~4,870-line
onMount): heliocentric + cislunar scene assembly and the per-frameonFrame, partly delegating to the existing$lib/three/fly-helio-scene,fly-cislunar-scene,fly-updaters,ascent-scene,descent-scene. WebGL glue. Hard to unit-test; needs an architectural seam, not test coverage.
These are separate concerns, but they meet at one seam: the controller decides what act/scene is active and where the clock is; the scene layer renders that. Get the seam wrong and each workstream forces a rewrite of the other — which is the whole reason this is one RFC.
2 · Target architecture
src/routes/fly/+page.svelte ← THIN page: wires DOM/HUD to the controller + the scene host,
│ owns Svelte reactivity only. No phase logic, no scene build.
├─ $lib/fly/flight-phase-controller.ts ← WORKSTREAM A (R1): PURE state machine.
│ inputs: { deepLink, scrubU, mission flags (isMoonMission, earthCoast, launchAvailable,
│ descentAvailable), event times (secoT, coastDur, …), clock }
│ outputs: { act: 'opening'|'ascent'|'coast'|'cruise'|'cislunar'|'flyby'|'descent'|'recovery',
│ viewMode, activeScene, legal transitions, phase-derived flags }
│ NO svelte / three / dom import. Fully unit-tested.
└─ $lib/three/fly-scene-host.ts (+ the existing fly-*-scene modules) ← WORKSTREAM B (3D SIM):
owns scene assembly + the per-frame onFrame, driven by the controller's act/clock.
The ~4,870-line onMount is decomposed into: buildScene(act) + updateFrame(state) units,
each a plain function over a scene handle — testable via the existing jsdom scene harnesses.The page becomes a thin adapter: Svelte $state/$derived/$effect translate URL + user input into controller inputs, read the controller's outputs, and hand the active act + clock to the scene host. Behavior is byte-identical throughout — this is a structural refactor, not a feature change.
3 · The two workstreams (both committed — this RFC is the full solution)
This RFC commits to both. Sequencing below is execution order, not deferral — WS-B is not a "maybe later," it is the second half of the same decomposition and ships as part of closing R1's parent concern.
Workstream A — Phase controller (was "R1")
Extract the phase state machine into $lib/fly/flight-phase-controller.ts — a pure, framework-agnostic module. +page.svelte consumes it; the scattered showLaunch/showCoast/showDescent/showRecovery/openingActive/viewMode booleans become derived from one controller state.
Success criteria (all measurable):
flight-phase-controller.tsexists, pure (no svelte/three/dom import), and is the single source of the active act + legal transitions.- Unit tests cover the transitions we hit this session and would have caught the bugs: crewed/suborbital →
ascent(not heliocentric cruise);isMoonMission→ cislunar vs heliocentric; scrub-U → act mapping;?launch=1/?descent=1deep-links; touchdown →recovery; opening → skip. Coverage-gated (pure lib → counts toward the frozen thresholds). +page.svelte's phase flags derive from the controller; the ordering-sensitive phase state moves into deterministic module init → the TDZ hazard class is removed for phase state./flyPlaywright e2e stays green; manual in-browser confirm of launch→coast→descent, a cislunar moon mission, and a flyby.
Workstream B — 3D scene decomposition (the "3D SIM")
Decompose the ~4,870-line onMount into a fly-scene-host that assembles the scene per active act and runs onFrame, built over the existing fly-helio-scene / fly-cislunar-scene / fly-updaters seam. The inline scene objects (moon-frame groups, phase lines, trajectory tubes — the last already extracted in R2) move into scene modules; onFrame becomes a composed set of per-frame updaters keyed on the controller's act.
Success criteria (all measurable):
+page.svelte'sonMountshrinks to: create renderer/host, subscribe to the controller, wire input listeners, register cleanup — target < ~600 lines (from ~4,870). No inlinenew THREE.*scene assembly left in the page.- Scene assembly + frame updates live in
$lib/three/fly-*modules, each a plain function over a scene handle, testable via the existing jsdom scene/dispose harnesses (likedispose-leak.test.ts,descent-models.test.ts). - The TDZ hazard is structurally gone — no giant closure with order-coupled declarations; each scene module has deterministic construction.
- Byte-identical render:
/flyPlaywright e2e green on desktop + mobile; manual confirm of every act (opening, ascent, coast, cruise, cislunar, flyby, descent, recovery) + a visual diff against pre-refactor screenshots for the flyby-hero and cislunar framings.
4 · The seam contract (why one RFC, designed once)
The controller exposes a read-only view the scene host consumes each frame:
interface FlightPhaseState {
act: FlyAct; // 'opening'|'ascent'|'coast'|'cruise'|'cislunar'|'flyby'|'descent'|'recovery'
viewMode: 'heliocentric' | 'cislunar';
clock: { simDay: number; launchT: number; coastMetDays: number; descentT: number; playing: boolean };
scene: { activeScene: FlySceneId; transitionInto?: FlySceneId };
}The scene host never mutates phase; it renders state and reports frame events (touchdown, ascent-complete) back through callbacks the page routes into controller transitions. This one-way data-flow (controller → scene, events → controller) is the invariant both workstreams must honor. Fixing it now is why the two can be built in sequence without rework.
5 · Non-goals
- No behavior change. Not a visual, timing, cinematic, or UX change. Pixel- and frame-parity with the current
/flyis a hard requirement (§3 e2e + visual diff gates). - Not rewriting the physics (
fly-physics,ascent-physics,descent-profile) or the cinematic quality bar ([[project_fly_cinematic_vision]]). - Not re-homing the existing
fly-*-scenemodules — they stay; WS-B moves the inline page scene-build down to join them. - Not touching
/explore(the other god-file) — a separate future concern.
6 · Sequencing, risk, rollback
- WS-A first — highest value (makes the buggy logic testable), lowest risk (pure logic, no WebGL), and it defines the seam WS-B consumes. Ship + validate before starting WS-B.
- WS-B second — larger, WebGL, higher regression risk; gated by the WS-A seam being real and the full e2e + visual-diff suite.
Risk: it's a prod-live file. Mitigation: each workstream lands behind green /fly e2e (desktop+mobile) + manual per-act browser confirm + a visual diff on the two most fragile framings (flyby-hero, cislunar); commits are small and revertible; the controller lands first with zero scene change so any regression is isolated to one layer. Rollback: each workstream is independently git revert-able because the seam keeps the layers decoupled.
7 · Decision (resolved 2026-08-05)
One RFC, sequenced workstreams — approved. The seam contract (§4) is designed once here; WS-A, WS-B, and WS-C are tracked as GitHub issues that reference this RFC. The two-RFC alternative was rejected because it would re-litigate §4 across files and risk divergence. Implementation of WS-A begins once the in-flight main push (the ADR-084 decomposition + review fixes) is docker-e2e-green and stable.
8 · Workstream C — /explore (same pattern, after /fly proves it)
/explore/+page.svelte is R1's sibling god-file — 8,816 lines, a 6,102-line <script>, 67 $lib imports. Its "acts" are the scale-shells (solar system → stellar neighbourhood → galactic → Local Group → universe), driven by a contextId state machine + ?context= / ?galaxy= deep-links + scale transitions (scaleReadout, activeScale, causality shells). Structurally identical to /fly: a tangled scale-shell orchestration (which shell/context is active, transitions, deep-link routing) welded to a per-scale 3D scene layer.
Apply the same decomposition — the seam contract (§4) generalizes (act → scale-shell; the controller decides the active shell + camera target, the scene layer renders it one-way):
src/routes/explore/+page.svelte ← THIN page
├─ $lib/explore/scale-shell-controller.ts ← pure, unit-tested: contextId + scale-shell
│ inputs: { deepLink (?context/?galaxy), activeScale, selected ids, transitions }
│ outputs: { shell, viewScale, activeContext, camera target, legal transitions }
└─ $lib/three/explore-scene-host.ts (+ per-shell scene modules) ← scene assembly + frame,
driven by the controller's shell/scale. No orchestration in the host.Success criteria (measurable, mirroring WS-A/WS-B):
$lib/explore/scale-shell-controller.ts— pure (no svelte/three/dom), the single source of the active shell + legal transitions; unit-tested (scale-shell transitions,?context=/?galaxy=deep-links, reset-to-solar, causality-shell state). Coverage-gated.explore-scene-host+ per-shell scene modules own scene assembly + frame updates; the page's scene-build/onMountreduces to wiring.- Byte-identical behavior —
/explorePlaywright e2e green + manual per-shell confirm (solar, neighbourhood, galaxy, Local Group, universe) + visual diff on each shell boundary-crossing.
Sequencing: WS-C is blocked-by WS-A + WS-B — do /fly first so the controller/scene-host pattern + the seam are proven before applying them to the second file. WS-C reuses the WS-B fly-scene-host shape (not the code — the pattern). Tracked as #443.
Non-goals unchanged (§5): no behavior change, no /explore feature work (the scale-toggle #258, cosmos #259, message-trajectories #410, grand-tour #411 are separate roadmap items, not this refactor).