Flight Director Foundation — Design (Brick A)¶
- Date: 2026-06-04
- Status: Implemented (Brick A) — web + native, verified (typecheck + native logic
tests + app compile + render check). Branch
feat/flight-director-foundation. - Deviations from plan: (1) the rift barrel-roll stays inline (now seeded via
ctx.rng()) rather than re-expressed as a directorrollmanoeuvre — deferred to Brick B, as the plan scoped. (2) The native--eventharness hook was not added (the web?event=+ the existing native--threatcover tuning; the harness's single-frame capture makes one-shot events awkward). Theroll/dive/climb/bankmanoeuvre kinds exist and are unit-tested, ready for Brick B. - Author: Johannes + Claude (pair)
- Scope: Brick A of the "Procedural Film" roadmap. Skeleton + wiring only.
1. Why this exists (the film vision, in one breath)¶
We are evolving the screensaver from a scene carousel + a narrative that merely polls the phase into a self-regenerating film: one continuous flight that flows through scenes via real transitions (Terrain → pull up → warp → descend into a Megacity street canyon → …), with the terminal story synced to the flight (pursuit, dodging, incoming fire, CORP support units, radio chatter).
That whole vision is decomposed into bricks A–F (see the session notes). This spec is only Brick A: the backbone — the machinery every later brick hangs off:
FlightDirector— sequences manoeuvres + owns a continuous speed/warp scalar.EventBus— the bidirectional, seeded, platform-neutral event channel.- A transient-envelope layer in
ReactiveWorldso discrete events can react the world without corrupting the slowthreatarc.
Brick A introduces no new geometry, no new scenes, and no new transition. It is
proven by re-routing two existing behaviours (the rift barrel-roll and the
onIntrusion → hesitation kick) through the new machinery with zero visible change.
North-star acceptance test (built in Brick B, but A must make it possible)¶
The canonical first transition we are building toward — captured here so A's shape is correct from day one:
Terrain → City "approach & dive": (1) pull up out of the terrain, (2) engage the warp boost, (3) swap the world under the warp streak (unseen), (4) emerge high above a megacity grid, camera pitched down, (5) descend into a street canyon and hand off to the City scene.
This is a sequenced manoeuvre chain with a continuous clock + a smooth speed ramp.
The FlightDirector is exactly the engine that runs such a chain. A must therefore
support: queued, chained manoeuvres that do not re-center/re-level between steps, and a
continuous speed scalar that can ramp. The transition geometry itself is out of scope
for A (it is Brick B).
2. Goals / Non-goals¶
Goals
- A
FlightDirectorobject, instantiated by the Engine (web) / Renderer (native), running once per frame at the existing camera post-process seam. - An injectable, chainable manoeuvre queue (reusing CameraFly's C1
sin²envelope) with a single continuous clock — no per-step re-center hiccup. - A continuous speed/warp scalar threaded to scenes via
ctx.speed(), layered on top of the existing 3-stepSPEED_VALUESenum (enum becomes the base). - A typed, synchronous, seeded
EventBus(emit/subscribe), platform-neutral. - A transient-envelope layer in
ReactiveWorld: discrete events produce one-shot additive envelopes on top of the threat-derived multipliers — never overwriting fog/bloom. - Camera is always event-reactive: scene-authored baseline + always-on event/manoeuvre overlay from the director; full director ownership is reserved for transitions (Brick B).
- Determinism: all director/event randomness flows through the seeded rng. Fix the two
existing
Math.random()leaks (tunnel boost shake, rift roll gap) as part of this work. - Parity: web-first, native fast-follow; data shapes structurally identical so the files diff cleanly.
- A
?event=<kind>web harness hook (and--eventnative harness) to tune each reaction in isolation, mirroring the existing?threat=/--threatworkflow.
Non-goals (explicitly deferred)
- B: real transitions / the Terrain→City approach / warp-as-seam / bridge geometry.
- C: the procedural itinerary/grammar + co-driving narrative phase from the route.
- D: combat actors (drone, debris, CORP units, incoming fire).
- E: multi-speaker radio narrative.
- F: granular per-scene settings + UI.
- Any global camera rail spanning all scenes (the warp-tunnel-as-seam decision makes it unnecessary).
- Any new on-screen settings UI.
3. Architecture¶
3.1 The three new units¶
FlightDirector — src/engine/modes/flight-director.ts¶
Single purpose: post-process the camera each frame according to (a) an active manoeuvre chain and (b) inbound events, and own the continuous speed scalar.
type ManoeuvreKind = 'bank' | 'dive' | 'climb' | 'kick' // 'kick' = the generalized hesitation pull
interface Manoeuvre { kind: ManoeuvreKind; dur: number; dir: number; intensity: number }
interface CamPose { x: number; y: number; pitch: number; yaw: number; roll: number }
interface FlightDirector {
/** advance internal clock, drain queued manoeuvres; called once/frame. */
update(t: number, dt: number): void
/** apply additive overlay to the pose the scene just wrote. Pure-ish; never writes fog/bloom. */
applyToCamera(base: CamPose): CamPose
/** continuous speed multiplier (rides on top of the SPEED_VALUES enum base). */
speed(): number
/** queue a manoeuvre; chains continuously (no clock reset between steps). */
enqueue(m: Manoeuvre): void
/** ownership flag — false in A (scene authors); set true only during a transition (B). */
ownsCamera: boolean
}
- Runs at the same seam as today's
pulseHesitationbrake: after the scene updater writes the camera, before the parallax snapshot is taken. (Exact line confirmed againstcore.tsduring implementation; the hesitation envelope is the integration model.) - The manoeuvre envelope is CameraFly's existing C1
sin²(πp)— value and slope zero at both ends — so chained manoeuvres never snap (the documented roll-snap bug). - A single continuous director clock (not per-manoeuvre
t0) so a chain (climb→warp→descend) flows without the per-instance re-center hiccup the readers flagged. applyToCamerais additive on the scene's pose (the proven parallax/hesitation pattern) and must respect the parallax restore/snapshot dance — it modifies, never accumulates across frames.
EventBus — src/engine/events/bus.ts¶
Typed, synchronous, multi-producer/multi-consumer. Settled inside the conductor's
update() before the scene updater runs, so events are consistent within a frame
(never landing a frame late).
type FlightEventKind =
| 'intrusion' // migrated from narrative.onIntrusion (the A proof)
| 'manoeuvre' // a director manoeuvre fired (climb/dive/bank) — for narrative/world to react
// B–E will extend: 'enterTunnel' | 'shotHit' | 'dodge' | 'unitArrive' | 'unitCrash' | 'hyperdrive'
interface FlightEvent { kind: FlightEventKind; intensity?: number; dir?: number }
interface EventBus {
emit(e: FlightEvent): void
subscribe(kind: FlightEventKind | '*', fn: (e: FlightEvent) => void): () => void
}
- Seeded: any randomness a handler needs (impact direction, jitter) draws from the
engine's seeded rng — never
Math.random(). - Respects narrative generation-cancellation: handlers that touch the terminal must
snapshot/check
generationexactly as the beat loops do, or they type into a cleared prompt. (Relevant from E onward; A's handlers touch only camera/world.) - Lives in the
src/engine/events/placeholder dir (created for exactly this).
Transient-envelope layer — extend src/engine/fx/reactive-world.ts¶
Generalize the single hesitation envelope into a small list of active one-shot envelopes the conductor advances each frame. Each envelope:
interface TransientEnvelope { t0: number; dur: number; shape: (p: number) => number; targets: EnvelopeTargets }
- Envelopes produce additive deltas on the threat-derived multipliers (e.g. a brief
bloom bump, a chroma spike, a camera kick) — strictly separate from
threat. - Single-writer invariant is sacred: the conductor still only sets
*ThreatMult/*Add; the engine still composesbase × multonce per frame. Envelopes feed the mult, never writefog.density/bloom.strengthdirectly. - calm mode (
prefers-reduced-motion) gates every motion-heavy envelope exactly asonIntrusionearly-returns today.
3.2 Tick order (web core.ts & native Renderer.swift, mirrored)¶
onFrame(conductor) // ReactiveWorld.update: threat + DRAIN event queue + advance envelopes
→ restore parallax baseline
→ scene.update(t, dt) // scene writes the baseline camera pose
→ director.update(t, dt) // advance clock, drain manoeuvre queue
→ cam = director.applyToCamera(cam) // additive overlay (replaces the inline hesitation brake)
→ parallax snapshot
→ render
The existing inline pulseHesitation brake is subsumed by the director: intrusion
becomes an event → a kick manoeuvre → the same sin² lateral pull. Net behaviour at the
camera is unchanged; the path is now event-driven.
3.3 Speed scalar¶
SPEED_VALUES { slow:0.32, norm:1, fast:2.8 }stays as the user base.FlightDirector.speed()returnsbase × directorMultiplier(1.0 in A; ramps in B/warp).- Scenes read
ctx.speed()(new thunk onSceneCtx, sibling tothreat()/storm()) instead of reading the enum directly. In A the value is identical to today, so no visible change.
4. Determinism¶
- One seed lineage:
engine.seed. The director and all event handlers draw from a seeded xorshift derived from it (the patternReactiveWorldalready uses for hesitation jitter). - Fix the two existing leaks (so "the film regenerates reproducibly" holds):
- tunnel auto-BOOST shake (
tunnel.ts) → seeded. - rift barrel-roll gap timing (
rift.ts) → seeded. - Same seed ⇒ same film. This is a hard invariant for every later brick.
5. Parity plan¶
- Web first, native fast-follow within the same brick.
- New files mirrored 1:1 in concept:
flight-director.ts ↔ FlightDirector.swift,bus.ts ↔ EventBus.swift, the ReactiveWorld envelope layer ↔Renderer.updateThreatenvelope layer. - Data shapes (
FlightEventKind,Manoeuvre,TransientEnvelope) are structurally identical across web/native so the files diff cleanly. - Native specifics honoured:
- The director's per-frame allocations stay inside the
autoreleasepool(or are pre-allocated) — the multi-GB-leak gotcha. - Single-threaded
advance → drawmodel preserved; no async, no background mutation. - Native camera is a per-scene struct with a
viewOverridematrix slot — the cleaner director→camera seam. We keep euler overlay in A; standardizing on a view-matrix override is noted for B.
6. Migration / proof (the A "done" demo)¶
- Route
narrative.onIntrusion→EventBus.emit({kind:'intrusion'}); a subscriber turns it into akickmanoeuvre. The lateral brake is byte-for-byte the same envelope. - Re-express the rift barrel-roll as a director manoeuvre (or have rift enqueue it),
driven by the seeded clock instead of
Math.random(). - Verify no behaviour change via screenshots/logic tests.
7. Testing & verification¶
- Native logic tests (
bash scripts/run-native-tests.sh): determinism — same seed ⇒ same manoeuvre schedule + same event jitter; envelope math (value+slope 0 at ends). - Web typecheck (
npm run typecheck) must pass. verify-crt(node scripts/verify-crt.mjs): screenshot rift-roll and intrusion-hesitation before/after — must be visually identical.?event=<kind>harness hook (web) +--event(native harness) to fire one event in isolation for tuning, mirroring?threat=/--threat.- Host-boundary check still holds:
! grep -r "from 'obsidian'" src/engine/— the newmodes/andevents/files must not importobsidian.
8. Risks & invariants (must not regress)¶
- Single-writer fog/bloom. Conductor sets mults only; engine composes
base × multonce/frame; controller owns bases. Envelopes feed the mult — never the raw density/strength. - Parallax restore/snapshot dance. The director overlays between scene-update and parallax snapshot; it modifies the pose, never accumulates.
- C1 continuity. All camera motion value+slope zero at manoeuvre ends, or the roll snaps.
- One continuous clock. Do not reset CameraFly's per-instance clock on a manoeuvre hand-off, or the flight re-centers/re-levels visibly.
- calm mode. Every motion-heavy envelope/manoeuvre gated by
prefers-reduced-motion. - Native autoreleasepool + single-thread. No per-frame leaks, no async mutation.
- Two hand-mirrored schemas. Anything added on web must be mirrored native; no codegen.
9. Out of scope → next bricks¶
B (transitions / Terrain→City), C (procedural itinerary + narrative co-drive), D (combat actors), E (radio narrative), F (per-scene settings UI). Each gets its own spec → plan → implementation cycle, building on this backbone.