Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
dce39a1
[feat] shadows on by default + shadow catcher (roadmap 12 V-1)
AlexZ005 Jul 19, 2026
c2005ce
[feat] curated default palette + look tune (roadmap 12 V-3)
AlexZ005 Jul 19, 2026
8122698
[feat] Add > Terrain sculptable ground primitive (roadmap 12 T-1)
AlexZ005 Jul 19, 2026
abdf0f3
[feat] multi-select-aware object context menu (roadmap 12 U-2)
AlexZ005 Jul 19, 2026
85eb777
[feat] ping v2 - object highlights + discoverability (roadmap 12 U-1)
AlexZ005 Jul 19, 2026
ca50bac
[fix] VR snap-angle cycle unified + live radial label (roadmap 12 R-2)
AlexZ005 Jul 19, 2026
f7d606f
[feat] VR pointer beam + hit reticle + hover shell (roadmap 12 R-1)
AlexZ005 Jul 19, 2026
d8c675e
[feat] audio-pack install path + sound-node rolloff (roadmap 12 M-2)
AlexZ005 Jul 19, 2026
6508929
[feat] scene music singleton (roadmap 12 M-1)
AlexZ005 Jul 19, 2026
4246dcb
[feat] N8AO ambient occlusion + view modes (roadmap 12 V-2)
AlexZ005 Jul 19, 2026
c6de525
[feat] settings search + toast dedupe/cap (roadmap 12 U-3)
AlexZ005 Jul 19, 2026
4192ce9
[feat] physics core rework - kinematic flow bodies + playground (road…
AlexZ005 Jul 19, 2026
9943d05
[feat] module SDK input layer + api.physics (roadmap 12 K-C)
AlexZ005 Jul 19, 2026
770ce92
[feat] physics joints - weld/hinge/motors + attach menu (roadmap 12 P-B)
AlexZ005 Jul 19, 2026
be508cf
[feat] possess + avatar controller module (roadmap 12 K-D)
AlexZ005 Jul 19, 2026
494514c
[feat] VR essentials interactables module (roadmap 12 K-E)
AlexZ005 Jul 19, 2026
61ef385
[feat] hand models + broadcast-identity custom hands (roadmap 12 R-3)
AlexZ005 Jul 19, 2026
8fcd3a0
[feat] terrain brush sculpting (roadmap 12 T-2)
AlexZ005 Jul 19, 2026
5853929
[feat] drivable car module (roadmap 12 K-F)
AlexZ005 Jul 19, 2026
ef6543a
[docs] update CLAUDE.md + skills for roadmap 12
AlexZ005 Jul 20, 2026
cd149d5
Merge branch 'main' into feature/playground-polish
AlexZ005 Jul 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 38 additions & 11 deletions .claude/skills/e2e-verify/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,19 @@ The init script (helpers does it) sets `localStorage.debugStores='true'` +
`hasSeenDisclaimer='true'`. App.svelte then publishes `window.__stores` = all stores
spread + modules: `meshEdit, vrControls, autosave, voiceChat, annotationsHandler,
flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK,
drawMode, pathCapture, lockControl, prefabs, physics, userModules, environment,
animatedImports, fileHandler, sceneBounds, cameraClip, ping, sessions, geometryEdit,
lightParams, themes, vrRadialMenu, vrPalette, vrWindowPoses, vrKeyboard, faceEdit,
avatarModel, explorer, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay,
sceneAssets, THREE, GLTFExporterModule, snapping, flowSockets, networkQuality, packs,
customNodes, nodesHandler, objectMenu`. Also on the stores spread: `viewportMenuOpener`
(Scene registers its context-menu opener here — call `$viewportMenuOpener(x,y,forceEmpty)`
to open the viewport/create menu without a right-click).
drawMode, pathCapture, lockControl, prefabs, physics, joints, possess, handModels,
terrainSculpt, userModules, environment, sceneMusic, animatedImports, fileHandler,
sceneBounds, cameraClip, ping, sessions, geometryEdit, lightParams, shadowDefaults,
palette, viewModeCtl, inputRuntime, shortcutsRegistry, themes, vrRadialMenu,
vrPalette, vrWindowPoses, vrKeyboard, faceEdit, avatarModel, explorer, bottomDock,
explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssets, THREE,
GLTFExporterModule, snapping, flowSockets, networkQuality, packs, customNodes,
nodesHandler, nodeCatalog, objectMenu`. Naming trap: `__stores.viewMode` is the
STORE (from the sceneStore spread); the viewMode MODULE is `viewModeCtl` — a module
key that shadows a same-named store silently breaks tests (#12 lesson). Also on the
stores spread: `viewportMenuOpener` (Scene registers its context-menu opener here —
call `$viewportMenuOpener(x,y,forceEmpty)` to open the viewport/create menu without
a right-click).

**Never dynamic-import `/src/lib/x.js` from page code to reach a singleton** — once
vite HMR-timestamps the app's copy you get a SECOND module instance (empty stores,
Expand Down Expand Up @@ -80,13 +85,35 @@ Use `https://theprototype.app:5173/` — hosts-mapped to 127.0.0.1; the `.app` h
makes peerjs use the **public cloud** (localhost tries ws://localhost:9001 and fails).
`helpers.connect(B, A)` does: fill peer id → Connect → Approve on A → ~9s settle.
Late joiners: connect a third context AFTER mutations, assert handshake state arrived
(objects/nodes/annotations/module state/env/custom defs). Voice: launch with
`--use-fake-device-for-media-stream --use-fake-ui-for-media-stream`.
(objects/nodes/annotations/joints/module state/env/music/handmodel/custom defs).
Voice: launch with `--use-fake-device-for-media-stream --use-fake-ui-for-media-stream`.
**B→A messaging works** since #12 (the adopted-inbound-conn fix) — tests may drive
mutations FROM the joiner (peer move streams, claims). When a suite needs the
dual-module-instance split collapsed, `freshReload(peer)` BEFORE `connect` and
re-read the id (`peer.id = await …peers.subscribe…peer.id`) — a reload mid-mesh
drops the P2P session.

## Known flakes / traps

- First run after adding a dependency: vite re-optimizes and reloads mid-test — rerun.
Lazy wasm (rapier) needs a throwaway prewarm page first (see physics.test.cjs).
Physics sims run REAL-time since #12 (fixed-timestep accumulator) — falls/settles
take wall-clock seconds even under a throttled rAF; don't compensate with huge waits.
- **Machine saturation**: headless pages can run at ~4fps with timers ~1.8x slow when
the host is loaded (dozens of user Chrome processes — do NOT kill them). Symptoms:
timeouts on waits that "always worked", missed one-shot flag reads. Cures: generous
`eventually` windows, BEHAVIORAL asserts (did the box move) over one-shot state
reads, and for flags that flicker (hold/claim booleans) an IN-PAGE sampling loop
(`setInterval` 50ms inside one `evaluate`) instead of round-trip polling.
- **Large plain-number arrays blow binarypack**: `conn.send` with a ~40k-element plain
array throws "Maximum call stack size exceeded" — and `broadcast()`'s try/catch
SWALLOWS it, so the message silently never leaves. Send raw bytes instead
(`new Float32Array(arr).buffer`) and normalize on receive (meshgeo/terrain do this).
If a big payload "never arrives" in a test, suspect this before the network.
- Pre-existing flakes (reproduce on a clean base — don't chase them into your diff):
add-menu search-Enter, sound-node Play overlap, connect-overlay querySelector,
scene-music byte-push timing. To PROVE a failure is pre-existing:
`git stash push -u`, run the suite on HEAD, `git stash pop`.
- Phase-comparison asserts between two peers: two sequential evaluates skew ~150ms —
tolerances ≥0.6 for fast oscillations, or compare Promise.all-sampled values.
- Overlays intercept clicks (properties drawer covers right ~320px; modals block all;
Expand Down Expand Up @@ -159,7 +186,7 @@ Late joiners: connect a third context AFTER mutations, assert handshake state ar
(the runner just `node`s each file; see net-backoff.test.cjs). Track PASS/FAIL locally
and `process.exit(1)` on failure (helpers.finish needs a browser).
- svelte-check delta hunting: `npx svelte-check --output machine | grep <yourfile>`;
baseline 2026-07-18 = **502 errors / 77 warnings** (drifts down as flowbite/typed
baseline 2026-07-19 = **501 errors / 77 warnings** (drifts down as flowbite/typed
code is removed — hold whatever it currently is; add no NEW). Note: in the big
JS-mode `.svelte` files (Scene.svelte) `@param {T}` JSDoc on a function is NOT honored —
give the param a default (`slot = 0`) to force the type, and prefer explicit locals
Expand Down
63 changes: 46 additions & 17 deletions .claude/skills/peer-feature/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,12 @@ in the codebase — copy the referenced implementation.
animated-import mixers. Seeded randomness only (`mulberry32`); no accumulation in
effects (compute from `base` + `time`); no `Math.random()` in anything replicated.
- **Authoritative** — one peer simulates and broadcasts results as plain messages;
others just apply. References: physics (initiator → `move` at ~10 Hz per awake body,
busy-guard message), pong (spawner owns the ball at ~12 Hz). Use when simulation
can't be deterministic; guard against two authorities.
others just apply. References: physics (initiator steps the world, movement-gated
`move` broadcasts, busy-guard message), pong (spawner owns the ball at ~12 Hz), the
car module (the blessed INPUT-FORWARDING recipe: every peer sends its inputs
`{op:'drive', throttle, steer}` at ~20 Hz, only the `api.physics.isInitiator()`
peer applies motors — result replicates as plain moves). Use when simulation can't
be deterministic; guard against two authorities.

## Not everything replicates — some state is deliberately LOCAL

Expand All @@ -29,25 +32,38 @@ prune it in `handleDisconnected` (or derive the UI from live peers so stale entr
can't render). References: `networkQuality.js` (per-peer RTT/relay from `getStats()`,
polled locally), the Explorer **pack library** (imported packs stay local until an
explicit future "Share"; only a *placed* object replicates through the normal import
path), and the LOCAL-prefs modules (themes, cameraClip, WindowShell `ws:*`). Rule of
thumb: if two peers would independently compute the same value, or it's a personal
setting, keep it off the wire.
path), input claims (`inputRuntime.claimInput` — a claim only pauses THIS peer's own
input consumers, nothing on the wire), view mode/shadow quality/sculpt brush prefs,
and the LOCAL-prefs modules (themes, cameraClip, WindowShell `ws:*`). Rule of thumb:
if two peers would independently compute the same value, or it's a personal setting,
keep it off the wire.

## Checklist for a new replicated feature

1. **State** in a store (`src/stores/*`) or module-level writable; uuid/id-keyed,
plain-serializable (peerjs binarypack: ArrayBuffers OK — raw-bytes syncs like
`objectfile` ride on this; no class instances/functions).
`objectfile` ride on this; no class instances/functions). **Large numeric payloads
MUST go as raw bytes**: a plain array of ~40k numbers makes binarypack recurse to
"Maximum call stack size exceeded" — and `broadcast()`'s try/catch swallows it, so
the send silently vanishes. Send `new Float32Array(arr).buffer` and normalize
array/ArrayBuffer/typed-view on receive (meshgeo is the reference).
2. **Local mutation function** applies + broadcasts:
`get(peers)?.send({ type: 'mything', ... })` (pattern: `annotationsHandler`).
3. **Receive case** in `peerHandler.svelte.js` `conn.on('data')` — applier does NOT
re-broadcast.
4. **Late joiners**: `getmything` request in `sendHandshake()` + a full-state reply
that retries until `conn.open` (`sendNodes`/`sendNodeDefs`/`sendModuleStates` —
that retries until `conn.open` (`sendNodes`/`sendJoints`/`sendModuleStates` —
never bare `setTimeout` sends: peerjs silently drops pre-open messages). Singleton
state (environment) instead pushes with a `changedAt` stamp, latest-wins — and any
symmetric pull needs a deterministic direction (nodesync: lower count pulls,
peer-id tiebreak) or drifted peers swap forever.
state (environment, sceneMusic) instead pushes with a `changedAt` stamp,
latest-wins — each singleton gets its OWN message type (music deliberately does
NOT piggyback on `environment` because env state round-trips through preset
export/import and would leak the track into presets) — and any symmetric pull
needs a deterministic direction (nodesync: lower count pulls, peer-id tiebreak)
or drifted peers swap forever. A replicated LIST of small defs (joints) copies
the annotations pattern: create/delete messages + full-list handshake reply +
sender-side delete-cascade + a presence-style history kind. Per-peer IDENTITY
choices (avatar photo, hand model) broadcast a content HASH with presence/
userdata and receivers pull the bytes via assetShare (`handModels.js`).
5. **Where does it live in the scene?** `objectsGroup` children = replicated, listed,
GLTF-synced, anyone edits. Scene-root groups (fixed `name`) = local/derived —
helpers, env rig, module content; rebuild them from state; they need
Expand All @@ -74,12 +90,13 @@ Throttle continuous streams (~10–20/s) with a final unthrottled send on gestur
peers get stale-expiry cleanup (`drawlive` 5s, ping 4s).

**Geometry/topology changes** can't ride a per-vertex channel — snapshot the FULL
geometry (`meshgeo`: uuid + positions array, size-capped ~45k floats, `faceEdit.js`).
geometry (`meshgeo`: uuid + positions, size-capped ~45k floats, `faceEdit.js`; the
WIRE format is raw `Float32Array.buffer` bytes per the binarypack rule above).
Receivers swap the geometry wholesale, the history kind replays the same snapshot, and
the receive applier must REBUILD any live edit-session caches (applyMeshGeo re-derives
its face groups — a stale cache after undo/remote swap corrupted gestures once). Live
reshape gestures stream throttled previews (~5/s) and commit ONE snapshot + undo entry
on release.
its face groups AND the terrain sculpt weld map — a stale cache after undo/remote swap
corrupted gestures once). Live reshape gestures stream throttled previews (~5/s) and
commit ONE snapshot + undo entry on release.

## Adding a VR panel (the follower-window pattern)

Expand Down Expand Up @@ -113,8 +130,20 @@ entry)` (replicated `/create <Name>`), `registerClickHandler(fn(hitObject) => bo
(desktop + VR trigger), `registerInteractiveGroup(name)`, `registerFrameTask(fn(time))`,
`send(payload)`/`onMessage(fn)` (namespaced `{type:'module', moduleId}`),
`registerStateSync({getState, applyState})` (late joiners), `registerMenu(label, fn)`
(renders on the module's manager card), accessors `scene() objectsGroup() peerId()
toast() now() THREE assetUrl(path)`.
(renders on the module's manager card), `registerVRMenuEntry({id, group, label,
action, closes})` (VR radial sector), accessors `scene() objectsGroup() peerId()
toast() now() THREE assetUrl(path) selectedUuid()`. #12 additions (reached via PRIMED
dynamic imports in moduleSDK — static edges close TDZ cycles): **input** —
`registerBindings` (Settings ▸ Shortcuts listing), `input()` snapshot
`{codes:Set, axes, vrButtons}`, `onInput(fn)`, `claimInput/releaseInput('keys'|
'locomotion')` (pauses the host's OWN consumers, LOCAL, always release); **physics**
— `api.physics.{isInitiator, applyImpulse, setJointMotor, joints()}` (mutations
initiator-only — forward inputs, see the authoritative car recipe above);
**possess** — `possess(uuid, {camera:'chase'|'orbit'|'none'})`/`releasePossess()`
(possessing = selecting = the lock; ONE undo per ride). A module KIND peers must
agree on derives from the replicated object NAME (car's 'Carbody'), never
locally-set userData. Worked examples: `src/modules/essentials/` (interactables) +
`src/modules/car/` (physics + input + claims).

Version trust: peers exchange `[{id, version}]` on connect and toast on mismatch
(advisory). Module viewport content = scene-root group rebuilt from state (see rule 5);
Expand Down
Loading