SIDE B briefs + contracts: crate worlds, per-world quests, stamps

Contract: src/core/worlds.ts (three pocket worlds above the booth walls,
portal/entry/exit anchors, stamp count) + additive world/stamp bus events.
Briefs: SIDEB overview + WORLDS / MACHINES / SOCIAL(ambience) / AUDIO lanes,
rescoped around the already-shipped SOCIAL_RESET (emotes, reset ritual,
avatars) and first-five-minutes (heartbeat, beacons, flythrough) phases.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-08-16 14:55:12 +10:00
parent caac3da930
commit 758e9dc4d3
7 changed files with 369 additions and 0 deletions

53
docs/briefs/SIDEB.md Normal file
View File

@ -0,0 +1,53 @@
# SIDE B — the crate worlds (phase overview)
*The booth is fixed and living. Now the crate opens. Three records in the
under-table crate are pressed with whole scenes — dive in, do the thing the
sound asks, press your stamp, come home. Plus dust bunnies and the golden
finish for collecting all three.*
**Already shipped — do NOT rebuild (and read before touching anything
adjacent):** emotes (Z/X/C/V), avatar looks, the server-authoritative reset
ritual + cooldown, and the Tab roster are the SOCIAL_RESET phase
(`docs/briefs/SOCIAL_RESET.md`, commit 05c2765, `src/net/avatarLook.ts`,
`'player:emote'`/`'quest:reset'` events); the heartbeat, quest spark beacons,
first-visit flythrough, and `'block:mining'` juice are the first-five-minutes
pass (commit caac3da). SIDE B must coexist with all of it.
**Read first:** `docs/CONTRACTS.md`, `docs/DESIGN.md`,
`docs/INTEGRATION_NOTES.md`, and the new contract `src/core/worlds.ts`
(world keys, pocket bounds, portal/entry/exit anchors, emote vocab — FIXED,
do not edit). Bus additions are already in `src/core/events.ts`.
## The four lanes (disjoint ownership — see your own brief)
| Lane | Brief | Owns |
|---|---|---|
| W — worlds | SIDEB_WORLDS.md | `src/worldgen/worlds/**`, small additive hooks in `buildBooth.ts` |
| M — machines | SIDEB_MACHINES.md | `src/machines/**`, `src/interact/**`, `src/net/NetClient.ts`, `server/relay.mjs` |
| S — ambience | SIDEB_SOCIAL.md | `src/fx/**`, `src/ui/**` (NOT `Avatars.ts` — SOCIAL_RESET owns avatars now) |
| E2 — audio | SIDEB_AUDIO.md | `src/audio/**` |
**Merge points** (integrator wires them; keep your edits additive and tiny):
`src/main.ts`. Nobody edits `src/core/**` (contract), another lane's dirs, or
the atlas/renderer. **No new block ids** — id 31 stays reserved; palette from
existing 31 blocks + atlas-v2 paint variants.
## Shared design laws
1. **Sound first.** Every quest readable by ear; visuals confirm.
2. **Places, not points.** Pockets are rooms with a mood, not obstacle courses.
3. **Nothing hard-fails.** Quests are retryable; leaving mid-quest is fine.
4. **Multiplayer semantics:** pockets are *solo dives* — your avatar simply
moves there (pockets are inside the same voxel world, so peers technically
could follow; that's fine — treat co-visits as a bonus, not a design
dependency). Only STAMPS sync (global, like repairs).
5. **Zero binary assets.** Procedural canvas/synth only, as ever.
6. Determinism, budgets: booth build < 900 ms, chunk meshes < 700, 100+ fps.
## Verification bar (every lane)
`npm run typecheck` clean; your demo page (or the real game via
`window.TURNCRAFT`) exercised live with zero console errors; a handoff doc
`docs/SIDEB_<lane>_handoff.md` listing files touched, events emitted/consumed,
and anything the integrator must wire. **Do not commit** — shared tree; the
integrator commits.

View File

