# SIDE B — Lane S handoff (fx/ui ambience) Dust bunnies, the vinyl-warp portal iris, and the HUD stamp tray + stamp celebrations. All bus-driven, no imports from any other lane, nothing outside `src/fx/**` + `src/ui/**` touched. **Not committed** (integrator commits). ## Files | file | change | |---|---| | `src/fx/bunnies.ts` | NEW — `DustBunnies`: 4 fluff-ball critters on the under-table floor | | `src/fx/portalIris.ts` | NEW — `PortalIris`: full-screen 2D-canvas groove-ring iris | | `src/fx/FxSystem.ts` | additive — `attachAmbience()`, iris + bunnies update, stamp confetti, `stamp:all` emissive shimmer | | `src/ui/Hud.ts` | additive — stamp tray (3 accent dots, top-right under the signal lamps) | | `src/fx/HANDOFF.md`, `src/ui/HANDOFF.md` | SIDE B sections appended | ## Integrator wiring — ONE line in `src/main.ts` ```ts // after the player is constructed (mirrors the fx.setBeacons pattern): fx.attachAmbience(world, player); ``` That's it. Everything else self-wires to the bus in the existing `FxSystem`/`Hud` constructors already called from `main.ts`. Without the line, bunnies simply don't spawn (and stamp confetti falls back to the world's `entry` anchor); iris + tray + shimmer work regardless. ## Events **Consumed (new this phase):** `world:enter`, `world:exit` (iris), `stamp:got` (tray dot + pulse, accent confetti at the player), `stamp:all` (gold tray rings, booth-wide emissive shimmer via the existing `setEmissiveBoost` path, gold confetti at the three crate portal discs). **Emitted:** `machine:interact { machineId: 'bunny', action: 'bunny_flee' }` — on the wander→flee transition only, globally rate-limited to 1/s (a 10 s continuous chase produces 1 emit, not 10 — the squeak marks the spook, per brief "at most 1/s"). Lane E2 maps it to a squeak. ## Behaviour details - **Bunnies** (`DustBunnies`): 4 critters, each a single mesh of 8–12 jittered dust-grey quads (soft glow-texture alpha, normal blending — 4 draw calls total). Homes: 2 in the record-crate area, 1 mixer PCB room, 1 deck-A PCB room, leashed to 14 voxels so they stay findable. Wander ≈0.3–0.8 v/s with idle pauses; spook at 5 voxels (player must be within 4 y — tabletop players don't spook them); flee exactly 8 voxels with ease-out (measured ramp 7→5.2→3.6→2.4→1.3 v/s), then settle (tremble) and resume. Movement uses a `world.isSolid` lookahead: ground must be reachable within ±1 voxel (overhangs are never ground — that's what keeps them out of the crate's edge-standing records), keep-out rings are steered around, fleeing tries ±45°/±90° deflections before giving up. If someone builds a block into a bunny it pops on top. - **Keep-out zones:** the three `WORLD_DEFS[k].portalStand` ±6 rings, the parts-bin tray and the fuse-box spot. The last two mirror `worldgen/anchors.ts` arithmetic (`M.minX+15/M.minZ+10`, `M.minX+24/M.maxZ-8`) derived from `LAYOUT` — same convention as `fx/layout.ts` VU towers. If Lane C ever moves the `UNDER` anchors, update `KEEP_OUT` in `bunnies.ts`. - **Iris** (`PortalIris`): 400 ms ease-in collapse → 90 ms full black → 400 ms release; groove rings drift INTO the aperture on `world:enter`, OUT on `world:exit`; accent lip + rings tinted from `WORLD_DEFS[world].accent` (warm-white fallback for unknown keys). Pure screen overlay on a `position:fixed` canvas at **z-index 9 — under the HUD (z 10)**, so start/pause overlays and subtitles are never covered; `pointer-events:none`; the camera (incl. `TURNCRAFT_CINE` / the flythrough) is never touched. Timeline runs on `performance.now()` inside `fx.update()` (no private rAF loop); idle = `display:none` + a single boolean check per tick. Retrigger mid-animation resumes from the current coverage (no pop). Canvas re-syncs its size on every trigger (guards embedded panes that boot with a 0-sized window). - **Stamp tray** (`Hud`): three 15 px record dots (with a centre "spindle hole") at `top:64px; right:14px`, right under the signal-lamp tracker, in `WORLD_KEYS` order with `WORLD_DEFS` accents. Dim ring when not got; accent fill + glow when got; scale-pulse on a live `stamp:got`; gold rings on all three via `.tc-stamps.all` on `stamp:all`. - **`stamp:all` shimmer:** 2.6 s additive boost wave (fast ramp, ~2 Hz glitter oscillation, ease-out) through the SAME injected `setEmissiveBoost` path the LED pulse uses — composes with (never replaces) the win/reset timelines. ## Join-replay coordination (Lane M — please confirm timing) Lane M re-emits `stamp:got` (and `stamp:all` when applicable) from the join snapshot to seed state. Both `FxSystem` and `Hud` treat stamp events arriving **< 4 s after construction** as replays: state applies (dots light, gold ring sticks) but celebrations are suppressed (no pulse, no confetti, no shimmer). A genuine stamp can't happen 4 s into a session, so there's no false-suppress risk; if the relay handshake can complete LATER than ~4 s after page load, the window constant lives in `FxSystem.isJoinReplay()` and `Hud.bornAt` uses — bump both. **Verified against Lane M's real relay during this session:** their join replay lit all three dots + gold silently; a live re-emit afterwards pulsed and threw confetti. ## Flag for the integrator — disco `disco_cue` lighting `SIDEB_MACHINES.md` M2 says Lane M emits `machine:interact {action:'disco_cue', index}` "so Lane S can light them", but `SIDEB_WORLDS.md` says "Lane M lights them" and my brief (`SIDEB_SOCIAL.md`) scopes Lane S to bunnies/iris/tray only — and the contract `machine:interact` payload has no `index` field. **No disco-tile lighting exists in Lane S.** If Lane M's handoff defines an encoding (e.g. index in the action string), a small FX reaction can be added on request. ## Verification (live game, port 5205, real code paths) - Typecheck: exit 0 (whole tree). - Bunnies, 60 sim-seconds sweep: 0 solid-cell penetrations, 0 keep-out violations (closest approach 6.14), max ground y=4 (rides copper traces, never climbs structures), all 4 wandered 3.9–8.4 voxels. Flee: exactly 8.00 voxels, ease-out ramp above, settle → wander. Emits: 1 per spook; 10 s continuous chase → 1 emit. **Zero steady-state allocation: 0 KB heap delta over 3600 warm ticks.** - Iris phases probed pixel-level on the overlay canvas: 210 ms centre open / edges black, 430 ms full black, 700 ms releasing, 900 ms hidden + `display:none`; compositing over the game verified by screenshot; HUD stays above it. - Tray: dots at top:64/right:14 with correct accents; got/pulse/all classes exercised by hand-emitted events AND by Lane M's real relay replay (silent) + live emits (pulsed). Visually confirmed lit in-game. - `stamp:all`: shimmer curve measured through the boost path (0.56→1.02→glitter→base over 2.6 s); gold confetti seen over the portal discs. - **fps unchanged:** warm A/B with forced GPU sync — bunnies visible 1.124 ms/frame vs hidden 1.094 ms (~890 vs ~914 fps-equiv; delta = 4 tiny draw calls); `fx.update` 7.6 µs/tick warm with everything attached; iris actively closing ≈ 1.08 ms/frame. All ~9× inside the 100 fps budget. - Zero console errors from fx/ui across the session (only the pre-existing relay-offline WebSocket retry noise, which predates this phase).