feat: live multiplayer ghosts over WebRTC (Trystero/Nostr)
Browse filesEvery open tab broadcasts its duck pose (trunk + joints + jaw + colour)
at 10 Hz over WebRTC data channels with serverless Nostr signaling, and
renders other visitors as translucent ducks (capped at 3). Peers online
count shows in the telemetry line.
Co-authored-by: Cursor <cursoragent@cursor.com>
README.md
CHANGED
|
@@ -45,6 +45,16 @@ Plug in a controller and the same mapping as the real robot runtime applies:
|
|
| 45 |
- D-pad down: sit / stand toggle
|
| 46 |
- Right trigger: mouth (analog) + quack
|
| 47 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
## How it works
|
| 49 |
|
| 50 |
- `rl.js` fetches the MJCF (`robot_allcollisions.xml`), strips the visual
|
|
|
|
| 45 |
- D-pad down: sit / stand toggle
|
| 46 |
- Right trigger: mouth (analog) + quack
|
| 47 |
|
| 48 |
+
## Multiplayer ghosts
|
| 49 |
+
|
| 50 |
+
Other people visiting the Space at the same time show up as translucent
|
| 51 |
+
ducks, live. Peer-to-peer WebRTC via [Trystero](https://github.com/dmotz/trystero)
|
| 52 |
+
(serverless signaling over public Nostr relays), so it works from a static
|
| 53 |
+
Space with no backend. Each tab broadcasts its duck's pose (trunk + 14
|
| 54 |
+
joints + jaw + colour) at 10 Hz; up to 3 ghosts are rendered, extra peers
|
| 55 |
+
stay connected but invisible. The header shows "N online" when peers are
|
| 56 |
+
around.
|
| 57 |
+
|
| 58 |
## How it works
|
| 59 |
|
| 60 |
- `rl.js` fetches the MJCF (`robot_allcollisions.xml`), strips the visual
|
ghosts.js
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Live multiplayer ghosts: every open tab of the sandbox broadcasts its
|
| 2 |
+
// duck state over WebRTC and renders the other visitors as translucent
|
| 3 |
+
// ducks. There is no backend - this is a static Space - so peer discovery
|
| 4 |
+
// uses Trystero's serverless signaling over public Nostr relays. Payloads
|
| 5 |
+
// are tiny (22 floats + a variant name at 10 Hz), and at most MAX_GHOSTS
|
| 6 |
+
// ghosts are instantiated regardless of how many peers are in the room.
|
| 7 |
+
//
|
| 8 |
+
// This module deliberately imports nothing from duck.js / variants.js:
|
| 9 |
+
// on the private Space those need the ?__sign JWT that only rl.js knows
|
| 10 |
+
// how to append, so all rig helpers arrive through init parameters.
|
| 11 |
+
|
| 12 |
+
const TRYSTERO_URL = "https://esm.run/trystero@0.25.3/nostr";
|
| 13 |
+
const APP_ID = "microduck-sandbox";
|
| 14 |
+
const ROOM = "lobby";
|
| 15 |
+
const MAX_GHOSTS = 3;
|
| 16 |
+
const SEND_HZ = 10;
|
| 17 |
+
const GHOST_OPACITY = 0.35;
|
| 18 |
+
// Per-frame easing toward the last received state (10 Hz updates, ~60 fps
|
| 19 |
+
// rendering): position/joints converge in ~4 frames, fast but smooth.
|
| 20 |
+
const EASE = 0.25;
|
| 21 |
+
|
| 22 |
+
export async function initGhosts(env) {
|
| 23 |
+
const noop = { update() {}, peerCount: () => 0, ghostCount: () => 0, debug: () => [] };
|
| 24 |
+
let room;
|
| 25 |
+
try {
|
| 26 |
+
const { joinRoom } = await import(TRYSTERO_URL);
|
| 27 |
+
room = joinRoom({ appId: APP_ID }, ROOM);
|
| 28 |
+
} catch (e) {
|
| 29 |
+
console.warn("ghosts disabled (signaling unavailable):", e);
|
| 30 |
+
return noop;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
const { scene, cloneRig, setJoint, setJawOpen, applyVariant, jointNames, getLocalState } = env;
|
| 34 |
+
// trystero 0.25 (backed by @trystero-p2p): makeAction returns
|
| 35 |
+
// { send, onMessage, onReceiveProgress } where onMessage is a SETTER -
|
| 36 |
+
// the receive handler is registered by assignment, not by calling it.
|
| 37 |
+
const act = room.makeAction("s");
|
| 38 |
+
// send() rejects when a peer's channel drops mid-transfer; that peer will
|
| 39 |
+
// be swept anyway, so failures are non-events.
|
| 40 |
+
const sendState = (data) => act.send(data).catch(() => {});
|
| 41 |
+
|
| 42 |
+
// Ghosts share geometry with the local rig (cloneRig), but get their own
|
| 43 |
+
// transparent material clones so repainting them never touches the
|
| 44 |
+
// visitor's own duck (variants.js caches materials globally).
|
| 45 |
+
const ghostify = (rig) => {
|
| 46 |
+
const cache = new Map();
|
| 47 |
+
rig.root.traverse((o) => {
|
| 48 |
+
if (!o.isMesh) return;
|
| 49 |
+
let m = cache.get(o.material.uuid);
|
| 50 |
+
if (!m) {
|
| 51 |
+
m = o.material.clone();
|
| 52 |
+
m.transparent = true;
|
| 53 |
+
m.opacity = GHOST_OPACITY;
|
| 54 |
+
m.depthWrite = false;
|
| 55 |
+
cache.set(o.material.uuid, m);
|
| 56 |
+
}
|
| 57 |
+
o.material = m;
|
| 58 |
+
});
|
| 59 |
+
};
|
| 60 |
+
|
| 61 |
+
const ghosts = new Map(); // peerId -> ghost
|
| 62 |
+
const makeGhost = (state) => {
|
| 63 |
+
const rig = cloneRig(env.rig);
|
| 64 |
+
applyVariant(rig, state.v);
|
| 65 |
+
ghostify(rig);
|
| 66 |
+
scene.add(rig.placer);
|
| 67 |
+
const trunk = rig.bodies.get("trunk_base");
|
| 68 |
+
// Snap straight to the first received pose: no fly-in from the origin.
|
| 69 |
+
trunk.position.set(state.p[0], state.p[1], state.p[2]);
|
| 70 |
+
trunk.quaternion.set(state.p[4], state.p[5], state.p[6], state.p[3]);
|
| 71 |
+
return { rig, trunk, target: state, joints: state.j.slice(), jaw: state.w ?? 0, variant: state.v };
|
| 72 |
+
};
|
| 73 |
+
|
| 74 |
+
const removeGhost = (peerId) => {
|
| 75 |
+
const g = ghosts.get(peerId);
|
| 76 |
+
if (!g) return;
|
| 77 |
+
scene.remove(g.rig.placer);
|
| 78 |
+
const seen = new Set();
|
| 79 |
+
g.rig.root.traverse((o) => {
|
| 80 |
+
if (o.isMesh && !seen.has(o.material.uuid)) {
|
| 81 |
+
seen.add(o.material.uuid);
|
| 82 |
+
o.material.dispose();
|
| 83 |
+
}
|
| 84 |
+
});
|
| 85 |
+
ghosts.delete(peerId);
|
| 86 |
+
};
|
| 87 |
+
|
| 88 |
+
// Handler signature in this build: (payload, context) where context is
|
| 89 |
+
// { peerId } - NOT the bare peerId string of classic trystero.
|
| 90 |
+
act.onMessage = (state, { peerId }) => {
|
| 91 |
+
let g = ghosts.get(peerId);
|
| 92 |
+
if (!g) {
|
| 93 |
+
if (ghosts.size >= MAX_GHOSTS) return; // room stays open, rendering capped
|
| 94 |
+
// Nostr relays replay recent events, so states from already-dead
|
| 95 |
+
// sessions can arrive right after joining: only ghost live peers.
|
| 96 |
+
if (!(peerId in room.getPeers())) return;
|
| 97 |
+
g = makeGhost(state);
|
| 98 |
+
ghosts.set(peerId, g);
|
| 99 |
+
g.lastSeen = performance.now();
|
| 100 |
+
return;
|
| 101 |
+
}
|
| 102 |
+
g.lastSeen = performance.now();
|
| 103 |
+
g.target = state;
|
| 104 |
+
if (state.v !== g.variant) {
|
| 105 |
+
g.variant = state.v;
|
| 106 |
+
applyVariant(g.rig, state.v);
|
| 107 |
+
ghostify(g.rig);
|
| 108 |
+
}
|
| 109 |
+
};
|
| 110 |
+
|
| 111 |
+
// Same setter-style registration as onMessage.
|
| 112 |
+
room.onPeerLeave = (peerId) => removeGhost(peerId);
|
| 113 |
+
|
| 114 |
+
// Graceful exit so other tabs drop this ghost immediately...
|
| 115 |
+
window.addEventListener("pagehide", () => {
|
| 116 |
+
try { room.leave(); } catch {}
|
| 117 |
+
});
|
| 118 |
+
// ...and a staleness sweep for peers that vanished without leaving
|
| 119 |
+
// (crashed tab, dropped connection): 5 s without a state packet at
|
| 120 |
+
// 10 Hz means the peer is gone, not just lagging.
|
| 121 |
+
const STALE_MS = 5000;
|
| 122 |
+
|
| 123 |
+
// Broadcast + stale sweep on setInterval (not rAF) so both keep running
|
| 124 |
+
// in occluded tabs.
|
| 125 |
+
setInterval(() => {
|
| 126 |
+
const s = getLocalState();
|
| 127 |
+
if (s) sendState(s);
|
| 128 |
+
const now = performance.now();
|
| 129 |
+
for (const [peerId, g] of [...ghosts]) {
|
| 130 |
+
if (now - g.lastSeen > STALE_MS) removeGhost(peerId);
|
| 131 |
+
}
|
| 132 |
+
}, 1000 / SEND_HZ);
|
| 133 |
+
|
| 134 |
+
// Scratch THREE.Quaternion without importing three: slerp needs a real
|
| 135 |
+
// instance, cloned here from any node of the already-built local rig.
|
| 136 |
+
const _q = env.rig.placer.quaternion.clone();
|
| 137 |
+
return {
|
| 138 |
+
room,
|
| 139 |
+
peerCount: () => Object.keys(room.getPeers()).length,
|
| 140 |
+
ghostCount: () => ghosts.size,
|
| 141 |
+
debug: () => [...ghosts.values()].map((g) => {
|
| 142 |
+
let meshes = 0, visible = 0, op = null;
|
| 143 |
+
g.rig.root.traverse((o) => {
|
| 144 |
+
if (o.isMesh) { meshes++; if (o.visible) visible++; op ??= o.material.opacity; }
|
| 145 |
+
});
|
| 146 |
+
const w = g.trunk.getWorldPosition(g.trunk.position.clone());
|
| 147 |
+
return { p: g.trunk.position.toArray(), world: w.toArray(), inScene: !!g.rig.placer.parent, meshes, visible, op, v: g.variant };
|
| 148 |
+
}),
|
| 149 |
+
update() {
|
| 150 |
+
for (const g of ghosts.values()) {
|
| 151 |
+
const t = g.target;
|
| 152 |
+
// Trunk pose in raw MJCF coords: the cloned root applies Z-up -> Y-up.
|
| 153 |
+
g.trunk.position.x += (t.p[0] - g.trunk.position.x) * EASE;
|
| 154 |
+
g.trunk.position.y += (t.p[1] - g.trunk.position.y) * EASE;
|
| 155 |
+
g.trunk.position.z += (t.p[2] - g.trunk.position.z) * EASE;
|
| 156 |
+
_q.set(t.p[4], t.p[5], t.p[6], t.p[3]); // THREE order x,y,z,w
|
| 157 |
+
g.trunk.quaternion.slerp(_q, EASE);
|
| 158 |
+
for (let i = 0; i < jointNames.length; i++) {
|
| 159 |
+
g.joints[i] += (t.j[i] - g.joints[i]) * EASE;
|
| 160 |
+
setJoint(g.rig, jointNames[i], g.joints[i]);
|
| 161 |
+
}
|
| 162 |
+
g.jaw += ((t.w ?? 0) - g.jaw) * EASE;
|
| 163 |
+
setJawOpen(g.rig, g.jaw);
|
| 164 |
+
}
|
| 165 |
+
},
|
| 166 |
+
};
|
| 167 |
+
}
|
index.html
CHANGED
|
@@ -302,7 +302,7 @@
|
|
| 302 |
appended: a plain src attribute can 401 when the browser blocks the
|
| 303 |
*.static.hf.space auth cookie inside the hub iframe. -->
|
| 304 |
<script type="module">
|
| 305 |
-
const V = "
|
| 306 |
const sign = new URLSearchParams(location.search).get("__sign");
|
| 307 |
const suffix = sign ? `&__sign=${encodeURIComponent(sign)}` : "";
|
| 308 |
import(`./rl.js?v=${V}${suffix}`);
|
|
|
|
| 302 |
appended: a plain src attribute can 401 when the browser blocks the
|
| 303 |
*.static.hf.space auth cookie inside the hub iframe. -->
|
| 304 |
<script type="module">
|
| 305 |
+
const V = "20"; // bump on rl.js changes to defeat heuristic caching
|
| 306 |
const sign = new URLSearchParams(location.search).get("__sign");
|
| 307 |
const suffix = sign ? `&__sign=${encodeURIComponent(sign)}` : "";
|
| 308 |
import(`./rl.js?v=${V}${suffix}`);
|
rl.js
CHANGED
|
@@ -32,7 +32,7 @@ window.__hfSigned = signed; // duck.js uses it for kinematics + STL requests
|
|
| 32 |
|
| 33 |
// Local modules are imported dynamically through signed() for the same
|
| 34 |
// reason: a static import of ./duck.js would 401 without the cookie.
|
| 35 |
-
const { buildRig, loadKinematics, setJoint, setJawOpen, MODEL_DIR, MESH_VERSION } =
|
| 36 |
await import(signed("./duck.js"));
|
| 37 |
const { VARIANTS, VARIANT_NAMES, materialHookFor, randomVariantName, applyVariant, specToHex } =
|
| 38 |
await import(signed("./variants.js"));
|
|
@@ -466,10 +466,13 @@ const quack = () => {
|
|
| 466 |
quackSound.currentTime = 0;
|
| 467 |
quackSound.play().catch(() => {});
|
| 468 |
};
|
| 469 |
-
function
|
| 470 |
const t = (performance.now() - quackAt) / QUACK_MS;
|
| 471 |
const flap = t >= 0 && t < 1 ? Math.sin(Math.PI * t) : 0;
|
| 472 |
-
|
|
|
|
|
|
|
|
|
|
| 473 |
}
|
| 474 |
|
| 475 |
// HUD elements for the mini command sticks + hint highlighting.
|
|
@@ -491,8 +494,10 @@ function renderStats() {
|
|
| 491 |
const [vx, vy, wz] = effectiveCmd();
|
| 492 |
// The active policy lives in the big center label and the twist in the
|
| 493 |
// mini sticks; up here only the bare telemetry remains.
|
|
|
|
| 494 |
statsEl.textContent =
|
| 495 |
-
`ctrl ${ctrlHz.toFixed(0)} Hz \u00b7 sim t ${data.time.toFixed(1)} s`
|
|
|
|
| 496 |
|
| 497 |
// Mini sticks: the dot mirrors the effective twist, lit yellow while the
|
| 498 |
// user is actually driving.
|
|
@@ -520,12 +525,15 @@ function renderStats() {
|
|
| 520 |
// Real implementation assigned in the gamepad section below; the render
|
| 521 |
// loop starts before that section has evaluated, hence the indirection.
|
| 522 |
let pollPad = () => {};
|
|
|
|
|
|
|
| 523 |
|
| 524 |
function loop() {
|
| 525 |
requestAnimationFrame(loop);
|
| 526 |
pollPad();
|
| 527 |
syncRig();
|
| 528 |
syncJaw();
|
|
|
|
| 529 |
controls.update();
|
| 530 |
renderStats();
|
| 531 |
renderer.render(scene, camera);
|
|
@@ -728,4 +736,34 @@ window.rl = {
|
|
| 728 |
velCmd, lastAction, resetSim,
|
| 729 |
step: async (n = 1) => { for (let i = 0; i < n; i++) await controlStep(); },
|
| 730 |
render: () => { syncRig(); renderer.render(scene, camera); },
|
|
|
|
| 731 |
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
|
| 33 |
// Local modules are imported dynamically through signed() for the same
|
| 34 |
// reason: a static import of ./duck.js would 401 without the cookie.
|
| 35 |
+
const { buildRig, cloneRig, loadKinematics, setJoint, setJawOpen, MODEL_DIR, MESH_VERSION } =
|
| 36 |
await import(signed("./duck.js"));
|
| 37 |
const { VARIANTS, VARIANT_NAMES, materialHookFor, randomVariantName, applyVariant, specToHex } =
|
| 38 |
await import(signed("./variants.js"));
|
|
|
|
| 466 |
quackSound.currentTime = 0;
|
| 467 |
quackSound.play().catch(() => {});
|
| 468 |
};
|
| 469 |
+
function jawOpenNow() {
|
| 470 |
const t = (performance.now() - quackAt) / QUACK_MS;
|
| 471 |
const flap = t >= 0 && t < 1 ? Math.sin(Math.PI * t) : 0;
|
| 472 |
+
return Math.max(flap, padJaw);
|
| 473 |
+
}
|
| 474 |
+
function syncJaw() {
|
| 475 |
+
setJawOpen(rig, jawOpenNow());
|
| 476 |
}
|
| 477 |
|
| 478 |
// HUD elements for the mini command sticks + hint highlighting.
|
|
|
|
| 494 |
const [vx, vy, wz] = effectiveCmd();
|
| 495 |
// The active policy lives in the big center label and the twist in the
|
| 496 |
// mini sticks; up here only the bare telemetry remains.
|
| 497 |
+
const peers = ghosts?.peerCount() ?? 0;
|
| 498 |
statsEl.textContent =
|
| 499 |
+
`ctrl ${ctrlHz.toFixed(0)} Hz \u00b7 sim t ${data.time.toFixed(1)} s` +
|
| 500 |
+
(peers ? ` \u00b7 ${peers + 1} online` : "");
|
| 501 |
|
| 502 |
// Mini sticks: the dot mirrors the effective twist, lit yellow while the
|
| 503 |
// user is actually driving.
|
|
|
|
| 525 |
// Real implementation assigned in the gamepad section below; the render
|
| 526 |
// loop starts before that section has evaluated, hence the indirection.
|
| 527 |
let pollPad = () => {};
|
| 528 |
+
// Multiplayer ghosts, initialised asynchronously at the end of the module.
|
| 529 |
+
let ghosts = null;
|
| 530 |
|
| 531 |
function loop() {
|
| 532 |
requestAnimationFrame(loop);
|
| 533 |
pollPad();
|
| 534 |
syncRig();
|
| 535 |
syncJaw();
|
| 536 |
+
ghosts?.update();
|
| 537 |
controls.update();
|
| 538 |
renderStats();
|
| 539 |
renderer.render(scene, camera);
|
|
|
|
| 736 |
velCmd, lastAction, resetSim,
|
| 737 |
step: async (n = 1) => { for (let i = 0; i < n; i++) await controlStep(); },
|
| 738 |
render: () => { syncRig(); renderer.render(scene, camera); },
|
| 739 |
+
get ghosts() { return ghosts; },
|
| 740 |
};
|
| 741 |
+
|
| 742 |
+
// ββ Multiplayer ghosts (WebRTC, serverless signaling) βββββββββββββββββββ
|
| 743 |
+
// Broadcast this duck's pose and render up to 3 other visitors live as
|
| 744 |
+
// translucent ducks. Fire-and-forget: any failure just means no ghosts.
|
| 745 |
+
const r3 = (x) => Math.round(x * 1000) / 1000;
|
| 746 |
+
try {
|
| 747 |
+
// Same heuristic-cache pitfall as everything else served without
|
| 748 |
+
// Cache-Control: reuse rl.js's own ?v= so ghosts.js updates ship together.
|
| 749 |
+
const selfV = new URL(import.meta.url).searchParams.get("v") ?? "0";
|
| 750 |
+
const { initGhosts } = await import(signed(`./ghosts.js?v=${selfV}`));
|
| 751 |
+
ghosts = await initGhosts({
|
| 752 |
+
scene, rig, cloneRig, setJoint, setJawOpen, applyVariant,
|
| 753 |
+
jointNames: JOINT_NAMES,
|
| 754 |
+
getLocalState: () => {
|
| 755 |
+
const qpos = data.qpos;
|
| 756 |
+
const j = new Array(NUM_JOINTS);
|
| 757 |
+
for (let i = 0; i < NUM_JOINTS; i++) j[i] = r3(qpos[qposAdr[i]]);
|
| 758 |
+
return {
|
| 759 |
+
p: [r3(qpos[0]), r3(qpos[1]), r3(qpos[2]), r3(qpos[3]), r3(qpos[4]), r3(qpos[5]), r3(qpos[6])],
|
| 760 |
+
j,
|
| 761 |
+
w: r3(jawOpenNow()),
|
| 762 |
+
v: currentVariant,
|
| 763 |
+
};
|
| 764 |
+
},
|
| 765 |
+
});
|
| 766 |
+
} catch (e) {
|
| 767 |
+
window.__ghostErr = String((e && e.stack) || e);
|
| 768 |
+
console.warn("ghosts disabled:", e);
|
| 769 |
+
}
|