@ -0,0 +1,61 @@
# SIDE B — Lane E2: per-world grooves + SIDE B sound
Read `docs/briefs/SIDEB.md` + `src/core/worlds.ts` first. You own
**`src/audio/**`**. React to bus events only. The main booth groove/stems
engine is yours already — extend, don't fork.
## A1. World grooves (the big one)
On `world:enter {world}`: duck the booth mix out (300 ms) and start that
world's loop; on `world:exit`: reverse. Same synth toolkit (synth.ts), same
shrunk-time BPM feel. Keep each loop 24 bars, hypnotic, mixable levels.
- **acid**: 118 BPM, four-on-floor kick + off-hat, and THE 303: a 16-step
sawtooth acid line through a resonant lowpass (Q high) + accent/slide feel.
The line's filter cutoff is driven by three bands mapped to
`fader:move {faderId:'acid_0'|'acid_1'|'acid_2'}` (0..1 from Lane M's
pedestals): 0 = detuned/wrong (cutoff badly off per-band offsets), target ≈
band's sweet value (Lane M owns the targets; you just map value→cutoff
smoothly so sweeping is AUDIBLE and finding the sweet spot is satisfying —
add a subtle "lock shimmer" layer per locked band via
`machine:interact {action:'acid_lock'}`). On stamp: the full line + a clap
layer for 4 bars of glory.
- **dub**: 74 BPM half-time — deep sub bassline, sparse rimshot skank on the
2/4 through a **feedback echo** (delay ~3/8, feedback 0.55, highcut) and a
spring-reverb boing patch. `machine:interact {action:'dub_feed'}` = the big
SPROING (bandpassed noise burst into the echo, feedback momentarily 0.8).
While carrying the charge (Lane M emits `machine:interact
{action:'dub_pickup'|'dub_drop'}`): the echo wet rises — you carry the wet.
- **disco**: 122 BPM — four-on-floor, octave bass, offbeat open hat, a little
string stab loop. `machine:interact {action:'disco_cue', index}` = per-tile
cue blip (pitch by index, pentatonic); player steps echo the blip a fifth
down. Stamp = filtered-disco string swell.
## A2. SIDE B SFX + stem tie-ins
- Portal: `world:enter` whoosh-drop (pitch dive + vinyl slow-down effect),
`world:exit` reverse spin-up.
- `stamp:got`: press-stamp THUNK + one-shot fanfare in that world's key.
- `stamp:all`: golden riser; then permanently add a **bonus percussion stem**
(shaker/conga layer) to the booth mix (new stem in the stems set, unmuted
only when stamp:all has fired — persists via Lane M's synced stamp state;
derive "all stamped" at boot by listening for `stamp:got` count===total or
an initial re-emit — coordinate exact boot handshake in your handoff).
- `machine:interact {action:'bunny_flee'}`: a shy squeak (soft, quiet, rare).
**Already shipped — coexist, don't rebuild:** emote audio (airhorn etc.),
the reset ritual's power-down collapse, and the zero-repair HEARTBEAT
(commit caac3da) are live. World grooves must duck WHATEVER the booth is
playing — including the heartbeat pre-first-repair — and hand it back on
exit; verify entering a pocket in a dead booth and in a won booth both sound
right, and that a reset while you're inside a pocket doesn't wedge the mix
(the exit handback must re-evaluate booth state, not restore a stale snapshot).
## Verify + handoff
Typecheck clean; extend `demo-audio.html` with a SIDE B panel (world
enter/exit buttons, the three acid faders, dub feed, disco cues, emote pops,
reset) and verify every path by ear + zero console errors in the demo AND a
real-game smoke (launch config `turncraft-laneB` port 5184 if free).
Handoff: `docs/SIDEB_E2_handoff.md` incl. any events you need Lane M to emit
that aren't listed here (coordinate via handoff, not by editing their code).

View File

