tfrere HF Staff Cursor commited on
Commit
bb90cc7
Β·
1 Parent(s): 9f9cc1c

feat: live multiplayer ghosts over WebRTC (Trystero/Nostr)

Browse files

Every 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>

Files changed (4) hide show
  1. README.md +10 -0
  2. ghosts.js +167 -0
  3. index.html +1 -1
  4. rl.js +42 -4
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 = "5"; // 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}`);
 
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 syncJaw() {
470
  const t = (performance.now() - quackAt) / QUACK_MS;
471
  const flap = t >= 0 && t < 1 ? Math.sin(Math.PI * t) : 0;
472
- setJawOpen(rig, Math.max(flap, padJaw));
 
 
 
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
+ }