# SIDE B — Lane M handoff (portals, world quests, stamps, relay) **Brief:** [docs/briefs/SIDEB_MACHINES.md](briefs/SIDEB_MACHINES.md) **Status:** ✅ All items delivered and verified live in the real game against a real local relay AND against Lane W's landed pocket geometry (their build arrived mid-session; every position below is aligned to their actual voxels, not brief guesses). `npm run typecheck` clean; zero console errors in the final build; `demo-machines.html` still runs standalone. **Not committed** — integrator commits (shared tree). --- ## Files touched (all inside Lane M ownership) | file | what | |---|---| | `src/machines/worlds/common.ts` | **new** — pocket bounds/nearest-world helpers, structural `teleport`/`isFlying` access to Lane B's player (no cross-lane import), `interactAt()` (the `index`-carrying `machine:interact` emitter), mulberry32 | | `src/machines/worlds/stamps.ts` | **new** — `Stamps` store (Set): idempotent `apply()` emits `stamp:got` / `stamp:all` + HUD toasts (suppressed by `quiet` for join replay); `Stamps.coerceKey` default-closed wire validation | | `src/machines/worlds/portal.ts` | **new** — three dive plates at the crate discs (E → `world:enter` + teleport to `entry`), exit pads (stand 0.25 s → `world:exit` + teleport to `returnPos`), y>141 out-of-pocket safety net, proximity HUD prompts, accent glow panes | | `src/machines/worlds/acidQuest.ts` | **new** — Tune the 303: 3 hold-E sweep pedestals, per-entry seeded hidden targets, release-inside-±0.07 locks, `fader:move acid_0..2` + `workshop:torque` gauge | | `src/machines/worlds/dubQuest.ts` | **new** — Feed the Spring: charge carry (wire-carry pattern), 8-segment wobbling kinematic deck overlay (travelling sine, honest `velocityAt`), pit-fall forgiveness, mid-deck mouth feed | | `src/machines/worlds/discoQuest.ts` | **new** — beat-Simon on the 4×4 tile grid: rounds 4→8, standing-on-tile detection, `audio:beat` sync + 122 BPM free-run fallback, **tile glow plates (lighting is Lane M's, per Lane S's scope)** | | `src/machines/machine.ts` | + `IdInteractions` (optional structural per-collider press/hold surface for world machines) | | `src/machines/index.ts` | constructs Portals + 3 quests + `Stamps` inside `createMachines`; `MachineSet.stamps`; `stamp:all` → deck A golden slipmat; re-exports | | `src/machines/platter.ts` | + `setGoldenSlipmat(on)` / `isGoldenSlipmat()` (gold emissive material swap, idempotent) | | `src/interact/interaction.ts` | generalized the workshop's press/hold routing: machines exposing `IdInteractions` get E with the hit collider id and may run hold-E; with none, behavior is byte-identical to before | | `src/net/NetClient.ts` | sends `{t:'stamp', w}` on local `stamp:got` (guarded, re-validated); applies remote `stamp` + `hello.state.stamps` (quiet) via `Stamps.apply` | | `server/relay.mjs` | **one additive message** `{t:'stamp', w}` — see protocol below | **No other lane's files were touched.** Lane W's geometry constants are mirrored (with file references), not imported — cross-lane imports stay contract-only. ## Integrator wiring needed in main.ts: **none** Everything rides existing wiring: the world machines are created inside `createMachines()` (colliders/scene/update/attachPlayer already flow through `MachineSet`), and the stamp sync lives in `NetClient`, which already receives `machines`. Two facts worth knowing, no action required: - `MachineSet` gained a `stamps: Stamps` field. - Portals reach `PlayerController.teleport` / `isFlying` **structurally** via `attachPlayer`'s object. A player without `teleport` (demo mock) means dives emit events but don't move — harmless in the demo, real game unaffected. ## Event vocabulary emitted (for Lanes S / E2) The `machine:interact` payloads carry the index BOTH ways E2's handoff asks for: **trailing digits of `machineId`** (`acid_0..2`, `disco_tile_0..15`) AND a runtime `index` property (typed-contract-safe via a widened variable — `core/events.ts` untouched). | event | when | |---|---| | `world:enter {world}` | E on a dive plate, before the teleport (S iris, E2 duck+groove) | | `world:exit {world}` | exit pad (0.25 s stand) or the y>141 safety net | | `fader:move {faderId:'acid_0'..'acid_2', value}` | pedestal sweeps; **initial (wrong) values re-emitted on `world:enter` + once more ~0.35 s later** (registration-order proofing) | | `machine:interact {machineId:'acid_', action:'acid_knob', index}` | ~6/s while a band sweeps | | `machine:interact {machineId:'acid_', action:'acid_lock', index}` | band locked (value snaps to target, so the last `fader:move` is exactly right) | | `machine:interact {machineId:'acid', action:'acid_tuned'}` | all three locked (fires on toy replays too; stamp events do not) | | `machine:interact {machineId:'dub', action:'dub_pickup' / 'dub_drop' / 'dub_feed'}` | charge taken / lost (pit fall OR leaving the pocket mid-carry) / fed to the tank | | `machine:interact {machineId:'disco_tile_', action:'disco_cue', index}` | sequence playback, one per beat | | `machine:interact {machineId:'disco_tile_', action:'disco_step', index}` | player steps a correct tile (E2 echoes a fifth down) | | `machine:interact {machineId:'disco', action:'disco_miss' / 'disco_lit'}` | wrong step (gentle reset) / full 8 done | | `machine:interact {machineId:'portals', action:'portal_dive' / 'portal_exit'}` | teleports (extra flavor hooks, ignorable) | | `stamp:got {world, count, total}` | stamp landed: local win, remote win, **and once per stamped world from the join snapshot** (idempotent — never re-fires for a world already applied) | | `stamp:all {}` | third stamp — exactly on the fresh transition, including when the join snapshot completes the set | | `workshop:torque {value / null}` | acid hold gauge (reuses the HUD radial) | | `workshop:msg {text}` | prompts/toasts (join-replay stamp toasts suppressed) | Consumed: `audio:beat` (disco show pacing; free-runs at 122 BPM when silent — the quest can't stall in a dead booth), `world:enter/exit` (self-wiring). **Join handshake (S §"Join-replay coordination", E2 §"boot handshake"):** `stamp:got`/`stamp:all` replays fire while `NetClient` handles `hello`, i.e. on the ws connect during page boot — measured well inside FxSystem's 4 s celebration-suppression grace. One edge: if the relay is unreachable at boot, the replay arrives on the first successful (re)connect (15 s backoff) and would land OUTSIDE the grace — rare, cosmetic (one extra confetti), noted. ## Relay protocol (additive, v3 + stamp) - Client → server `{t:'stamp', w}`: `w` must be exactly `acid|dub|disco` (default-closed; anything else dropped). Duplicate = dropped (no rebroadcast). Accept → persist **immediately**, rebroadcast to other peers. - `hello.state.stamps: string[]` — join snapshot; old clients ignore it, old state files load as no stamps. - **`reset` deliberately does NOT clear `stamps`** — stamps are pressed into the records, not the booth (M5). Comment sits in the reset case. ## Geometry alignment (to Lane W's landed build) - **Portals:** dive plate hugs each disc's lower rim (probed live: the rim overhangs the stand at eye height — solid y4 to z 35, y5 to z 37), centre `[discX+1, 4.0, 34.6]`, small enough not to wall the crate aisle. - **Acid:** plinth colliders/knobs at world centres `[52.5, ·, 44.5/52.5/60.5]`; our chrome knob rides ON Lane W's led pip voxel (top face 148), lock lamp above. - **Dub:** deck overlay x 223..226 (centre 224.5), z 40..64, base **147.62** (static deck top 147 — ours never dips below it: 147.07 min, so the ride never pops onto voxels). Mouth = their rca_gold inlay `[224,146,52]` under our floating deck → feeding is E on the deck within ±3.2 of z 52.5 (the pit's reach geometry makes the bridge the only spot). Charge home `[232,146.2,78]` in front of their horn (x 232 — their build + ours both keep the horn walk off the exit-pad lane; an earlier centred placement walked players over the pad, found live). Pit fall = feet < 143.6 while carrying (pit walks at 143, canyon at 144 stays safe). - **Disco:** tile centres at Lane W's **pitch-8** grid `x 384.5/392.5/400.5/408.5`, `z 52.5/60.5/68.5/76.5` (not the brief's pitch-6 sketch), 5×5 halves. Glow plates hover at y 144.05. - **Exit pads:** trigger centred on the pad's world centre (`exitPad+0.5`), ±1.3 with a **0.25 s dwell** — clipping a pad corner mid-quest can't yank you (the disco far row is 2 voxels from its pad); a 1 s post-teleport cooldown stops re-triggers. ## Design decisions worth knowing - **Acid locks evaluate on hold RELEASE** (release inside ±0.07 = lock, value snaps to target). Sweeping through the sweet spot doesn't auto-lock — you tune by ear and let go, radio style. Shift reverses the sweep; ends ping-pong. - **Quests replay as toys** once stamped: same loop, flourish message, no stamp events (Stamps.apply is the single idempotence gate, so E2's "4 bars of glory" only fires on real stamps). - Acid targets/values and disco sequences reseed per `world:enter`; disco sequences avoid immediate repeats (re-entry detection can't see doubles). - Dub deck `velocityAt` reports the true lateral surface velocity of the weave; vertical rides on Lane B's ride-snap (verified 89%+ grounded on the wobble at carry amplitude, worst-case rates ~25× inside the envelope). - The safety net skips flying players (dev flight stays usable) and counts as a `world:exit` of the nearest world so E2's duck always unwinds. ## Verification record (real game, port 5194; local relay only) - Relay (node scripts, fresh state): stamp round-trip A→B; **no sender echo**; duplicates dropped; **14-case fuzz** (bad types/keys/enums/proto/broken JSON) all dropped with the socket alive; join snapshot carries stamps; persistence + `winAt` rewind; **reset clears repairs, stamps survive**, quest replayable. `server/booth-state.json` deleted before AND after. - In-browser (headless-driven via `window.TURNCRAFT`, real raycast/E/hold routing): all three portals dive with prompts; exit pad + dwell + cooldown; safety net teleports non-flyers and leaves flyers alone; ACID three locks → stamp (exact vocab above, gauge shown/hidden); DUB grounded wobble ride, pickup, canyon-roam safe, pit fall → `dub_drop` + retry toast, feed → stamp; DISCO wrong-step forgiveness, rounds 4→8 with cue/step/plate lighting → stamp #3 → `stamp:all` → **golden slipmat**; reload → join replay (3× `stamp:got` quiet + golden restored ⇒ `stamp:all` fired); **full reset→re-win cycle**: reset broadcast (`by` name), booth dead, stamps 3/3 + golden intact, dead-booth dive+exit work, re-win with stamps intact. Zero console errors in the final build; `demo-machines.html` clean. ## Known edges - Late-reconnect join replay can land outside Lane S's 4 s grace (above). - The `stamp:got` HUD toast for REMOTE stamps shows live (by design — a peer pressing a stamp is multiplayer news); only join-snapshot replays are quiet. - If a future phase ever wants stamps resettable, that's a relay change (clear `stamps` in the reset case) + nothing client-side.