@ -0,0 +1,90 @@
# SIDE B — Lane M: portals, world quests, stamps, relay
Read `docs/briefs/SIDEB.md` + `src/core/worlds.ts` first — **plus
`docs/briefs/SOCIAL_RESET.md` and the SOCIAL_RESET handoffs**: the reset
ritual, emotes, and `interaction.setBreakGuard` already exist and are
server-authoritative — you build AROUND them, never on them. You own
**`src/machines/**`, `src/interact/**`, `src/net/NetClient.ts`,
`server/relay.mjs`**. Reuse the proven patterns: interaction tags + press/hold
(see `machines/workshop/`), kinematic colliders, `MachineBase`. Keep the
Workshop untouched except where noted.
## M1. Portals (src/machines/worlds/portal.ts or similar)
At each `WORLD_DEFS[k].portalStand`: an interaction collider ("DIVE INTO
<NAME> (E)" via `workshop:msg`-style HUD text — use `workshop:msg`). On E:
emit `world:enter {world}`, teleport the player to `entry` (PlayerController
has `teleport`). At `exitPad`: walking onto it (or E) → `world:exit {world}` +
teleport to `returnPos`. Falling out of a pocket must be impossible (Lane W
seals shells), but add a safety: if player y > 141 and NOT inside any pocket
bounds, teleport to `returnPos` of the nearest world (belt-and-braces).
While inside a pocket, suppress the booth machines' interactions? NOT needed —
distance already prevents it.
## M2. One mini-quest per world (own module per world)
All retryable, sound-first, no hard fail. On completion: emit stamp via relay
(see M4), `stamp:got {world, count, total}` locally, and a `workshop:msg`
line. Already-stamped worlds stay visitable (quest replays as a toy, no
double-stamp).
- **ACID — Tune the 303.** Three knob pedestals (Lane W builds plinths at
x=52, z=44/52/60, pocket-relative — colliders yours). Audio (Lane E2) loops
a 2-bar acid line whose filter cutoff is WRONG on entry. Hold-E a pedestal =
sweep its band (reuse hold/torque pattern; emit `workshop:torque` for the
HUD gauge). Each pedestal has a hidden target 0..1 (seeded per entry);
within ±0.07 of target = locked (its pip glows — emit
`machine:interact {action:'acid_lock'}`). All three locked → the line
snaps into tune → stamp. Emit `machine:interact {action:'acid_knob'}` with
each meaningful change; Lane E2 maps value→cutoff via
`fader:move {faderId:'acid_0'..'acid_2'}` (reuse that event, values 0..1).
- **DUB — Feed the spring.** An echo charge (glowing carryable, like the wire
carry — reuse the carry pattern with `isCarrying`-style state) sits at the
horn. Carry it across the spring-tank bridge: you overlay a 3-wide moving
kinematic deck on Lane W's static chrome deck (segments like the workshop
beam, gentle sine wobble ±0.35 rad/s max — the ride-snap handles it). Reach
the tank mouth (chasm centre), E to drop → `machine:interact
{action:'dub_feed'}` → stamp. Falling into the chasm floor: charge returns
to the horn, subtitle "THE SPRING WANTS IT — TRY AGAIN".
- **DISCO — Light the floor.** The 4×4 tile grid (Lane W lays it; your
colliders detect standing-on-tile). On `audio:beat`, flash a sequence of 4
growing to 8 tiles (`machine:interact {action:'disco_cue', index}` so Lane S
can light them); the player repeats it by stepping tiles between beats.
Wrong tile = gentle reset ("THE FLOOR FORGIVES — WATCH AGAIN"). Full 8 →
stamp.
## M3. Stamps + golden unlock
Track stamps in a small `Stamps` store (Set<WorldKey>) synced via relay (M4),
persisted server-side like repairs. On count===3 (any client): emit
`stamp:all`, and swap deck A's slipmat material to a golden variant (you own
platter.ts — add `setGoldenSlipmat(on)`) + Lane E2's bonus stem fires.
On join, relay state re-applies stamps (idempotent).
## M4. Relay protocol (server/relay.mjs + NetClient)
ONE additive message, validated default-closed like `workshop` (emotes and
unrepair/reset already exist — touch neither):
- `{t:'stamp', w}` — w in acid|dub|disco. Adds to a persisted `stamps` set;
rebroadcast; included in the join snapshot. Reject anything else.
Keep frame caps. Client mirror: NetClient sends stamps, applies incoming +
join snapshot (`stamp` → Stamps store + `stamp:got`, idempotent).
## M5. Coexist with the reset ritual (do not rebuild it)
The SOCIAL_RESET reset (5 s fuse hold → server-authoritative reset, 10-min
cooldown) already resets the quest + workshop. Your additions must survive a
full reset→re-win cycle: **stamps persist through resets** (they're pressed
into the records, not the booth — assert this), portals keep working while
the booth is dead, and world quests remain playable. Verify one full cycle.
## Verify + handoff
Typecheck clean; drive everything headless via `window.TURNCRAFT` in the real
game (dev port: launch config `turncraft-laneD-verify`, 5194) with zero
console errors: enter/exit each world, complete each quest, stamp sync across
a relay round-trip (run `node server/relay.mjs` locally; NOTE: delete
`server/booth-state.json` before AND after — stale state makes clients join
completed), plus one full reset→re-win cycle with stamps surviving. Fuzz the
new relay message (bad kinds/shapes rejected). Handoff:
`docs/SIDEB_M_handoff.md` incl. exact event vocab emitted for Lanes S/E2.

View File

