Repository navigation
Merged
Conversation
- modules/music-lab: two registerAudioDevice kinds and nothing else. The
Speaker takes an audio in through a level gain into an HRTF PannerNode
(inverse rolloff, refDistance 1, maxDistance 40 - what voice chat carries)
and out to the instruments bus; the Piano's ADSR voices land on its own
output gain, which reaches no bus at all. Sound goes nowhere until a cable
joins them, which is the point.
- One frame task, throttled to 20 Hz, re-aims every built panner at its
object's world position and its -Z forward, so carrying a speaker moves
where it sounds from and turning it is audible through the cone
(inner angle is the knob; outer = inner + 60 at 0.25 gain).
- Playing is one path for desktop click and VR trigger: the handler reads
userData.midi off the mesh it was handed, walks up to the device object and
calls api.audio.note - which replicates for us. No api.send anywhere in the
module, no state sync, nothing derived from local state.
- The key dip runs from onNote, never from the click, so the peer who did not
click dips the same key. build / onParam / onNote are pure functions of
their arguments; every param is live.
- The device root is a MESH with children, not a Group: the late-joiner object
sync sends a Group as a bare {type:'group', ...} message carrying no
userData, which would strand a joiner with a device whose kind is gone.
- tests/music-lab.test.cjs: 24 checks on three real peers, all measured
through the destination tap - silent uncabled, 440 Hz heard through the
speaker, heard on B, 95% quieter 60 m away, silent when unplugged, heard by
a late joiner, quieter facing away. tests/helpers.cjs gains the core suite's
audio tap, AUDIO_ARGS and audioMetrics (additively; setupPage takes
{audio: true}).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GTLDw59m5FXZZMBPwyXoew
…ler pads) Three more devices on the engine, the same registerAudioDevice contract as C1: - Transport: the face of the SHARED clock - play/stop, BPM -/+, tap tempo, a bar counter every peer draws itself (a canvas texture made in build, never replicated). Only play/setBpm leave it, and those are the transport's own replicated messages. - Drum machine: 16 steps x 8 synthesized pads, no assets. THE PATTERN IS THE DEVICE DOCUMENT (the `pattern` param: 8 rows of 16 velocity digits), so it replicates, saves and undoes as one thing with no channel of its own. Steps are scheduled through the engine's look-ahead scheduler (api.audio.schedule, every 0.25 beat): every peer runs the same pattern from the same transport, so every peer's kick lands on the same audio time and nothing crosses the wire during playback; a late joiner starts on the NEXT beat (the scheduler's `late` flag), never a catch-up burst. A click toggles a step and opens a paint stroke that follows api.pointerRay() across the grid (desktop drag, VR sweep): the stroke previews LOCALLY like a knob scrub and commits ONE setParams write when it goes idle - one undo entry for a whole drag. Pad buttons play a pad now as a replicated note (pad n = note 36 + n, reachable from a flow Note Trigger). Per-pad level/pan/mute/choke/sample in the `pads` JSON param; the closed hat chokes the open one. - Sampler pads: 4x4; an Explorer audio item dropped on a pad (core's new api.registerDropHandler) becomes the pad's sample by CONTENT HASH - every peer pulls and decodes the bytes itself through api.audio.sample. Per pad pitch/start/loop/gate/ level/pan. An empty pad answers with a tick. Everything visual is local and transient (WeakMaps keyed by the object, WeakSets for lit and flashing cells): NOTHING runtime goes into userData, because userData replicates - the first cut cached a material Map there and a late joiner's build threw on the plain object it arrived as, leaving the drum machine a placeholder on C. Flight tests/music-beat-lab.test.cjs (three peers, 40 checks), every timing claim read at the destination tap as ONSETS against the WALL CLOCK: B builds and lights the replicated grid and draws its own transport; a click is one write and a four-cell drag is ONE write / ONE undo entry, replicated as one document, and one undo reverts it on both; Play from the transport face runs both peers on a 500 ms grid in phase within 1 ms; a late joiner C lands on the grid in phase within 1 ms; 61 kicks over 30 s with 12 ms max deviation; 7 kicks on the grid with the CPU throttled 4x (24 ms max deviation); a WAV dropped on pad 6 lands in both documents by hash, B decodes it, and a pad press on A is HEARD once on B; BPM buttons and tap tempo write the shared clock. Counterfactuals (one run): hits played at call time instead of the scheduled audio time -> the 30 s guard red (70 ms max deviation, worst spacing 76 ms); the stroke writing per painted cell -> 2.5/2.6/2.8 red (three extra undo entries, undo reverts one cell). Restored byte-identically, green again. Requires core 4527f09 (api.registerDropHandler) for the sampler drop; the rest runs on the A-batch engine. music-lab.test.cjs (C1) still passes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GTLDw59m5FXZZMBPwyXoew
- modules/music-fx: six registerAudioDevice kinds and nothing else, one file, api.THREE, no top-level imports. A four-channel Mixer (in1..in4 -> out and a post-fader send; per channel gain, pan, mute, solo, send; master) exposed through the inputs/outputs maps core's patch looks multi-port devices up in, and five pedals, each one in, one out, a dry/wet mix: Delay (DelayNode + feedback; time is 1/4, 1/8, 1/8T, 1/16 synced to the SHARED transport bpm, or free ms), Reverb (ConvolverNode over a generated decaying noise burst, regenerated on size/decay with a 120 ms debounce, seeded so it is a pure function of the two knobs and every peer builds the same room, no file shipped), Filter (BiquadFilterNode; type, cutoff, q), Distortion (drive -> tanh WaveShaper at 4x -> tone low-pass), Bitcrush (an AudioWorklet loaded from a blob URL built from a string in module.js, once per context; a ScriptProcessorNode fallback if the worklet cannot load, reported in the handle's mode). The worklet path is what ships and what the flight measured. - A knob is a param write, never a rebuild: every onParam glides an AudioParam on the running graph (setTargetAtTime, 15 ms); the nodes a cable is plugged into are the same nodes after a write as before. A synced delay's time is f(params, bpm); one frame task at 4 Hz re-reads api.audio.transport().bpm and re-aims every synced delay it moved, so setBpm on any peer lands every peer's delays on the same number. build/onParam are pure of their arguments. - Nothing here reaches a bus: a pedal's out and the mixer's out/send connect to nothing until cabled. Meshes are stomp boxes with a coloured top and the in/out jacks on the sides, a desk for the mixer; every root is a Mesh (the late-joiner rule), plugs are vrpatch-in/out:<id> with a port:<id> marker. A demo-chain menu drops the five pedals and the mixer cabled in series and plugs a Music Lab piano/speaker in if they are already in the scene. - tests/music-fx.test.cjs: 50 checks on three real peers plus the real-zip installs, a test source and sink registered in-page (no dependency on music-lab), everything measured through the destination tap: the low-pass halves the centroid (3776 -> 333 Hz) and the high-pass raises it (6151 Hz); distortion puts 15% of the power above 600 Hz where the sine had none and the tone knob takes it back (0.058 -> 0.007 above 1 kHz); the bitcrusher at 4 bits / rate 8 puts 3.3% above the fundamental via the worklet; the delay keeps echoing 150-900 ms after the source stops (tail peak 0.081) and follows setBpm(90) to 0.333 s on A and on B; the reverb rings on (0.016, the same to 3% on two runs now that the impulse is SEEDED - a Math.random burst's response at one frequency is a random draw and read 0.009..0.039 across runs); filter -> distortion vs distortion -> filter read repeatably (hf 0.0226/centroid 1500 vs 0.0001/378) and differ; the mixer's mute and solo drop the other tone by 106 dB, the send carries one channel post-fader, and eight fader writes in 0.9 s neither click (magnitude share above 3 kHz 0.0003) nor gap nor rebuild the input node nor restart a source; a cutoff written on A lands in B's document, B's BiquadFilterNode and B's own spectrum; a late joiner holds every device real, gets the cables from the handshake and hears the low-passed chain. - Counterfactuals: wiring the wet path into the dry gain instead of the output turned exactly the ten wet-path checks red (3.3, 3.4, 4.2, 4.3, 5.2, 5.3, 8.3, 10.1, 10.4, 11.4) and nothing else; stepping the mixer fader with .value= instead of gliding turned exactly 9.5 red (0.0003 -> 0.1376). Restored byte-identically and green again. - index.json gains the entry; music-fx.zip is gitignored like every other zip. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GTLDw59m5FXZZMBPwyXoew
A new module, modules/music-dj: a Deck and a Crossfader through the same
registerAudioDevice contract as music-lab.
- Deck: a track by CONTENT HASH (an Explorer audio item dropped on the deck through
api.registerDropHandler; every peer pulls and decodes its own copy), play/stop, cue,
a platter you scrub (one revolution = 2 s) and a pitch fader (varispeed 0.5x..1.5x -
pitch and tempo are ONE control in v1, as recommended; time-stretching is a project).
THE POSITION IS THREE NUMBERS IN THE DOCUMENT, {startedAt, offset, rate}: every peer
derives the playhead from them and its own clock, the playhead is never streamed. A
future startedAt starts at its exact audio time (api.audio.timeFor), a past one at the
derived position now - the same answer on every peer whenever the message arrives.
- The scrub, the pitch fader and the crossfader are ONE LIVE GESTURE each: throttled
previews (api.audio.previewParams, replicated, no history) while the hand moves along
api.pointerRay(), one commit (api.audio.setParams with the document captured when the
gesture STARTED as `before`) when it goes idle - one undo entry per gesture, and undo
puts the deck back where the scrub began. The pitch fader re-bases offset/startedAt
with every rate it writes, so the position stays continuous on every peer; a bare rate
edit from the Inspector keeps this peer continuous but re-derives the document position
(the documented v1 limit).
- Crossfader: inputs a/b, one out, position -1..1 and a curve (linear / constant power /
sharp); gains glide with setTargetAtTime, never jump.
- The platter turns with the derived playhead, which is why the scrub reads its angle in
the DECK's frame: read in the platter's own frame a perfectly still hand reported motion
every frame, the gesture never went idle, and nothing ever committed.
Flight tests/music-dj.test.cjs (two peers + a late joiner, 33 checks): tracks by hash on
both peers, decoded on B; Play from the face is heard at 330 Hz on both taps and the two
derived playheads agree within 1 ms; a scrub previews on B while the hand moves with
nothing in history, commits ONE entry, the peers agree again, and one undo puts the offset
back to where the scrub started; the crossfader's extremes are one deck's signal only (96
and 91 dB of separation, on A and on B), centred constant-power within 0.1 dB, and a drag
of the knob is one replicated commit; the pitch fader lands at ~1.5x on both as one write
with the playhead continuous through the change on the 6 s loop; a late joiner derives the
same playhead within 2 ms and hears the deck at its rate; pause freezes the offset on B and
play resumes from it.
Counterfactuals (one run): the commit without the gesture's start document -> 3.5 red
(undo restores the last preview, not the start); the pitch preview writing only `rate` ->
5.3 red (the playhead jumps). Restored byte-identically, repacked. The platter-frame bug
above was the third, found by the flight itself (3.3 red for two runs).
Requires core 4527f09 (registerDropHandler), 5231589 (api.audio.previewParams) and
0ad8154 (setParams `before`). index.json: music-dj added, music-lab bumped to 0.2.0.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GTLDw59m5FXZZMBPwyXoew
…ne manifest
Core 7a228f7 asks each device KIND what a document of it references by content hash
(`spec.assets(params) -> [{hash, name}]`), so a .tpscene export bundles the bytes and a
template does not load silent. The drum machine and the sampler declare every pad sample
in `pads`; the deck declares its `track`.
Flights: music-beat-lab 7.7 (the sampler's dropped sample is in the manifest by hash,
named) and music-dj 1.6 (both decks' tracks are). All three music flights green on the
lane.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GTLDw59m5FXZZMBPwyXoew
…its modules The room is built exactly as scripts/author-templates.cjs builds the template - the modules' own menus (Music Lab: piano + speaker, Music Lab: beat lab, Music FX: demo chain) - exported through the REAL .tpscene path, then imported on a peer that HAS the modules and on one that does NOT. Flight (three pages, 17 checks): the three menus exist; twelve devices of eleven kinds and ten cables, all built real; the payload names BOTH modules with no flow node in the room (the device-kind requirement signal, core 79144fb); the export is 36 kB; a peer with the modules imports and loads it the templates modal's way (importSessionZip + requestLoadSession) and holds the same twelve devices and ten cables, all built real, its transport face takes Play and the loaded beat is heard; a peer without the modules is prompted before anything is touched, the prompt names music-fx and music-lab, Cancel is a no-op and nothing loads. A second module on a page already used by the helper installs through the authoring script's own sequence (the helper's manager click times out there). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GTLDw59m5FXZZMBPwyXoew
…gine
A new module `modules/music-voice` (one file, api.THREE, no top-level imports), an
index.json entry, and the test-flight tests/music-voice.test.cjs (two peers on Chrome's
fake media device plus a late joiner).
What was built
- Mic: this peer's RAW microphone into the graph through api.audio.captureMic (a separate
capture from voice chat, AEC/NS/AGC off, never gated by PTT) -> level -> out; a monitor
toggle onto the local instruments bus; a face button that records a take through
api.audio.record and writes the item's CONTENT HASH into the document (lastTake), which
replicates, undoes and enters the Scene manifest through spec.assets. A recording LED
from the api.audio.recording() store.
- Looper: in/out. Record arms; a one-shot on the transport's scheduler a beat before the
next bar starts a MediaRecorder on a MediaStreamDestination, and a gain GATE on the
shared audio clock opens exactly at the bar and closes `bars` bars later. A 3 ms sync
pip ends exactly where the gate opens, so every peer locates it in the decoded bytes
and cuts exactly bars*4*60/bpm seconds after it: the same samples everywhere, an exact
period. Playback is api.audio.schedule(startedBeat mod P, fn, {every: P}) pure of
(beat, at, bpm), plus a mid-loop catch-up from the transport's phase for a take that
just landed, a late joiner or a restarted transport. Overdub mixes the playing loop
(scaled by feedback) into the next window -> a new hash. A take is one gesture: state
previews for the LEDs, one setParams with `before`, one undo entry.
- Synth: polyphonic osc -> low-pass -> ADSR voices on the shared context, started at
api.audio.timeFor(at); params wave/attack/decay/sustain/release/cutoff/resonance/level/
polyphony/gate; the oldest voice is stolen past polyphony; a one-octave key face whose
click is a replicated api.audio.note; in VR the trigger holds the note (gate 0) and its
release sends an `off` note.
- Theremin: in VR a frame task reads api.vrHand(hand) at ~15 Hz and writes the hand pose
as previewParams(uuid, {pitch, volume}) - replicated, history-free - and every peer
synthesizes from the document (onParam glides). Continuous pitch, no notes. On desktop
pitch/volume are ordinary params.
- A sidebar entry "Music Voice: demo" that adds the four, cables synth -> looper, and
cables looper, mic and theremin into a Music Lab speaker when that kind is registered.
Measurements (the flight, all at an in-page sink's tap)
- The mic reads a live, enabled, raw track with the mic toggle off and carries signal;
a 2 s take lands as an Explorer audio item, its hash is in the document on B, the bytes
reach B by hash, and the take is in the Scene manifest.
- The looper's window starts on a bar boundary after the press, startedAt sits on the
transport's own grid, the take is one undo entry; A and B cut the SAME samples (the
pip edge) to exactly 2.000 s at 120 bpm / 1 bar; the loop plays at 330 Hz on both; the
onsets across two loops sit on a 2000 ms grid, on the bar of the shared transport, with
A and B in phase; an overdub with a 440 Hz source lands as a new hash whose playback
carries both tones on A and B; one undo restores the first loop; a late joiner cuts
the same samples and plays in phase.
- The synth plays four simultaneous fundamentals on A and B, a fifth steals the oldest,
the release is measurable (sustain, mid-release decay, silence).
- The theremin's previewParams {pitch 440, volume 0.8} reads 440 Hz on A and B, 880
moves it, saw adds the second harmonic, volume 0 silences both, and no preview enters
history.
Flight: 71 checks ALL PASS (run three times green). Measured: the loop cut at the pip edge
to exactly 96000 samples (2.000 s at 48 kHz), A and B the same edge; onsets 1996-2002 ms
apart, about 9 ms after the bar grid, A and B within 2 ms; the take lands about 1.1 s after
the bar it was armed for (lead 0.6 s + 1 bar + tail).
Counterfactuals (one run with both, restored byte-identically by cmp, green again after)
- the looper's window opening at record time instead of the bar boundary -> 3.8 red
(startedAt 536 ms off the transport's grid) and 3.16 red (onsets 554 ms off the bar);
the period (3.15) and the A/B phase (3.17) stay green, as they should
- the theremin's volume gated off the LOCAL hand instead of the document -> 5.1, 5.4 and
5.5 red (silent on A); 5.3 stays green because B built its copy FROM THE DOCUMENT with
the preview already in it - the document, not the hand, is what a peer hears
A trap the flight found in its own measurement: an onset detector that advances its scan
by WALL time re-scans a window whose audio has not advanced under load and stamps the
same rising edge again 120+ ms later (a fake onset at the burst's end on random bars,
identical on A and B). The detector now advances by the AUDIO clock's progress, never
re-scans, and disarms across a lost window. Every relative spectrum check also requires
signal first: a silent tap reads -160 dB on every bin and passes a relative test vacuously.
API gaps found (none blocking): no bytes-to-Explorer path in the SDK besides
api.audio.record({stream}) (a looper that captures a sample-accurate ring buffer has to
re-record it); no api.audio.releaseMic counterpart to captureMic; no dispose hook for a
module's own store subscriptions.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GTLDw59m5FXZZMBPwyXoew
# Conflicts: # index.json
The three-lane merge left music-voice listed twice (lane c5 appended its own row beside the one the c3 resolution had already carried), which renders two identical gallery cards. Dev-branch only. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ve, sync, toolbox The `football` module (category game): Match Rules / Team Gate / Match Button / Serve / Score Lamp / Records / Football Value / Football Event nodes on the dungeon-realms rule-ownership pattern; slots as events, last touch derived from the knock hit feed (api.onHit feature-detected, the knock debug hook on an A1 build), goals decided by the physics initiator and applied everywhere, the ONE peer a goal names bumps its own peerVars row (F7), registerStateSync for late joiners, a toolbox with host settings and a Build pitch recipe (api.create / physics.set / flow.addNodes) that is the def's objects+graph source. esbuild bundle src/ -> module.js, football.zip, index row, build:football / test:football scripts, DEVX #19-#21 (game.setState, scene-physics write, XR bounds + roomAnchor). Design finding (flowRuntime restore loop): a targeted object's base pose is re-seated every frame, so no football node targets the BALL — Match Rules targets the pitch and takes the ball as a wired object input. Node tests (modules/football/test, 70 checks, all pass), one line per counterfactual: - an unknown mode falls back to the default; a clamped goalsToWin/matchSeconds - duel refuses a second red; teams takes it; the seated peer may re-join; spectate always - freeVanished frees only the disconnected peer; everyone live frees nothing - attacker's last touch = goal on their row; defender's = own goal on THEIR row - ownGoals ignore: the own goal does not count; no touch / spectator touch: attackers score, nobody credited; the toucher's team is read at GOAL time (swap -> own goal) - practice scores nothing; free for all credits the toucher, no touch = nobody - the same touch into the blue gate is a red goal - goals: 5 wins, 4 does not, the clock is ignored; time: 60 ends, 59 does not, level = draw, goals ignored; either = first; practice never ends; FFA by player, tie on time = draw - serveDirection deterministic per stamp, other stamps go the other way, lean bounded; serveImpulse = mass x speed at the centre, kicks a ball in a gate back to the centre - matchLog capped at 50 newest kept; a missing log starts one - pitch: one dynamic body, sensors 0.5 m deep behind the mouth, end wall behind the sensor, a longer pitch puts the gates further out, NO effect node targets the ball, hudButtons adds four perPlayer HUD Buttons wired into `press` Test-flight tests/football.test.cjs (two peers + a late joiner, real zip, :5208 A1 core): first run 55/61 PASS — all six failures traced to the served ball scoring on its OWN (zero-g, 0.7 walls, 3 m/s) between assertions, plus a teleport landing inside the serve delay (ignored by design). The flight now serves at 0.8 m/s with damping 1.2 and waits for the serve before each teleport; the re-run is queued on the e2e lock (see the handover for its result). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Agn7hhLHkg5xSgrU74yjRA
The VR surface is IN THE SCENE (fork 6): the Score Lamp nodes light the two rows of five lamps above each gate from the replicated score on every peer, the Match Button nodes turn the four button objects at the gates and the pitch side into join / start / new-match presses through the click handler (desktop click, play-mode tap, VR trigger), and a goal chimes at its gate through api.playSound on every peer. The DOM HUD (hud.js, Towers' menu / hud / pause / over shape so D1 can compare) reads the SAME nodes: perPlayer HUD Buttons wired into the Match Buttons' `press` input, and the Records node fills the sheet, score and match-log lists (an element field may name several lists, comma-separated). The def graph gains the pause rows (P / resume / quit) and the over screen's New match. Node tests: 81 checks pass (six perPlayer HUD buttons all reach a `press`, the pause rows, both sheet lists fed, every fb-* element the graph names exists in the HUD doc). Flight section 16 (queued on the e2e lock, see the handover): Blue lamp 1 lit on BOTH peers after a blue goal while Blue lamp 2 and Red lamp 1 stay dim (counterfactual), the score line and the sheet rows land in the HUD lists and the DOM HUD text, a perPlayer HUD Button press joins ONLY the presser. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Agn7hhLHkg5xSgrU74yjRA
…liders fallback Pitch defaults sized for a real room (3 x 5 m, gates 1.2 x 0.8 m, mouths at 1.35 m, ball at 1.3 m). The toolbox's Fit pitch re-lays every static pitch object from pitchObjects(dims) by name through api.moveObject (ordinary replicated `move`s, so a colocated pair sees one pitch) and never touches the live ball; Fit to room reads XRBoundedReferenceSpace bounds and Centre on room the colocation roomAnchor, both feature-detected on the api (DEVX #21) with the Length / Width sliders as the fallback. The hot-body move cadence is core's (L-A owns physics.js) and is left with the core lane. Flight section 17 (queued): 40 static objects re-laid for an 8 x 4 m room, the blue gate moves further out ON PEER B, the right wall stands at half the new width, headless has no bounds and no anchor so the fallback is the path taken. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Agn7hhLHkg5xSgrU74yjRA
…a late joiner footballDef() (src/def.js) is the author-templates.cjs shape - kind game, slug football, modules/installModules football, daylight env, the zero-g + knock physics block, AgX/bloom/smaa post, the def graph with the HUD buttons, the four-screen HUD document, the 41 pitch objects - emitted as modules/football/football.def.json by `npm run build:football` for the orchestrator to import into the core author script and run `--only football` against the scenes checkout (this lane writes no core file and never runs it). The test-flight (tests/football.test.cjs) is B1's on the recipe-built pitch: the same objects and graph, so the def and the flight cannot drift apart. Node tests: 86 checks pass (the def is deterministic byte-for-byte, carries the module, 41 objects, the def graph, four screens and gravity 0). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Agn7hhLHkg5xSgrU74yjRA
… in gamestate.vars Fork 3 as locked: per-player goals / touches / owngoals are peerVars rows written by their owner alone and kept across a new match (a session record); the match history is appended by the authority at match end to gameState.vars.football.matches (capped 50, names not peer ids), which rides the game singleton into the .tpscene, is listed by the Records node's logElement and counted by Football Value `read: matches`. The cloud leaderboard stays a separate cloud-repo plan. Node tests: 88 checks pass — a scoreless draw logs with no scorers; cap 1 keeps only the newest (counterfactual). Flight 13.3/13.4/15.2 (queued): the log carries the sheet on A, replicates to B with the singleton, and B's goals row survives a new match. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Agn7hhLHkg5xSgrU74yjRA
…ud buttons Both remaining test-flight failures were harness/wiring, not rules — the flight is now 89/89 (two peers + a late joiner, real zip, A1 core on :5208) and the node tests 89/89. 1. THE GOAL LATCH. Goal detection is an ENTER EDGE per gate, so a ball left sitting in a sensor by a gentle post-goal serve is already `inside` that gate: throwing it in again produced no edge and the second goal never fired (9.3 and its whole cascade 9.4, 10.5, 12.5, 13.3, 14.1). The flight's teleport helper now parks the ball at the CENTRE and lets a frame pass, clearing the latch on the authority, before throwing it into a gate. 2. A HUD BUTTON CANNOT DRIVE A MODULE NODE — a real gap, now DEVX #22. `evalNode` has no `hudbutton` case ("hudbutton contributes no runtime value"): core's own consumers read the wired source's trigger STAMP through triggerStampFor, which a module effect cannot reach, because `resolveInputs` injects only VALUES and `undefined` is skipped. So `hudbutton -> fbbutton.press` looked right in the editor and silently did nothing, while the identical-looking `onclick -> fbbutton.press` works. The template now bridges each HUD Button through a `delay` (0.05 s), which consumes the stamp and re-emits it as a numeric pulse; `press`/`trigger` are typed `number` so an On Click, a Delay, a Compare or a Toggle all drive them. pitch.js carries the reasoning under THE HUD-BUTTON BRIDGE. Also: the pitch recipe builds the def's graph (`hudButtons: true`) so a recipe-built scene is wired for a HUD document it may gain later, and flight 16.12 now asserts A LEAVES blue — it read as a pass without 16.10 ever working, which is what hid the gap for a run. New checks, one line per counterfactual: - a scoreless draw logs with no scorers; cap 1 keeps only the newest (the two owed by the handover) - every HUD button reaches a Match Button `press` THROUGH a Delay - counterfactual: no HUD button wires straight into a module input (it would read undefined) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Agn7hhLHkg5xSgrU74yjRA
football: a VR football module on the knock/hit feed
…realms plays it through the scene
- `dungeon` is now the Dungeon Kit 2.0.0 (esbuild-bundled from src/): the 9-stage generator
moved here from dungeon-realms with its node tests (gen/campaign, still green), a pure
`contract.js` builds the userData.play record (v2: raster + spawn-ordered rooms + world-space
props/portals/spawns + grounded + markers) on ONE persistent scene-root group 'dungeon-module'
whose userData.kit is the function seam (generate/showFloor/clear/setMarkers/setGrounded);
netcode generate/floor/clear + a {seed, params, floorIndex} state sync; `#dungeon-panel` is a
registered toolbox (api.registerToolbox, the SDK's worked example) with the DOM kept as the
fallback behind a feature-detect; the `dkdungeon` node IS the recipe and broadcasts nothing;
the key/door objective is gone (Realms is the game). Toolbox writes go to a live Dungeon node
when one owns the recipe (api.flow.setNodeData), otherwise the Kit's own op.
- `dungeon-realms` 2.0.0 is an OVERLAY: it reads the Kit through api.scene(), observes
{seed, floorIndex} every frame and rebuilds its own group 'dungeon-realms' (gems + portals from
the contract), drives travel through kit.showFloor (the Kit replicates), publishes gems/portals
as minimap markers through kit.setMarkers (DEVX #13), writes Game Rules > disableFlight as
play.grounded (DEVX #14 - the capture-phase Q/E swallow is deleted), gates on api.isPlaying()
(DEVX #11). `drhud` and `drdungeon` are deleted, not ported; the HUD is core HUD elements the
template authors, fed by the new `drvalue` (number out), `drrows` (rows into a HUD list by id)
and `drevent` (event out, fireNodeTrigger on the originating peer). The start/victory MENU stays
module DOM (DEVX #23, filed). Pure rules in rules.js with node tests.
- helpers.cjs: openModules matches the User tab by prefix - the label grows a count after the
first install, so a second install on the same peer timed out (found by the two-module flight).
- index.json: dungeon = Dungeon Kit (tool), both rows 2.0.0; AUTHORING.md lists the Kit as the
toolbox example and Realms as the inter-module-seam example; DEVX-REQUESTS notes what 2.0 retired.
Counterfactuals:
- contract.test: grounded default removed -> "a bare Kit publishes grounded:true" red; restored.
- rules.test: the `have` cap removed -> "have is capped at the total" red; restored.
- dungeon-realms flight: Game Rules disableFlight off -> play.grounded false on the next frame,
then true again (in-suite); a collected gem removes exactly one minimap marker on both peers.
- dungeon flight: a bare Kit publishes markers: [] (the "puts nothing" rule) - Realms adds them.
Suites (APP_URL 5216, both zips on every peer): tests/dungeon.test.cjs NEW 23/23;
tests/dungeon-realms.test.cjs 35/35 (old flight 22/22 as base); node tests: dungeon 45/45
(29 moved + 16 contract), dungeon-realms 20/20.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…n, radius on the form - the manager redraws on api.flow.onChange + api.game.onChange (core PR #224) instead of a 500ms interval; a clock runs only while a respawn countdown is on screen, an older recipe chain's latch value is re-read once 300ms after a change, and the panel re-reads on pointerenter (a rename in the viewport is not an event) - the recipe asks api.flow.freeRegion once per pair, so pairs stack under whatever is in the graph instead of on a row derived from collectibles.length (DEVX #16) - both seams are FEATURE-DETECTED: on a 1.14.0-shaped api the module keeps its 500ms poll and fixed rows (asserted by registering the same source against an api with the seams removed) - S4 plan items: the touch radius is on the form (disabled with the reason under a click trigger); a group header whose counts include older recipe chains says "+N older recipe chain(s) counted here (edit in the node editor)" - version 1.1.0 (manifest, module, index.json); DEVX-REQUESTS #16/#17 marked SHIPPED - flights: module-collectible 130/130 (+1 legacy-copy check; the R29 checks moved to their own file because the main flight sits near the runner's 8-minute cap — it was killed at 480s with them in), new module-collectible-signals 14/14, against core feat/29-sdk-polish on :5213 - counterfactual: forcing the poll branch -> "redraws on change signals" and "an idle manager touches nothing" red (12 DOM mutations in 2.5s) - S3 measured here too: one group press over 60 members = 60 nodedata messages, 7.1ms, 0 undo entries -> the batch seam stays held (see core PR #224) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e as a node, the core hud fed by realms nodes
- `modules/dungeon-realms/src/def.js` + `emit-def.mjs`: `npm run build:dungeon-realms` now also emits
`dungeon-realms.def.json`, the def core's scripts/author-templates.cjs reads (the football shape,
PR #218). `modules: [{id:'dungeon'}, {id:'dungeon-realms'}]` - the two-module case the requirement
list exists for. The graph: a Number feeds the Dungeon Kit node's seed (1337, apply on) wired to the
'Entrance plinth' Object Selector, Game Rules / Start Menu / Prop Counter, Realms Value -> HUD Text
(gems, need, level, levels, players), Realms HUD Rows -> HUD lists (objective, players), the gem
event -> Counter -> HUD Text, Realms Event start/victory/reset -> Set Game State, P pause / Resume /
Quit. The HUD document: menu / hud / pause / over screens. Objects: the entrance arch (plinth, two
pillars, lintel, lantern, light), placed by the emitter at the entrance room seed 1337 produces with
the Kit's own generator, so players spawn under it. Night env, click + grounded play block, AGX +
bloom + smaa post, `thumb.sceneGroups: ['dungeon-module']` so the card shows the world.
- The world is regenerated from the graph's Dungeon node on every load: the file carries the recipe,
never the level data (golden rule 5 - scene-root content is not saved).
Counterfactuals (core suite game-dungeon-realms, 64 checks, against the authored scene):
- game.js no longer pulses the start event -> 3.7 "the On start stamp landed in the TRIGGER LOG" red
(and 3.8-3.10, 3.14, 5.2 downstream); restored, module.js byte-identical (md5).
- in-suite: a menu-seeded dungeon (777) stands BEFORE the load; after applySession the Kit shows seed
1337 from the node (the /clear all -> node ordering); the replicated gem event counts ONCE on B.
Authored on 2026-09-19 against 5216: scene 9169 B, thumb 3848 B, staged for the integrator in
cloud-lane-29-staging/games/dungeon-realms/.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…e, value/event nodes, the def - `modules/untangle` 2.0.0 is bundled from src/ (esbuild) so the puzzle is a PURE, node-tested module (`puzzle.js`: mulberry32, generate, chordsCross, segsCross, edgeCrossings - 10 checks incl. solvability over 40 levels). Positions are board units (the unit disc) so a radius change re-renders without touching the replicated model. - `utboard` "Untangle Board" (level, radius, boardY, x, z, yaw, autoAdvance, apply): the drdungeon shape, rule ownership. Its level is the STARTING level - applied when the node's value changes (so autoAdvance can move on) and never on first sight over a landed state sync. THE INSTALL GATE: with no node in any graph the module waits EXPIRE_FRAMES and then falls back to today's behaviour (a level-1 board on its own); with a node, the node decides. A scene clear (applySession runs /clear all FIRST) resets to level 1 and re-arms the node so it re-applies on the next tick even when its data did not change. - The canvas SPRITE HUD is the VR-only path (api.isVR()); on desktop the template's HUD Text reads `utvalue` (level, crossings, solved, dots, edges, count) and `utevent` (solved, level) is pulsed with fireNodeTrigger on the peer that dropped the solving dot. - Golden rule 7: the module-state exchange on connect is SYMMETRIC, so an UNTOUCHED fallback board answers getState with null - a joiner's fresh level-1 scramble used to overwrite the room's game (found by the new flight's late-joiner check). - `src/def.js` + `emit-def.mjs` -> `untangle.def.json` (`npm run build:untangle`): the thin, honest template - a room (floor, back wall, the pedestal the node targets, a ring frame, two lamps), the board node at level 2, value readouts into HUD Text, the solved event into a Counter, Start / P pause / Quit, a 3-screen HUD, sunset env, `thumb.sceneGroups: ['untangle-module']`. The file carries the LEVEL, never the positions. - index.json: untangle 2.0.0 (no `template` field until the scenes release - the staged index row has it). Counterfactuals: - puzzle.test: the non-crossing chord filter removed -> "the ring layout solves every graph (0/40)" red. - tests/untangle.test.cjs (25 checks): the joiner's untouched board did NOT overwrite A (in-suite, was a real red before the null-state fix). - core suite game-untangle (46): onSceneClear no longer re-arms the node -> 1.12 "the same file loaded again ... the node re-applied LEVEL 2 (a board exists)" red; restored, module.js byte-identical (md5). Authored on 2026-09-19 against 5216: scene 8946 B, thumb 2802 B, staged in cloud-lane-29-staging/games/untangle/. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…t, DEVX #15 yes - AUTHORING.md section 5: a Knock row (`onHit(cb)` - every knock this peer sees, with its fields, returns the unsubscribe; `hitLog()` - a copy {last, recent}, runtime state), football named as the game-SDK worked example, and an "onHit fires on every peer" note (bump a per-player counter only for your own hit, or every peer banks it). - README.md module table: the football row (it is where the module table lives). - DEVX-REQUESTS.md #15: "yes" in the gap table, and a #15 row in the core-status table (YES, per 24-B D2: api.peerIds() diffed each second is the disconnect signal, football does exactly this; api.peerNames() the name half). - checked: npm run test:football green; the football flight (npm test -- football) 91 PASS against core 1.14.0 + lane 29-football. Docs-only; the other 16 flights not rerun. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… for players - the `health` module (modules/health, bundled from src/ like football): a `health` effect node (max, scope object|player, regen, deathAction hide|respawn|nothing, respawnDelay; inputs damage/heal numbers + respawnAt object), `damage` / `heal` / `healthreset` / `healthevent` event nodes, a `healthvalue` value node (fraction/current/max/alive), the manager toolbox (one chain per selected object or one player health, ONE undo entry each, live rows), a debug line and two HUD actions (bar + hit points) - fork 2 of roadmap 29 as built: an object's hp is DERIVED from core Counters fed by damage pulses (one trigger-log entry per pulse, applied once per peer; the count rides the DEVX #18 handshake so a joiner converges); a player's hp is their own peerVars row {base, at} with regen a pure function of the clock. No authority, no timer sends, no registerStateSync - the one rule for sources: a replicated stamp is fired LOCALLY by every peer, a source only this peer saw (its click) is fired REPLICATED; first sight never fires; the round reset seeds on the first sight of the game shell (a joiner mid-round must not zero the counts it was just handed) - node tests (npm run test:health, 51 checks): ledger + graph walk + recipe, with the shared-add counterfactual in the ledger test - flight tests/module-health.test.cjs (74 checks, two peers + a late joiner): recipe/undo, convergence from two peers, the dead-hit guard, healthvalue + debug line, joiner reads the same numbers and re-applies nothing, per-player rows, regen, death fires once, respawn, a new round restores, heal, the manager rows - counterfactuals: click pulses fired locally instead of replicated -> 8 checks red (B reads 3/3, the kill never reaches the peer, the joiner's hit reaches nobody); round reset acting on the first sight of a round -> the joiner zeroed its counters and read Crate1 alive (seen live before the seed rule); a read-modify-write add from two peers converges on 1 hit (in-flight counterfactual, the ledger read 2) - suites: module-health 74/74; test:health 51/51; core untouched (no seam needed: the spike proved a module authors and pulses core's spawn node) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…tNodesData - bulkApply collects the members that differ and hands them to core's api.flow.setNodesData (core R29 S3b) in one call: one Ctrl+Z undoes the whole group change. Feature-detected - on a core without it the module writes per node exactly as before. Members that already agree are still skipped; the wire is unchanged (one nodedata per member). - README: the group-edit paragraph and the toolbox row say so. v1.1.1. - module-collectible-signals section 12: a 20-member group press is ONE flownodes data entry of 20 items attributed to `collectible`, replicates to the peer, one undo restores all twenty on both peers; the 1.14-shaped api (setNodesData removed) still flips the whole group per node. - Counterfactual: batch call disabled (forced fallback) -> 3 red (17 -> 37 entries, one undo restored one member). - module-collectible-signals 21/21 (base 14/14); module-collectible 130/130 (base 130), against core feat/29-s3-followup on :5213. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…at a spawn point - `damage.source`: `hit` rides core's knock feed (api.onHit: one `hit` message, every peer fires its own LOCAL pulses, `scale: speed` = amount x speed/speedRef capped 3x, floored at one point); `touch` is the self-proximity EDGE on the health's object (the toucher fires REPLICATED pulses, leave-and-return touches again); `zone` hurts the local player `amount` every 1/perSecond while inside `radius` of the object wired into `damage.zone` (local + the row). Only a peer in play detects touch/zone - respawn: a player comes back at full on their row and `api.flyTo` flies the EDITOR camera to the object wired into `respawnAt`; in play the rig owns the camera and no seam moves the player (DEVX #23, owed) - the flight proves the editor case - `health` is a VALUE node (its hp) with a `target` object input, NOT an effect: the runtime re-seats an effect target's base pose every frame (football's "no node may target the ball"), which pinned a damageable crate in place the moment the host was in play - observed in the flight (a replicated move did not stick). The engine hides a dead object itself, in play only, and gives back exactly what it hid (21-F2 by hand) - the toolbox form gains Radius; engine.test.mjs runs the engine against a fake api (click/wired/first-sight/reset, the joiner round rule, touch/zone/respawn, the knock feed with its stamp de-dupe) - counterfactuals: touch fired while the peer moved the CAMERA -> nothing (the play rig re-seats the camera at its own spot, so the flight moves the object instead); the health node as an effect -> the crate could not be moved under the player in play, zone never fired (13 red across two runs); a knock stamp replayed -> dropped (unit); a stamp present at first sight -> no pulse (unit); a hit on the dead -> no pulse (unit) - suites: module-health 99/99 (two peers + joiner: knock via a real probe sweep, touch on the peer, zone + death + respawn on the host); test:health 81/81 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…derived from counters - `modules/waves` (src/ bundled like football): a `waves` value node (the current wave; goal object input; the size curve, interval, walk speed/stagger, reach, the spawn-point name prefix), `wavesvalue` (wave/left/size/waves/done), `wavesevent` (start/wave/over, local on every peer), the arena toolbox (enemies from the selection or N boxes; per enemy the health chain + a heal chain + a zone chain into the player's health; the Waves node with its goal; over -> Set Game State; the player health), a debug line, two HUD actions, hud.js (menu/hud/over + the driver graph) and waves.def.json (the template def, author-script shape, staged) - THE LEDGER TRICK: enemies are pre-placed objects with the health module's chains; their hits never reset between waves - each wave every peer fires LOCAL heal pulses (idempotent against the heal counter and a per-node expectation, since nodeValue republishes ~6/s), so a hit counter reads hp x kills and the wave is a pure function of the counters, which a late joiner gets in the handshake - enemies walk spawn -> goal as a pure function of (the wave's start stamp: the round's startedAt in seconds, or the previous wave's last kill + interval; the clock), written locally on every peer; parked poses restored when no run is on - over: every peer fires the local `over` pulse (-> the game shell) and appends the SAME run entry to gameState.vars['waves:<name>'].runs (keyed by the last kill's stamp, idempotent, capped 50) - the football log precedent without an authority - health: the peer that dealt a killing blow (its click, its own hand's knock) bumps its own `kills` peerVars row, read off the last sweep's number (flowValues is ~6 Hz) - tests/helpers.cjs: the Modules > User tab locator is /^User/ (an exact "User" hung a second install on one peer: the tab reads "User (1)") - node tests test:waves 54/54 (the curve with its counterfactuals: a straggler keeps the run open, later kills do not skip a wave; the recipe, the HUD graph, the def byte-identical twice); test:health 84/84 (kill credit, mine vs another hand) - flight tests/module-waves.test.cjs 59/59, two peers + a late joiner: the arena from the toolbox, wave 1 walks (B places the enemy where A does), two peers clear it and the survivors heal into wave 2, the joiner reads wave 2 and the same ledger, three peers clear wave 2 -> done/over on all three, kills 2/2/1, one identical run entry on A, B and C, a new round zeroes everything - counterfactuals: heals into the next wave removed -> 12 red (the ledger, done on every peer, over, the kills, the log); the round stamp compared in ms -> the wave never started (seen live, fixed by one conversion); heals fired again before the value republished -> double heals and uncredited kills (seen live, fixed by the expectation); held: module-health 99/99 after the engine change - the spawner is not used (spike: a transient copy has no nodes, pulses carry no payload, copies live only while the sim runs); enemies are pre-placed (Towers B8) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…or health + waves - index.json: `health` and `waves` rows (category game; no `template` field - the waves def is staged for the integrator, not released here) - README.md: the two modules in the table - AUTHORING.md: both in the worked examples; friction-log entries an effect node pins its target's pose, the two clocks (seconds of day vs the round's ms), nodeValue is ~6 Hz, the /^User/ tab locator - DEVX-REQUESTS.md: #23 api.teleportPlayer (no seam moves the play-mode player), #24 a synchronous trigger count / live Counter read, #25 the two clocks on the api - zips: npm run pack -- --all --force packs 20 modules incl. health.zip and waves.zip Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
# Conflicts: # DEVX-REQUESTS.md
# Conflicts: # package.json # tests/helpers.cjs
1.15: roadmap 29's modules lanes (collectible 1.1.1, Dungeon Kit + Dungeon Realms 2.0.0, Untangle 2.0.0, health + waves 1.0.0, football docs)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
dev → main so the gallery CDN (
modules@main) serves the 1.15 zips:dungeon2.0.0 (the Dungeon Kit),dungeon-realms2.0.0,untangle2.0.0,health1.0.0,waves1.0.0,collectible1.1.1, plus the football docs row. Gates in #9 (node tests ALL PASS; 8 flights against corefeat/1.15ALL PASS). Must land BEFORE scenesv2moves (the Dungeon Realms / Untangle cards install from here).🤖 Generated with Claude Code