Flight Director Foundation Implementation Plan¶
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Build the backbone (FlightDirector + EventBus + transient-envelope layer + continuous speed scalar) that later film bricks hang off, proven by re-routing two existing behaviours (intrusion-hesitation, rift barrel-roll) through it with zero visible change.
Architecture: A FlightDirector post-processes the camera each frame at the exact seam the inline hesitation brake uses today (after the scene updater, around the parallax snapshot). An EventBus carries seeded, platform-neutral events; the conductor (ReactiveWorld web / Renderer.updateThreat native) emits them and advances a list of one-shot transient envelopes that add to the threat-derived mults — never overwriting fog/bloom. Scenes read forward speed via a new ctx.speed() thunk (enum base × director multiplier, =1 in this brick).
Tech Stack: TypeScript + three.js (web, src/engine/); Swift + Metal (native, native/macos/KuroNativeSaver/Core/); assert-based native logic harness (scripts/run-native-tests.sh → tests/main.swift); web verification via npm run typecheck + node scripts/verify-crt.mjs.
Testing reality (read before starting): This repo has no web unit-test runner by design. The canonical, test-first numeric contracts (envelope math, determinism) live in the native assert harness (tests/main.swift) — which doubles as the web↔native parity reference, exactly like the existing LCG/terrain tests. The web side is verified by typecheck + verify-crt visual parity (intrusion/rift identical before↔after). Do not add a web test framework in this brick.
File Structure¶
New files
- src/engine/events/bus.ts — typed synchronous EventBus (web). One responsibility: pub/sub of FlightEvent.
- src/engine/modes/flight-director.ts — FlightDirector (web): manoeuvre queue + continuous clock + speed multiplier + camera overlay.
- native/macos/KuroNativeSaver/Core/EventBus.swift — Swift mirror of bus.ts.
- native/macos/KuroNativeSaver/Core/FlightDirector.swift — Swift mirror of flight-director.ts.
Modified files (web)
- src/engine/engine/core.ts — own bus + director; replace inline hesitation block with director.apply(...); add speed() to SceneCtx construction; route pulseHesitation through the director.
- src/engine/engine/scenes/scene-base.ts — add speed?: () => number to SceneCtx.
- src/engine/fx/reactive-world.ts — emit intrusion to the bus; add the transient-envelope list + advance it in update().
- src/engine/engine/scenes/rift.ts — re-express the barrel-roll as a director roll manoeuvre; remove Math.random() (seeded).
- src/engine/engine/scenes/tunnel.ts — boost shake → seeded rng; read ctx.speed().
- src/engine/engine/scenes/{terrain,city,void}.ts — read ctx.speed() instead of SPEED_VALUES[settings.speed].
- src/screensaver/main.ts — parse ?event=<kind> into a one-shot harness trigger.
Modified files (native)
- Core/Renderer.swift — own bus + director; mirror the hesitation→director.apply swap; emit intrusion to bus; advance envelopes in updateThreat.
- Core/Scene.swift — add speed to SceneContext.
- the native rift/tunnel/terrain/city/void scenes — mirror the web scene changes.
- tests/main.swift — add envelope-math + determinism checks.
PHASE 1 — Web¶
Task 1: EventBus (web)¶
Files:
- Create: src/engine/events/bus.ts
- [ ] Step 1: Write the module
// EventBus — typed, synchronous, multi-producer/multi-consumer channel for
// discrete flight/narrative events. Settled within a frame (handlers run on emit),
// so events never land a frame late. Randomness a handler needs is drawn from the
// engine's SEEDED rng by the EMITTER (e.g. ReactiveWorld), never Math.random here.
export type FlightEventKind =
| 'intrusion' // the operator "notices" — migrated from narrative.onIntrusion
| 'manoeuvre'; // a director manoeuvre fired (for narrative/world to react to, later)
export interface FlightEvent {
kind: FlightEventKind;
/** event-specific scalar (e.g. intrusion → hesitation duration in seconds). */
intensity?: number;
/** -1 | 0 | +1 lateral/directional hint where meaningful. */
dir?: number;
}
type Handler = (e: FlightEvent) => void;
export class EventBus {
private subs = new Map<FlightEventKind | '*', Set<Handler>>();
subscribe(kind: FlightEventKind | '*', fn: Handler): () => void {
let set = this.subs.get(kind);
if (!set) { set = new Set(); this.subs.set(kind, set); }
set.add(fn);
return () => set!.delete(fn);
}
emit(e: FlightEvent): void {
this.subs.get(e.kind)?.forEach((fn) => fn(e));
this.subs.get('*')?.forEach((fn) => fn(e));
}
}
- [ ] Step 2: Typecheck
Run: npm run typecheck
Expected: PASS (no errors).
- [ ] Step 3: Commit
git add src/engine/events/bus.ts
git commit -m "feat(engine): typed EventBus for discrete flight events"
Task 2: FlightDirector (web)¶
Files:
- Create: src/engine/modes/flight-director.ts
- [ ] Step 1: Write the module
// FlightDirector — sequences camera manoeuvres on ONE continuous clock and owns the
// continuous speed multiplier. It POST-PROCESSES the pose the scene already wrote
// (additive, like the parallax/hesitation seam), and NEVER writes fog/bloom.
// Camera ownership note: in this brick the director only overlays (ownsCamera=false);
// full ownership during transitions arrives in Brick B.
import { mkRng } from '../engine/rng';
import type { EventBus, FlightEvent } from '../events/bus';
export type ManoeuvreKind = 'kick' | 'roll' | 'dive' | 'climb' | 'bank';
export interface Manoeuvre {
kind: ManoeuvreKind;
dur: number; // seconds
dir: number; // -1 | 0 | +1
intensity: number; // unit-ish amplitude
start?: number; // filled when dequeued (continuous director clock)
}
/** sin²(πp): value AND slope are 0 at p=0 and p=1 → chained manoeuvres never snap. */
export function envelope(p: number): number {
if (p <= 0 || p >= 1) return 0;
const s = Math.sin(Math.PI * p);
return s * s;
}
export interface CameraTarget {
position: { x: number; y: number };
rotation: { z: number };
}
export class FlightDirector {
private clock = 0;
private active: Manoeuvre[] = [];
private speedMul = 1; // ramps in Brick B (warp); 1 here
ownsCamera = false;
private rng: () => number;
constructor(seed: number, bus?: EventBus) {
this.rng = mkRng(seed ^ 0x9e3779b9);
bus?.subscribe('intrusion', (e: FlightEvent) =>
this.enqueue({ kind: 'kick', dur: Math.max(0.3, e.intensity ?? 1.5), dir: 0, intensity: 0.55 }),
);
}
/** continuous forward-speed multiplier (enum base is applied by the caller). */
speed(): number { return this.speedMul; }
enqueue(m: Manoeuvre): void { this.active.push(m); }
/** advance the director clock and retire finished manoeuvres. Call once/frame. */
update(_t: number, dt: number): void {
this.clock += dt / 1000;
for (const m of this.active) if (m.start == null) m.start = this.clock;
this.active = this.active.filter((m) => (this.clock - (m.start as number)) / m.dur < 1);
}
/** additive overlay on the pose the scene just wrote (modifies in place). */
apply(cam: CameraTarget): void {
for (const m of this.active) {
const p = (this.clock - (m.start as number)) / m.dur;
const env = envelope(p);
if (env === 0) continue;
switch (m.kind) {
case 'kick': cam.position.x *= 1 - m.intensity * env; break; // lateral steadying (== old hesitation)
case 'roll': cam.rotation.z += m.dir * m.intensity * env; break; // barrel-roll
case 'bank': cam.position.x += m.dir * m.intensity * env; cam.rotation.z += m.dir * 0.3 * env; break;
case 'dive': cam.position.y -= m.intensity * env; break;
case 'climb': cam.position.y += m.intensity * env; break;
}
}
}
}
- [ ] Step 2: Typecheck
Run: npm run typecheck
Expected: PASS.
- [ ] Step 3: Commit
git add src/engine/modes/flight-director.ts
git commit -m "feat(engine): FlightDirector — manoeuvre queue + continuous clock + speed scalar"
Task 3: Add speed() to SceneCtx¶
Files:
- Modify: src/engine/engine/scenes/scene-base.ts
- [ ] Step 1: Add the thunk to the interface
In SceneCtx, after the storm?: () => number; line, add:
/** Effective forward-speed multiplier = SPEED_VALUES[settings.speed] × director.
* Scenes should read this instead of the enum directly (enables warp ramps). */
speed?: () => number;
- [ ] Step 2: Typecheck
Run: npm run typecheck
Expected: PASS.
Task 4: Wire bus + director into the Engine¶
Files:
- Modify: src/engine/engine/core.ts
- [ ] Step 1: Import + fields
Add imports near the top (with the other ./ imports):
import { EventBus } from '../events/bus';
import { FlightDirector } from '../modes/flight-director';
import { SPEED_VALUES } from '../data/defaults';
SPEED_VALUES is already imported, skip that line.)
Add fields to the Engine class (near onFrame):
/** Discrete-event channel shared by conductor, scenes and (later) terminal. */
bus = new EventBus();
/** Camera/flight director — overlays manoeuvres + owns the speed multiplier. */
director!: FlightDirector;
- [ ] Step 2: Construct the director where
seedis set
After this.rng = mkRng(this.seed); (constructor, ~line 277) add:
- [ ] Step 3: Provide
speed()to scenes
In loadScene(), in the ctx object literal (~line 422-426), add after the threat/storm line:
- [ ] Step 4: Drive the director in the tick + replace the inline hesitation
In start()'s tick, replace the hesitation block (current core.ts:488-498, the if (this._hesT0 >= 0) { ... }) with:
// Director: advance its clock, then overlay manoeuvres on the pose the scene
// wrote. The 'kick' manoeuvre reproduces the old hesitation brake exactly.
this.director.update(this.clockT, dt);
this.director.apply(this.cam);
- [ ] Step 5: Route
pulseHesitationthrough the director (back-compat shim)
Replace the body of pulseHesitation (~line 346-350) with:
pulseHesitation(durationSec: number) {
this.director.enqueue({ kind: 'kick', dur: Math.max(0.3, durationSec), dir: 0, intensity: 0.55 });
}
_hesT0 / _hesDur fields removed if the typecheck flags them; otherwise leave them (no behaviour). Prefer removing both field declarations to keep it clean.
- [ ] Step 6: Typecheck
Run: npm run typecheck
Expected: PASS.
- [ ] Step 7: Commit
git add src/engine/engine/core.ts src/engine/engine/scenes/scene-base.ts
git commit -m "feat(engine): wire EventBus + FlightDirector into the tick; speed() thunk"
Task 5: Conductor emits intrusion + transient-envelope layer¶
Files:
- Modify: src/engine/fx/reactive-world.ts
- [ ] Step 1: Emit to the bus instead of calling pulseHesitation directly
Replace onIntrusion() (lines 61-64) with:
private onIntrusion() {
if (this.d.calm) return;
this.d.engine.bus.emit({ kind: 'intrusion', intensity: 1.5 + this.rand() * 1.5 });
}
this.rand() keeps the duration deterministic; the director subscriber turns it into the same kick.)
- [ ] Step 2: Add the transient-envelope list
Add a field + a small advance helper. Field (near private threat = 0;):
/** One-shot additive envelopes layered on the threat-derived mults (NOT threat). */
private envelopes: { t0: number; dur: number; bloom: number; fog: number }[] = [];
Add a public emitter (used by the ?event= harness and, later, combat events):
/** Fire a one-shot world envelope (additive bump on bloom/fog mults). calm-gated. */
pulse(kind: 'flash' | 'surge', t: number) {
if (this.d.calm) return;
if (kind === 'flash') this.envelopes.push({ t0: t, dur: 0.6, bloom: 0.6, fog: 0 });
else this.envelopes.push({ t0: t, dur: 1.2, bloom: 0.3, fog: -0.1 });
}
- [ ] Step 3: Advance envelopes in
update()and fold into the mults
update(_t, dt) currently ignores _t; rename it to t. Just before the e.fogThreatMult = ... / e.setBloomThreat(...) writes, compute the additive deltas:
// ── transient one-shot envelopes (additive on the mults; never raw fog/bloom) ──
let bloomAdd = 0, fogAdd = 0;
this.envelopes = this.envelopes.filter((ev) => {
const p = (t - ev.t0) / ev.dur;
if (p >= 1) return false;
const s = Math.sin(Math.PI * p), env = s * s;
bloomAdd += ev.bloom * env; fogAdd += ev.fog * env;
return true;
});
Then change the two writes to include the adds:
e.fogThreatMult = 1 + threat * (calm ? 0.6 : 1.6) + fogAdd;
...
e.setBloomThreat(1 + storm * 0.4 + bloomAdd);
(Update the method signature to update(t: number, dt: number) and the onFrame arrow already passes t.)
- [ ] Step 4: Reset envelopes on dispose
In dispose(), add this.envelopes = []; so disabling REACT returns the world to neutral.
- [ ] Step 5: Typecheck
Run: npm run typecheck
Expected: PASS.
- [ ] Step 6: Commit
git add src/engine/fx/reactive-world.ts
git commit -m "feat(engine): conductor emits intrusion to the bus + transient-envelope layer"
Task 6: Determinism cleanup + scene migrations (web)¶
Files:
- Modify: src/engine/engine/scenes/rift.ts, tunnel.ts, terrain.ts, city.ts, void.ts
- [ ] Step 1: rift barrel-roll → director manoeuvre, seeded gap
In rift.ts, the build closure receives ctx. Capture const dir = ctx. Replace the Math.random() roll-gap (line 127) with the scene's seeded ctx.rng():
If rift applies the roll inline to the camera, leave the visual identical but additionally enqueue the equivalent director manoeuvre when a roll starts (proves the queue):
Concretely, where the roll is triggered, add: // (optional, proves the queue) enqueue a director roll of the same shape:
// engineDirector.enqueue({ kind:'roll', dur: ROLL_DUR, dir, intensity: Math.PI*2 })
roll kind exists and is unit-tested in native (Task 12); wiring rift to enqueue it is deferred to Brick B's transition work. Keep rift's existing inline roll.
- [ ] Step 2: tunnel boost shake → seeded; read ctx.speed()
In tunnel.ts, replace lines 309-310:
SPEED_VALUES[settings.speed] read with (ctx.speed?.() ?? SPEED_VALUES[settings.speed]).
- [ ] Step 3: terrain/city/void read ctx.speed()
In each of terrain.ts, city.ts, void.ts, replace the SPEED_VALUES[settings.speed] read with (ctx.speed?.() ?? SPEED_VALUES[settings.speed]). Keep each scene's own multiplier factor (terrain *0.2, void *14*dt, etc.) unchanged.
- [ ] Step 4: Typecheck
Run: npm run typecheck
Expected: PASS.
- [ ] Step 5: Commit
git add src/engine/engine/scenes/
git commit -m "fix(engine): seed the last Math.random leaks; scenes read ctx.speed()"
Task 7: ?event= harness hook (web)¶
Files:
- Modify: src/screensaver/main.ts
- [ ] Step 1: Parse the param and fire once after open
Where other URL params are parsed into WebHost.overrides, read ?event=. After the engine/controller is open and running, if present, fire it once via the bus / conductor pulse:
const ev = new URLSearchParams(location.search).get('event');
if (ev === 'intrusion') engine.bus.emit({ kind: 'intrusion', intensity: 2.0 });
else if (ev === 'flash' || ev === 'surge') reactiveWorld?.pulse(ev, engineClockNow());
intrusion (sufficient for tuning the camera path). Keep it behind the same DEV guard the other debug hooks use.
- [ ] Step 2: Typecheck
Run: npm run typecheck
Expected: PASS.
- [ ] Step 3: Commit
git add src/screensaver/main.ts
git commit -m "feat(harness): ?event= one-shot trigger for tuning flight reactions"
Task 8: Web visual verification (no behaviour change)¶
- [ ] Step 1: Start dev server
Run: npm run dev (leave running on :5173).
- [ ] Step 2: Screenshot intrusion + rift before/after
Run: THREAT=0.3 node scripts/verify-crt.mjs "rift:phosphor" "void:phosphor"
Then with an event: node scripts/verify-crt.mjs "void:phosphor" after appending ?event=intrusion (or set via the harness).
Expected: rift barrel-roll and the intrusion lateral-steadying look identical to the pre-change build (compare against a screenshot taken from main). No snaps, no drift.
- [ ] Step 3: Host-boundary check
Run: ! grep -rn "from 'obsidian'" src/engine/
Expected: no matches (the new modes/ and events/ files must not import obsidian).
PHASE 2 — Native parity¶
Each native task MIRRORS the web change of the same name. Before editing, read the target Swift file region to anchor the edit; the web implementation above is the spec-of-record. Keep field names / math identical so the files diff cleanly. Honour: autoreleasepool (no per-frame leaks), single-thread (no async), single-writer fog/bloom.
Task 9: EventBus.swift¶
Files:
- Create: native/macos/KuroNativeSaver/Core/EventBus.swift
- [ ] Step 1: Write the mirror
// EventBus — Swift mirror of src/engine/events/bus.ts.
enum FlightEventKind { case intrusion, manoeuvre }
struct FlightEvent { var kind: FlightEventKind; var intensity: Double = 0; var dir: Double = 0 }
final class EventBus {
private var subs: [ObjectIdentifier: (FlightEvent) -> Void] = [:]
private var byKind: [Int: [(FlightEvent) -> Void]] = [:]
func subscribe(_ kind: FlightEventKind, _ fn: @escaping (FlightEvent) -> Void) {
byKind[kind.idx, default: []].append(fn)
}
func emit(_ e: FlightEvent) { byKind[e.kind.idx]?.forEach { $0(e) } }
}
private extension FlightEventKind { var idx: Int { self == .intrusion ? 0 : 1 } }
- [ ] Step 2: Build the renderer (compile check)
Run: bash scripts/run-native-tests.sh
Expected: builds (tests still pass — nothing references the bus yet).
- [ ] Step 3: Commit
git add native/macos/KuroNativeSaver/Core/EventBus.swift
git commit -m "feat(native): EventBus mirror"
Task 10: FlightDirector.swift¶
Files:
- Create: native/macos/KuroNativeSaver/Core/FlightDirector.swift
-
[ ] Step 1: Write the mirror (same fields, same
envelope, same manoeuvre kinds +applymath as flight-director.ts). Use the nativeLCGrng (seed ^ 0x9e3779b9). Subscribe tobus.intrusion→ enqueue akickofmax(0.3, e.intensity)intensity 0.55.apply(_ cam: inout Camera)mutatescam.position.x/yand the roll component. -
[ ] Step 2: Build
Run: bash scripts/run-native-tests.sh
Expected: builds.
- [ ] Step 3: Commit
git add native/macos/KuroNativeSaver/Core/FlightDirector.swift
git commit -m "feat(native): FlightDirector mirror"
Task 11: Wire bus + director into Renderer.swift; SceneContext.speed¶
Files:
- Modify: Core/Renderer.swift, Core/Scene.swift, native rift/tunnel/terrain/city/void scenes
- [ ] Step 1: Read
Renderer.swiftadvance(dt)+ the hesitation envelope +updateThreat. Addbus/directorproperties (director built where the scene LCG seed is known). Mirror the web tick: afterscene.updateand where the inline hesitation runs, calldirector.update+director.apply(&scene.camera). InupdateThreat, where intrusion is detected (Terminal.onIntrusion),bus.emit(.init(kind:.intrusion, intensity: 1.5 + rand*1.5)). Add the transient-envelope list + fold into the fog/bloom mults (mirror Task 5), respecting the native single-writer composition. - [ ] Step 2: Add
var speed: () -> DoubletoSceneContext(Scene.swift); provide it where the context is built (speedBase × director.speed()). Migrate native rift/tunnel/terrain/city/void to read it. Seed any nativeFloat.random/drand48in tunnel/rift if present (verify — may already use LCG). - [ ] Step 3: Build + run tests
Run: bash scripts/run-native-tests.sh
Expected: PASS.
- [ ] Step 4: Commit
git add native/macos/KuroNativeSaver/Core/
git commit -m "feat(native): wire EventBus + FlightDirector + speed() into Renderer/scenes"
PHASE 3 — Tests + verification¶
Task 12: Native logic tests (the canonical TDD contracts)¶
Files:
- Modify: native/macos/KuroNativeSaver/tests/main.swift
- [ ] Step 1: Add envelope-math checks (write first, expect FAIL)
// --- FlightDirector envelope: value AND slope 0 at both ends, peak 1 at mid -------
do {
approx(Double(FlightDirector.envelope(0.0)), 0.0, 1e-12, "env(0)=0")
approx(Double(FlightDirector.envelope(1.0)), 0.0, 1e-12, "env(1)=0")
approx(Double(FlightDirector.envelope(0.5)), 1.0, 1e-9, "env(0.5)=1")
// slope ~0 at the ends (finite difference)
check(FlightDirector.envelope(0.001) < 1e-3, "env slope→0 at start")
check(FlightDirector.envelope(0.999) < 1e-3, "env slope→0 at end")
}
Run: bash scripts/run-native-tests.sh → Expected: FAIL (envelope not found) until Task 10 exposes it as a free/static function. (If Task 10 already landed, this passes — that is fine; the test still pins the contract.)
- [ ] Step 2: Add determinism check
// --- Director determinism: same seed ⇒ identical kick schedule ------------------
do {
let a = FlightDirector(seed: 1337); let b = FlightDirector(seed: 1337)
a.enqueue(.init(kind: .kick, dur: 1.0, dir: 0, intensity: 0.55))
b.enqueue(.init(kind: .kick, dur: 1.0, dir: 0, intensity: 0.55))
var camA = unitCam(); var camB = unitCam()
for _ in 0..<30 { a.update(0, 16); a.apply(&camA); b.update(0, 16); b.apply(&camB) }
approx(Double(camA.position.x), Double(camB.position.x), 1e-12, "director deterministic x")
}
unitCam() = a tiny helper building a Camera with position.x = 1; add it near the top of main.swift if no equivalent exists.)
- [ ] Step 3: Run
Run: bash scripts/run-native-tests.sh
Expected: PASS (all checks ok).
- [ ] Step 4: Commit
git add native/macos/KuroNativeSaver/tests/main.swift
git commit -m "test(native): FlightDirector envelope math + determinism"
Task 13: Final verification + branch wrap¶
- [ ] Step 1:
npm run typecheck→ PASS. - [ ] Step 2:
bash scripts/run-native-tests.sh→ PASS. - [ ] Step 3:
! grep -rn "from 'obsidian'" src/engine/→ no matches. - [ ] Step 4:
npm run dev+verify-crtrift/intrusion → visually identical tomain. - [ ] Step 5: Update the spec status to "implemented"; note any deviations.
- [ ] Step 6: Report results to the user (don't push — user pushes on request).
Self-Review (run before execution)¶
- Spec coverage: FlightDirector ✓ (T2/T10), EventBus ✓ (T1/T9), transient envelopes ✓ (T5/T11), always-event-reactive camera ✓ (director.apply overlay, T4/T11), speed scalar ✓ (T3/T6/T11), determinism + leak fixes ✓ (T6, T12), parity web-first→native ✓ (Phase 2),
?event=harness ✓ (T7), tests/verify ✓ (T8/T12/T13). North-star (Terrain→City) is correctly out of scope (Brick B) — the queue/clock/speed it needs are built here. - Placeholders: the rift "optional enqueue" snippet in T6 Step 1 is explicitly scoped down to "seeded gap only" for this brick with the rationale stated — not a TODO. No other placeholders.
- Type consistency:
FlightEvent {kind,intensity,dir},Manoeuvre {kind,dur,dir,intensity,start},envelope(p),director.{update,apply,enqueue,speed}used identically across web tasks and mirrored in native.