@ -0,0 +1,45 @@
# SIDE B — Lane S: dust bunnies, portal FX, HUD stamp tray
Read `docs/briefs/SIDEB.md` + `src/core/worlds.ts` first. You own
**`src/fx/**` and `src/ui/**`** — NOT `src/net/Avatars.ts` (the SOCIAL_RESET
phase owns avatars/emotes now; `docs/briefs/SOCIAL_RESET.md` is required
reading so you don't duplicate its FX). Consume bus events only — never
import Lane M/W code.
## S1. Dust bunnies (fx critters, purely cosmetic)
34 fist-sized fluff-balls (procedural: a tiny THREE group of 812 grey
dust-tone quads, jittered) drifting around the under-table PCB floor (y≈3).
Keep-out zones: the three portal rings (`WORLD_DEFS[k].portalStand` ±6) and
the parts-bin/fuse quest spots. Wander slowly; flee 8 voxels when the player
comes within 5 (ease out, never through walls — sample `world.isSolid` ahead);
settle again. No colliders, no game effect. Emit
`machine:interact {action:'bunny_flee'}` at most 1/s when spooked (Lane E2
squeaks). Budget: zero steady-state allocations, no fps regression.
## S2. Portal + stamp FX
- `world:enter`: 400 ms vinyl-warp iris — full-screen overlay of concentric
groove rings collapsing to black, accent-tinted per world
(`WORLD_DEFS[k].accent`); release on arrival. `world:exit`: reversed.
(Coexist with the first-visit flythrough's cine override — never fight the
camera; you're a screen overlay only.)
- `stamp:got`: confetti burst (reuse the FxSystem burst pattern) + HUD toast
via the tray (S3).
- `stamp:all`: golden shimmer — a brief booth-wide emissive pulse via the
existing `setEmissiveBoost` path, plus gold confetti at the crate.
## S3. HUD stamp tray
Small tray top-right under the signal lamps: three record-stamp dots
(accent-coloured when got, dim ring when not), scale-pulse on `stamp:got`,
gold ring on `stamp:all`. Initial state comes from replayed `stamp:got`
events on join (Lane M re-emits from the join snapshot — coordinate exact
timing via handoffs).
## Verify + handoff
Typecheck clean; exercise in the real game (`turncraft-laneE-verify` launch
config) — bunnies wander/flee with zero console errors and measured fps
unchanged; portal iris and tray driven by hand-emitted bus events. Handoff:
`docs/SIDEB_S_handoff.md`.

View File

@ -0,0 +1,55 @@
# SIDE B — Lane W: the three pockets (worldgen)
Read `docs/briefs/SIDEB.md` first. You own **`src/worldgen/worlds/**`** (new)
plus small additive edits in `src/worldgen/buildBooth.ts` (portal dressing +
calling your builders). Do not move any existing anchor or LAYOUT position.
## W1. Portal dressing at the crate (buildBooth additive)
The three portal discs (`WORLD_DEFS[k].portalDisc`, r=12, already built by
buildCrate) get: a 1-voxel `led_*` ring inlay on the crate floor around
`portalStand` (accent-coloured: acid=led_green, dub=led_amber, disco=led_blue —
approximate accents with existing led blocks), and a small marquee of 23
accent led voxels on the disc rim. Subtle — the crate stays dim; the rings are
the "these three are special" tell.
## W2. Three pocket rooms (src/worldgen/worlds/)
One module per world + an index that exposes `buildWorlds(w: IVoxelWorld)`
called once from buildBooth (after everything else; same seed discipline —
use `mulberry32`, never Math.random). Each pocket: fully enclosed shell at
`pocketMin..pocketMax` (outer skin `matte_black` so undersides read as shadow
from the booth; NO light leaks — every interior emissive fully enclosed),
floor/walls/ceiling themed inside, interior height ~14.
- **ACID WAREHOUSE** (x 20..84): raw concrete feel — `steel_grey`/`brushed_alu`
floor slab variants, breeze-block wall rhythm from `ply_edge`, pillars,
`led_green` cable runs snaking wall→floor→three knob pedestals (Lane M places
machines AT `entry`-relative positions listed in their brief — you build the
pedestals: three 3×3×3 `matte_black` plinths at z 44 / 52 / 60 along x=52,
1-voxel green pip on each). A strobe rail of `strobe_dot` on the ceiling.
- **DUB CHAMBER** (x 192..256): deep space — `vinyl_black` walls with sparse
`led_amber` embers, a central chasm (floor drops 4 at x 210..238) crossed by
a 3-wide wobbling **spring-tank bridge** footprint: you lay the STATIC deck
(`chrome` 3×24 along z at x 224); Lane M overlays the moving collider. A big
horn-speaker cone of stacked `speaker_mesh` rings on the far wall.
- **DISCO LOFT** (x 364..428): warm parquet — `plywood`/`ply_edge` herringbone
floor with a 4×4 grid of 5×5 **dance tiles** (flush, alternating
`label_cream`/`rubber` borders, centre voxel `led_*` per tile — Lane M lights
them; you place the tile grid centred on [396, 143, 52..76]), a mirrorball of
`chrome`+`glass` voxels hanging centre-ceiling, `led_red`/`led_blue` wall
wash strips.
Exit pads: 3×3 `rca_gold` pad flush at each `exitPad`, one accent led pip.
## W3. Budgets & proof
Deterministic (two builds byte-identical); booth build < 900 ms total; chunk
meshes < 700; zero visibility from inside the booth (fly the cine camera
around the booth at night — no glow leaks) AND from the first-visit
flythrough path (commit caac3da rides the cine override high over the booth —
run it and confirm the pocket shells read as, at most, faint black slabs in
the dark). Update `worldgenDemo` validation
with pocket assertions (shell sealed, entry/exit air, pedestals present).
Handoff: `docs/SIDEB_W_handoff.md`. Dev port: use launch config
`turncraft-laneC-verify` (5212).

View File

@ -36,6 +36,14 @@ export type GameEvents = {
'workshop:torque': { value: number | null }; // radial HUD meter (null hides)
'workshop:msg': { text: string }; // transient HUD subtitle
// SIDE B — crate worlds + stamps (additive per the SIDEB briefs; primitives
// only — world vocab lives in core/worlds.ts). Emotes and the reset ritual
// predate this phase ('player:emote' / 'quest:reset' above).
'world:enter': { world: string }; // player dove into a pocket
'world:exit': { world: string }; // player returned to the booth
'stamp:got': { world: string; count: number; total: number };
'stamp:all': Record<string, never>; // golden slipmat unlock
// Mining-in-progress juice (first-five-minutes pass). Emitted ~8 Hz while
// a block is being held-mined; FX puffs and audio ticks react. FX-only —
// no gameplay reads this.

57
src/core/worlds.ts Normal file
View File

@ -0,0 +1,57 @@
// TURNCRAFT — SIDE B contract: the crate worlds. This file is CONTRACT: lanes
// read it, never edit it. Like LAYOUT/QUEST anchors, these are the fixed
// positions everything else builds against.
//
// Fiction: three of the records standing in the under-table crate are pressed
// with whole scenes. Press E beside one and you dive into it — a pocket world
// in the dark above the booth walls (fully shelled, invisible from inside the
// booth). Each pocket holds one sound-first mini-quest; completing it presses
// a STAMP into your copy. All three stamps → the golden slipmat rises.
import type { Vec3 } from './types';
export type WorldKey = 'acid' | 'dub' | 'disco';
export const WORLD_KEYS: readonly WorldKey[] = ['acid', 'dub', 'disco'];
export const STAMP_TOTAL = 3;
export interface WorldDef {
key: WorldKey;
name: string; // display name (HUD, flyers)
/** Crate disc that is this world's portal (from buildCrate: x=26+5s, cy=15, cz=31, r=12). */
portalDisc: Vec3; // disc centre
portalStand: Vec3; // where the player stands to use it (crate floor)
/** Pocket shell bounds, INCLUSIVE, fully enclosed (1-voxel shell, interior dark-lit). */
pocketMin: Vec3;
pocketMax: Vec3;
entry: Vec3; // player spawn inside the pocket (feet)
exitPad: Vec3; // stand here + E (or walk onto pad) to return
returnPos: Vec3; // reappear here at the crate
accent: [number, number, number]; // FX/HUD accent colour
}
export const WORLD_DEFS: Record<WorldKey, WorldDef> = {
acid: {
key: 'acid', name: 'ACID WAREHOUSE',
portalDisc: [31, 15, 31], portalStand: [31, 3, 36],
pocketMin: [20, 142, 20], pocketMax: [84, 158, 84],
entry: [52, 144, 30], exitPad: [52, 143, 78], returnPos: [34, 3, 36],
accent: [96, 255, 96],
},
dub: {
key: 'dub', name: 'DUB CHAMBER',
portalDisc: [46, 15, 31], portalStand: [46, 3, 36],
pocketMin: [192, 142, 20], pocketMax: [256, 158, 84],
entry: [224, 144, 30], exitPad: [224, 143, 78], returnPos: [49, 3, 36],
accent: [255, 176, 40],
},
disco: {
key: 'disco', name: 'DISCO LOFT',
portalDisc: [61, 15, 31], portalStand: [61, 3, 36],
pocketMin: [364, 142, 20], pocketMax: [428, 158, 84],
entry: [396, 144, 30], exitPad: [396, 143, 78], returnPos: [64, 3, 36],
accent: [255, 120, 220],
},
};
// (Emotes shipped in the SOCIAL_RESET phase — see src/net/avatarLook.ts and
// the 'player:emote' bus event. This contract deliberately adds none.)