Skip to content

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). /fly is 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.svelte is the largest, highest-churn, least-protected file in the app — 10,890 lines, a 7,365-line <script>, 74 $lib imports, 63 $state + 48 $derived + 12 $effect, and a ~4,870-line onMount closure (lines 2486→7360) that builds both 3D scenes and holds the per-frame onFrame. It is coverage-excluded (blanket src/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)

SymptomEvidenceConsequence
Phase logic untestedcoverage-excluded; only Playwright touches it, and Playwright doesn't gate preflightRegressions 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-fragileone ~4,870-line onMount; documented TDZ hazard at ~3519A 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-risky74 imports, 63 $state, one reactive scopeAny 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-frame onFrame, 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):

  1. flight-phase-controller.ts exists, pure (no svelte/three/dom import), and is the single source of the active act + legal transitions.
  2. 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=1 deep-links; touchdown → recovery; opening → skip. Coverage-gated (pure lib → counts toward the frozen thresholds).
  3. +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.
  4. /fly Playwright 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):

  1. +page.svelte's onMount shrinks to: create renderer/host, subscribe to the controller, wire input listeners, register cleanup — target < ~600 lines (from ~4,870). No inline new THREE.* scene assembly left in the page.
  2. 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 (like dispose-leak.test.ts, descent-models.test.ts).
  3. The TDZ hazard is structurally gone — no giant closure with order-coupled declarations; each scene module has deterministic construction.
  4. Byte-identical render: /fly Playwright 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:

ts
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 /fly is 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-*-scene modules — 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

  1. 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.
  2. 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 (actscale-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):

  1. $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.
  2. explore-scene-host + per-shell scene modules own scene assembly + frame updates; the page's scene-build/onMount reduces to wiring.
  3. Byte-identical behavior/explore Playwright 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).

Orrery — architecture documentation · MIT · No tracking