Three records in the under-table crate are pressed with whole scenes. E beside
one dives you into its pocket above the booth walls; complete its sound-first
quest to press a STAMP; all three raise the golden slipmat + a permanent bonus
percussion stem. Dust bunnies move in under the table.
- Worlds (Lane W): ACID WAREHOUSE / DUB CHAMBER / DISCO LOFT — sealed themed
pockets, portal rings, exit pads; deterministic, zero light leaks (incl. the
flythrough path), meshes unchanged at 469, build ~760 ms.
- Machines (Lane M): portal dive/exit/safety (membership-based escape catch),
tune-the-303 hold-sweep pedestals, carry-the-charge wobbling spring bridge,
beat-Simon dance floor with glow tiles; stamps store + relay {t:'stamp'}
(default-closed, persisted, join snapshot, fuzzed); golden slipmat; stamps
survive the reset ritual.
- Ambience (Lane S): dust bunnies (wander/flee, zero steady-state allocs),
vinyl-warp portal iris, HUD stamp tray, stamp confetti + golden shimmer.
- Audio (Lane E2): per-world grooves (acid 303 w/ fader-driven bands, dub
feedback-echo + spring, disco pentatonic tiles) over a boothGain
duck-and-handback that can never restore stale state; portal/stamp/golden
SFX; bonus stem.
Post-integration review (2 seam finders, 10 findings, all fixed + verified):
stamp events carry origin (local/remote/replay) so consumers celebrate
proportionately instead of wall-clock guessing — kills phantom join
celebrations, first-person confetti for peers' wins, and audio/visual grace
mismatches; reconnect re-uploads locally-won stamps the server missed;
relay saveState survives disk errors; pocket escape via mined floor counts as
a real exit at any altitude; exit-pad speed gate + dwell stops mid-quest
yank-outs; dive cooldown stops exit/re-dive ping-pong; locked acid bands
need deliberate holds (tap-spam dead); disco foot band excludes jump apex.
Verified: whole-tree typecheck + build clean; per-lane live verification
(27/27 worldgen assertions, relay fuzz, 18-phase audio matrix, fps unchanged);
integrated smoke: dive/exit/safety/stamps/golden + all origin paths live.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
157 lines
11 KiB
Markdown
157 lines
11 KiB
Markdown
# 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<WorldKey>): 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_<i>', action:'acid_knob', index}` | ~6/s while a band sweeps |
|
||
| `machine:interact {machineId:'acid_<i>', 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_<i>', action:'disco_cue', index}` | sequence playback, one per beat |
|
||
| `machine:interact {machineId:'disco_tile_<i>', 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.
|