City 90° Corner — Implementation Plan¶
For agentic workers: this is the deferred hardest brick. It needs a build→look→tune loop (ideally on-device) — the corner geometry is bespoke and the seam where the two corridors meet is visually critical. Do NOT ship blind; verify each step with screenshots.
Goal: A convincing real 90° turn in the City scene — fly down a street canyon, round a corner at an intersection, continue down the cross-street. Per Johannes' choice: "echter 90°-Abzweig (groß)", not a banked approximation.
Architecture: Convert the City from the fixed +Z scroll-treadmill to the Void heading-frame model (camPath + heading + fwd/right), with a periodic ±π/2 heading turn at "intersections". Chunks become world-static and recycle heading-relative, rotated to the heading at respawn. A bespoke intersection piece bridges the two corridors at the turn.
Tech Stack: TypeScript/three.js (src/engine/engine/scenes/city.ts) + Swift/Metal
(native/macos/KuroNativeSaver/Core/CityScene.swift). Templates: void.ts heading-frame
(verified 2026-06-05), rift.ts seam continuity.
Recon map (full hooks, file:line): the roadmap-recon workflow output — section "recon:city-90". Read it before starting; it documents all 8 risks in detail.
Key decisions (resolved, so the build is unambiguous)¶
- Heading-frame, not scroll-axis-swap. The recon map's scroll-axis-swap (z↔x treadmills) needs two parallel treadmill systems + a bespoke corner. The Void heading-frame is already proven in this repo (void/wreckage curve + dodge) and unifies both legs into one model. Reuse it.
- Repeating turns, not a one-shot + warp-out. Keep the endless-screensaver model: heading turns ±π/2 periodically (every ~18-28s of flight) at "intersections". Avoids FilmDirector coordination and infinite-cross-street content worries.
- The corner is a rounded turn over ~2-3s, not a hard right-angle. A hard right-angle needs walls that meet at 90° (impossible to tile cleanly with straight chunks). A rounded turn (heading eases 0→±π/2 like void's turns, but a full quarter) lets the existing straight chunks approximate the bend as they recycle into the new heading — same trick that makes void's curve read. The "intersection" is the open plaza at the bend's apex.
- Plaza apex piece. At the apex, spawn a one-off open square (ground only, buildings pulled back) so the bend reads as a real intersection, not a kink in a wall. This is the only bespoke geometry.
Risks carried from the recon map (mitigations baked into the steps)¶
- R1 treadmill not heading-aware → replaced wholesale by heading-frame (Task 2).
- R3 CameraFly weave must be right-frame → apply
f.xviaright(Task 2, Step 3). - R4 stars/skyline hardcoded in z → re-seed stars isotropic; drop the z=-900 skyline plane (Task 4).
- R6 native
speedPerSecbaked at init → switch to per-framectx.speed()(Task 5, Step 1). - R7 dust recycle is z-only → make heading-relative like void's speed particles (Task 3).
- R8 chunk seam density jump → acceptable (already true on z); note in
log, don't over-engineer.
Task 1: Heading-frame state + remove the +Z scroll (web)¶
Files: Modify src/engine/engine/scenes/city.ts (build closure + updater).
- [ ] Add build-scope state mirroring void:
let heading=0, headTarget=0, prevHeading=0, nextTurnT = 16 + rng()*10;const camPath = new THREE.Vector3();const fwd = new THREE.Vector3(0,0,-1), right = new THREE.Vector3(1,0,0); - [ ] In the updater, replace
cA.position.z += spd; cB.position.z += spdand the z-wrap with: - periodic turn:
if (t > nextTurnT) { headTarget += (rng()<0.5?-1:1)*Math.PI/2; nextTurnT = t + 18 + rng()*10; }(a full quarter-turn). - ease:
heading += (headTarget-heading)*(1-Math.exp(-dts/2.2));(slower than void's 1.8 — a city turn is statelier). Setfwd/rightfrom heading. camPath.addScaledVector(fwd, spd*?)— match the old effective forward speed (old scroll wasspdon a 320-long chunk; pick a forward rate that feels equivalent, ~spd*1.0, tune visually).- [ ] Verify:
npm run typecheck. Scene still renders (turns will look wrong until Task 2).
Task 2: Chunk recycle heading-relative + rotated (web)¶
Files: Modify city.ts updater + chunk init.
- [ ] Track each chunk's world center. Recycle when
(center - camPath)·fwd < -CL/2: reposition tocamPath + fwd*(CL*1.5)and setchunk.rotation.y = heading(so the corridor aligns to the current heading), and re-roll its seed for variety (rebuild or pre-build a small pool of 3-4 chunk groups and cycle them). - [ ] Camera pose:
cam.position = camPath + right*(f.x)(weave in the right-frame, R3),cam.position.y = max(1.8, f.y),cam.rotation.y = heading + f.yaw, bank into the turn like void (f.roll + clamp(-turnRate*2.5, ±0.5)). - [ ] Verify with screenshots: fly straight, then a turn — the canyon should bend and the camera round it. Tune forward speed, turn ease, and chunk spacing until the bend reads.
Task 3: Dust heading-relative (web)¶
Files: city.ts updater dust loop.
- [ ] Replace the
dz > 10 → dz -= 720z-recycle with void's speed-particle model: recycle when(pos - camPath)·fwd < near→ respawn atcamPath + fwd*far + right*spread. - [ ] Verify: dust streams along the flight direction through a turn (no pile-up — R7).
Task 4: Plaza apex piece + stars/skyline fix (web)¶
Files: city.ts.
- [ ] Build a one-off plaza group (large ground plane, no buildings, maybe 2-4 landmark towers
at the block corners). When a turn fires, position it at
camPath + fwd*(turn lookahead)so the bend's apex lands on the open square. Hide/reposition it between turns. - [ ] Re-seed stars isotropic (
(r()-0.5)*700on BOTH x and z, not z-biased). Drop thez=-900skyline plane (it only made sense for a fixed heading — R4). - [ ] Verify: a full turn reads as rounding an intersection, not clipping a wall.
Task 5: Native parity (CityScene.swift)¶
Files: Modify native/macos/KuroNativeSaver/Core/CityScene.swift; touch
Renderer.swift only if needed.
- [ ] Step 1: fix
speedPerSecbaked-at-init → readctx.speed()per frame (R6). Mirror the void heading-frame fields + logic added in Tasks 1-3. TheChunkstruct keepsitemsin local coords; per frame setmodel = translation(camPath-relative) * rotationY(heading). - [ ] Plaza piece as a non-recycling
[DrawItem]added toitems, repositioned per turn. - [ ] Verify:
bash scripts/run-native-tests.sh(ALL PASS) + the harness compiles allCore/*.swift.
Task 6: Tune + commit¶
- [ ] On-device (or web screenshots) tune: turn cadence, ease rate, plaza scale, seam at the bend. This is the build→look→tune loop the brick was deferred for.
- [ ] Commit web + native together once the turn reads convincingly.
Self-review notes¶
- The heading-frame conversion is the same shape as the void curve fix shipped 2026-06-05 (commit 348ba7f) — reuse that code as the literal template, including the lateral-recycle insight (LAT_MAX) so the canyon stays dense through the turn.
- If the rounded-turn approximation looks too loose for "echter 90°-Abzweig", the fallback is the recon map's bespoke two-treadmill + hard corner — more work, sharper result. Decide after seeing the rounded version on-device.