// Live multiplayer ghosts: every open tab of the sandbox broadcasts its // duck state over WebRTC and renders the other visitors as translucent // ducks. There is no backend - this is a static Space - so peer discovery // uses Trystero's serverless signaling over public Nostr relays. Payloads // are tiny (22 floats + a variant name at SEND_HZ), and at most MAX_GHOSTS // ghosts are instantiated regardless of how many peers are in the room. // // This module deliberately imports nothing from duck.js / variants.js: // on the private Space those need the ?__sign JWT that only rl.js knows // how to append, so all rig helpers arrive through init parameters. const TRYSTERO_URL = "https://esm.run/trystero@0.25.3/nostr"; const APP_ID = "microduck-sandbox"; const ROOM = "lobby"; const MAX_GHOSTS = 3; const SEND_HZ = 15; const GHOST_OPACITY = 0.35; // Snapshot interpolation: each ghost renders INTERP_DELAY_MS in the past, // lerping between the two buffered snapshots that bracket the render time // (timed by local ARRIVAL, so no clock sync and no payload change - old // clients interoperate). This replaces the old exponential chase toward // the latest packet, whose move-then-stall rhythm read as micro-stutter. // The delay absorbs network jitter; past the newest snapshot the ghost // holds pose, so a background-throttled 1 Hz sender degrades into slow // discrete steps instead of teleports. Nobody synchronizes on ghosts, so // the added latency is invisible. const INTERP_DELAY_MS = 150; const BUF_MAX = 40; // snapshots kept per ghost (~2.5 s at 15 Hz) // Idle visitors stay invisible: a ghost is only shown once its peer has // strayed from the pose of its first state message (everyone spawns at // the arena center). Once revealed it stays visible for the whole peer // session, so a reset teleporting the duck back to center doesn't blink // the ghost out. const REVEAL_DIST = 0.2; // m, horizontal distance from spawn const REVEAL_YAW = 0.4; // rad (~23deg), turning in place also reveals // state.p is raw MuJoCo qpos[0..6]: [x, y, z, qw, qx, qy, qz], Z-up. const yawOf = (p) => Math.atan2(2 * (p[3] * p[6] + p[4] * p[5]), 1 - 2 * (p[5] * p[5] + p[6] * p[6])); const hasMoved = (spawn, p) => { const dx = p[0] - spawn.x, dy = p[1] - spawn.y; if (dx * dx + dy * dy > REVEAL_DIST * REVEAL_DIST) return true; const dyaw = yawOf(p) - spawn.yaw; return Math.abs(Math.atan2(Math.sin(dyaw), Math.cos(dyaw))) > REVEAL_YAW; }; export async function initGhosts(env) { const noop = { update() {}, peerCount: () => 0, ghostCount: () => 0, debug: () => [], mapDots: () => [] }; let room; try { const { joinRoom } = await import(TRYSTERO_URL); room = joinRoom({ appId: APP_ID }, ROOM); } catch (e) { console.warn("ghosts disabled (signaling unavailable):", e); return noop; } const { scene, cloneRig, setJoint, setJawOpen, applyVariant, jointNames, getLocalState } = env; // Locomotion-variant rig source: state.l 1 = rollers, 0/absent = legs // (old clients never send it). Falls back to the leg rig when the local // tab hasn't built the roller rig yet - known v1 limitation. const rigFor = (l) => (env.getRigFor ? env.getRigFor(l ?? 0) : env.rig); // trystero 0.25 (backed by @trystero-p2p): makeAction returns // { send, onMessage, onReceiveProgress } where onMessage is a SETTER - // the receive handler is registered by assignment, not by calling it. const act = room.makeAction("s"); // send() rejects when a peer's channel drops mid-transfer; that peer will // be swept anyway, so failures are non-events. const sendState = (data) => act.send(data).catch(() => {}); // Ghosts share geometry with the local rig (cloneRig), but get their own // transparent material clones so repainting them never touches the // visitor's own duck (variants.js caches materials globally). // // Rendering: a ghost reads as ONE translucent shell, not ~70 individually // transparent meshes (through which every internal motor/PCB used to show). // Classic depth-prepass silhouette, kept inside the transparent pass so the // grid floor/walls (transparent ShaderMaterials at renderOrder 0) still // draw behind ghosts instead of being depth-rejected: // PREPASS (renderOrder 1): every ghost mesh duplicated as a color-less // twin (colorWrite off, depthWrite on) -> the depth buffer ends up // holding the nearest ghost surface per pixel. // BEAUTY (renderOrder 2): the real meshes, transparent + depthWrite off; // the default LessEqual depth test then rejects everything except the // fragment matching the prepass depth, so internals and back shell // layers are skipped and each pixel is shaded exactly once. // Orders are shared by all ghosts (not per-ghost pairs): every beauty pass // tests against every ghost's prepass depth, so overlapping ghosts occlude // each other like solids instead of double-blending. const PREPASS_ORDER = 1; const BEAUTY_ORDER = 2; const ghostify = (rig) => { // Collect first: twins are added below and must not be re-traversed. const meshes = []; rig.root.traverse((o) => { if (o.isMesh) meshes.push(o); }); const cache = new Map(); let depthMat = null; for (const o of meshes) { // Materialization-FX wire overlays cloned along with the local rig: // drop them, they share the LIVE scan shader material and would // flash on this ghost every time the local duck re-materializes. if (o.userData.fxOverlay) { o.parent?.remove(o); continue; } if (o.userData.ghostPrepass) continue; // twin from a previous ghostify let m = cache.get(o.material.uuid); if (!m) { m = o.material.clone(); m.transparent = true; m.opacity = GHOST_OPACITY; m.depthWrite = false; cache.set(o.material.uuid, m); } o.material = m; o.renderOrder = BEAUTY_ORDER; if (!o.userData.hasGhostTwin) { if (!depthMat) { // Depth-only material: cloned (no THREE import here) and muted. // transparent stays true so the twin sorts into the transparent // pass, after the grid but before the beauty meshes. depthMat = m.clone(); depthMat.colorWrite = false; depthMat.depthWrite = true; } // Twin as an identity-transform child: follows joints for free. No // userData.meshName, so applyVariant never repaints it. const twin = new o.constructor(o.geometry, depthMat); twin.renderOrder = PREPASS_ORDER; twin.userData.ghostPrepass = true; o.userData.hasGhostTwin = true; o.add(twin); } } }; const ghosts = new Map(); // peerId -> ghost // Interpolation snapshot: the pose parts of a state message, stamped // with the local arrival time. const snapOf = (state) => ({ p: state.p, j: state.j, w: state.w ?? 0, at: performance.now() }); const makeGhost = (state) => { const rig = cloneRig(rigFor(state.l)); applyVariant(rig, state.v); ghostify(rig); scene.add(rig.placer); const trunk = rig.bodies.get("trunk_base"); // Snap straight to the first received pose: no fly-in from the origin. trunk.position.set(state.p[0], state.p[1], state.p[2]); trunk.quaternion.set(state.p[4], state.p[5], state.p[6], state.p[3]); return { rig, trunk, buf: [snapOf(state)], variant: state.v, loco: state.l ?? 0, }; }; const removeGhost = (peerId) => { const g = ghosts.get(peerId); if (!g) return; scene.remove(g.rig.placer); const seen = new Set(); g.rig.root.traverse((o) => { if (o.isMesh && !seen.has(o.material.uuid)) { seen.add(o.material.uuid); o.material.dispose(); } }); ghosts.delete(peerId); }; // Handler signature in this build: (payload, context) where context is // { peerId } - NOT the bare peerId string of classic trystero. act.onMessage = (state, { peerId }) => { let g = ghosts.get(peerId); if (!g) { if (ghosts.size >= MAX_GHOSTS) return; // room stays open, rendering capped // Nostr relays replay recent events, so states from already-dead // sessions can arrive right after joining: only ghost live peers. if (!(peerId in room.getPeers())) return; g = makeGhost(state); // Idle-visitor gate: remember where this peer first appeared and keep // its rig invisible until it strays from there (see hasMoved above). g.spawn = { x: state.p[0], y: state.p[1], yaw: yawOf(state.p) }; g.revealed = false; g.rig.placer.visible = false; ghosts.set(peerId, g); g.lastSeen = performance.now(); return; } g.lastSeen = performance.now(); g.buf.push(snapOf(state)); if (g.buf.length > BUF_MAX) g.buf.shift(); if (!g.revealed && hasMoved(g.spawn, state.p)) { g.revealed = true; // latched for the rest of this peer session g.rig.placer.visible = true; } // Peer switched legs <-> rollers: rebuild its ghost on the other rig // (cheap - cloneRig shares geometry). The snapshot buffer carries over // so the interpolated motion stays continuous across the swap. if ((state.l ?? 0) !== g.loco) { const { lastSeen, spawn, revealed, buf } = g; removeGhost(peerId); g = makeGhost(state); Object.assign(g, { lastSeen, spawn, revealed, buf }); g.rig.placer.visible = revealed; ghosts.set(peerId, g); return; } if (state.v !== g.variant) { g.variant = state.v; applyVariant(g.rig, state.v); ghostify(g.rig); } }; // Same setter-style registration as onMessage. room.onPeerLeave = (peerId) => removeGhost(peerId); // Graceful exit so other tabs drop this ghost immediately... window.addEventListener("pagehide", () => { try { room.leave(); } catch {} }); // ...and a staleness sweep for peers that vanished without leaving // (crashed tab, dropped connection): 5 s without a state packet means // the peer is gone, not just lagging (even background-throttled tabs // still send at ~1 Hz). const STALE_MS = 5000; // Broadcast + stale sweep on setInterval (not rAF) so both keep running // in occluded tabs. setInterval(() => { const s = getLocalState(); if (s) sendState(s); const now = performance.now(); for (const [peerId, g] of [...ghosts]) { if (now - g.lastSeen > STALE_MS) removeGhost(peerId); } }, 1000 / SEND_HZ); // Scratch THREE.Quaternions without importing three: slerp needs real // instances, cloned here from any node of the already-built local rig. const _qa = env.rig.placer.quaternion.clone(); const _qb = env.rig.placer.quaternion.clone(); return { room, peerCount: () => Object.keys(room.getPeers()).length, ghostCount: () => ghosts.size, // Minimap feed: revealed ghosts' arena positions in raw MJCF coords // (trunk.position is set from qpos before the root's Z-up fix). mapDots: () => [...ghosts.values()] .filter((g) => g.revealed) .map((g) => ({ x: g.trunk.position.x, y: g.trunk.position.y })), debug: () => [...ghosts.values()].map((g) => { let meshes = 0, visible = 0, op = null, twins = 0; g.rig.root.traverse((o) => { if (!o.isMesh) return; if (o.userData.ghostPrepass) { twins++; return; } meshes++; if (o.visible) visible++; op ??= o.material.opacity; }); const w = g.trunk.getWorldPosition(g.trunk.position.clone()); return { p: g.trunk.position.toArray(), world: w.toArray(), inScene: !!g.rig.placer.parent, revealed: g.revealed, meshes, visible, twins, op, v: g.variant }; }), update() { const renderT = performance.now() - INTERP_DELAY_MS; for (const g of ghosts.values()) { const buf = g.buf; // Drop snapshots fully in the past; keep [0] and [1] bracketing // renderT (or the two newest, if renderT has caught up). while (buf.length > 2 && buf[1].at <= renderT) buf.shift(); const a = buf[0]; const b = buf.length > 1 ? buf[1] : a; // Clamped: before the segment -> a, past the newest -> hold b. const span = b.at - a.at; const u = span > 0 ? Math.min(1, Math.max(0, (renderT - a.at) / span)) : 1; // Trunk pose in raw MJCF coords: the cloned root applies Z-up -> Y-up. g.trunk.position.set( a.p[0] + (b.p[0] - a.p[0]) * u, a.p[1] + (b.p[1] - a.p[1]) * u, a.p[2] + (b.p[2] - a.p[2]) * u, ); _qa.set(a.p[4], a.p[5], a.p[6], a.p[3]); // THREE order x,y,z,w _qb.set(b.p[4], b.p[5], b.p[6], b.p[3]); g.trunk.quaternion.copy(_qa.slerp(_qb, u)); for (let i = 0; i < jointNames.length; i++) { setJoint(g.rig, jointNames[i], a.j[i] + (b.j[i] - a.j[i]) * u); } setJawOpen(g.rig, a.w + (b.w - a.w) * u); } }, }; }