Reactive Narrative World — Implementation Plan¶
Executes the design in
docs/superpowers/specs/2026-06-04-reactive-narrative-world-design.md. Web-first. Each stage is independently shippable and leaves the world fully working. Verified via forced-phase/threat screenshots (Playwright). Frequent commits.
Goal: the 3D world reacts to the narrative phase (subtle build → dramatic payoff, crash = release).
Tech: TypeScript + three.js (web); a ReactiveWorld conductor reading the narrative phase, pushing per-frame params to the engine.
Stage 1 — Web MVP (signal + conductor + fog/CRT/camera/storm)¶
Task 1 — Narrative phase exposure¶
- Modify
src/engine/terminal/narrative.ts: - store
phaseStartedAtinenterPhase(). get phase(): Phase,get phaseProgress(): number(clamp(now-start)/(end-start)).forcePhase(p: Phase)— pin phase + cancel the scheduled transition (for?phase=tuning).- add
onIntrusion?: () => voidtoNarrativeRunnerDeps; call it where an intrusion event lands (beatIntrusion 60% branch, beatAlarm intrusion branch, beatPanic rapid-intrusion branch).
Task 2 — Engine hooks¶
- Modify
src/engine/engine/core.ts: - public
threat = 0,storm = 0;onFrame: ((t,dt)=>void) | null = null. - in
tick, callthis.onFrame?.(this.clockT, dt)at the TOP (beforecurrentUpdater) so threat is ready for scenes. setFogThreat(mult): store afogThreatMult; applyscene.fog.density = this._fogBase * mult— but the base is owned by controller/day-night. Simpler:setFogThreat(densityFinal)sets density directly as the LAST writer. ReactiveWorld computesbase * (1+threat*K)from a base it reads via a getter passed in.setBloomThreat(mult):bloomPass.strength = this._bloomBase * mult(store base on applyFxSettings).pulseHesitation(durSec): start an envelope (_hesT0,_hesDur); intickafter the scene/parallax step, if active, scale the camera's lateral offset toward centre by(1 - 0.6*sin²(π*p))— implement as a yaw/x nudge that decays. (CameraFly untouched.)SceneCtx(scene-base.ts): addthreat?: () => number,storm?: () => number; wire inloadSceneto returnthis.threat/this.storm.
Task 3 — ReactiveWorld conductor¶
- Create
src/engine/fx/reactive-world.ts: new ReactiveWorld({ engine, narrative, crt, baseFog: () => number, settings, forcedThreat?: number }).update(t, dt)(registered asengine.onFrame): computethreatTargetfrom phase band +smoothstep(phaseProgress); low-pass tothreat(τ=2.5 s; τ=0.4 s while relaxing after crash/ROUTINE reset);storm = smoothstep(0.7,1,threat).- push:
engine.threat/storm = …;engine.setFogThreat(baseFog()*(1+threat*1.6)); CRT viacrt.setIntensity(clamp(userIntensity+threat*0.7))+engine.crtPass.uniforms.halation/ntsc += threat*…(store user base, add);engine.setBloomThreat(1+storm*0.4). - on
narrative.onIntrusion→engine.pulseHesitation(1.5 + seededRand()*1.5). - respects
settings.narrativeReactiveWorld; reduced-motion "calm mode" caps (fog mult → 1+threat*0.6; no storm/bloom/hesitation/crt-glitch surge). forcedThreat(0..1) overrides the computed value when set.
Task 4 — Particle storm in scenes¶
terrain.ts: readctx.storm?.(); add lateral+ storm*12*sin(t*3.1)to dust + scale point size.void.ts: readctx.storm?.(); widenspeedPtsspawn spread*(1+storm*0.5).
Task 5 — Settings + control + forced params¶
data/defaults.ts:narrativeReactiveWorld: boolean(defaulttrue) in the settings + DEFAULT.controller.ts: constructReactiveWorldafter the narrative block (pass crt + a baseFog getter); FX+ control-bar toggle.src/screensaver/main.ts: parse?phase=(→narrative.forcePhase) and?threat=(→ ReactiveWorld.forcedThreat); thread into the controller open path.
Task 6 — Verify + tune + commit¶
- Extend
scripts/verify-crt.mjsto accept&phase=/&threat=(shot tokenscene:preset:p<phase>or env). - Screenshot ROUTINE vs PANIC for terrain/void/city; confirm subtle→dramatic; tune the K/τ numbers.
- typecheck + build; commit Stage 1.
Stage 2 — Enemy colour (web)¶
color.ts:enemyOf(c)— HSL hue +160°.core.ts:enemyMatspool +setEnemyFraction(f).- CITY + VOID: tag an infectable subset; crossfade accent→enemy twin by
enemyFraction = smoothstep(0.25,0.9,threat). - Verify + commit.
Stage 3 — Native port¶
Terminal.swift: exposephase+ progress.ReactiveWorld.swiftmirroring the threat math + fog/CRT/bloom/camera mappings.- enemy colour via a second accent in
SceneUniforms(heaviest; may land last). - PNG-verify via harness (add a
--threat/--phaseflag); commit.