@genex-ai/multiplayer 0.15.0 → 0.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -283,9 +283,15 @@ handshake. Past a small party what breaks first is not bandwidth but reliability
283
283
  must negotiate with everyone already present, and every leg can fail. The 7th `join()` is
284
284
  refused with `voice:rejected`; the room still holds 64 players.
285
285
 
286
- **Configure TURN for real games.** Roughly 10–20% of players sit behind symmetric NAT and
287
- cannot connect peer-to-peer at all. Without a TURN server they hear nothing and are heard
288
- by nobody, while everyone else works fine — which is a miserable bug to diagnose:
286
+ **TURN is automatic — you do not configure it.** Roughly 15% of players sit behind symmetric
287
+ NAT, carrier CGNAT, or a firewall that blocks UDP, and cannot connect peer-to-peer at all. In
288
+ a mesh that is not a private problem: they fail *every* leg, so nobody in the party hears them
289
+ and they hear nobody, while everyone else works fine. `join()` fetches short-lived relay
290
+ credentials from the Genex relay before it opens any peer connection, so this is handled for
291
+ you with no key to obtain, nothing to put in your bundle, and no code to write.
292
+
293
+ Pass `iceServers` only to run your own TURN — it is an override, and it suppresses the
294
+ platform request entirely:
289
295
 
290
296
  ```ts
291
297
  await room.voice.join({ iceServers: [{ urls: 'turn:…', username: '…', credential: '…' }] });
package/dist/index.d.ts CHANGED
@@ -18,9 +18,10 @@ type VoiceParticipant = {
18
18
  };
19
19
  type VoiceJoinOptions = {
20
20
  /**
21
- * Extra ICE servers — in practice a TURN entry. Roughly 10–20% of players sit behind
22
- * symmetric NAT and cannot go peer-to-peer at all; without TURN, voice simply never
23
- * connects for them. STUN-only is the default because it needs no infrastructure.
21
+ * OVERRIDE the ICE servers. You do not need this: `join()` asks the relay for short-lived
22
+ * TURN credentials automatically, so the ~15% of players behind symmetric NAT, carrier
23
+ * CGNAT or a UDP-blocking firewall connect without any work from the game. Pass this only
24
+ * to run your own TURN — doing so suppresses the relay request entirely.
24
25
  */
25
26
  iceServers?: RTCIceServer[];
26
27
  /** Join with the mic gated off — the right default for push-to-talk games. */
package/dist/index.js CHANGED
@@ -1,6 +1,37 @@
1
1
  // src/index.ts
2
2
  import { Client, getStateCallbacks } from "@colyseus/sdk";
3
3
 
4
+ // src/ice-request.ts
5
+ function createIceRequester(send, timeoutMs) {
6
+ let inFlight = null;
7
+ let settle = null;
8
+ return {
9
+ request() {
10
+ if (inFlight) return inFlight;
11
+ inFlight = new Promise((resolve) => {
12
+ let timer;
13
+ const done = (cfg) => {
14
+ if (!settle) return;
15
+ settle = null;
16
+ inFlight = null;
17
+ if (timer) clearTimeout(timer);
18
+ resolve(cfg);
19
+ };
20
+ settle = done;
21
+ timer = setTimeout(() => done(null), timeoutMs);
22
+ send();
23
+ });
24
+ return inFlight;
25
+ },
26
+ deliver(cfg) {
27
+ settle?.(cfg);
28
+ },
29
+ get pending() {
30
+ return inFlight !== null;
31
+ }
32
+ };
33
+ }
34
+
4
35
  // src/voice.ts
5
36
  var DEFAULT_ICE = [{ urls: "stun:stun.l.google.com:19302" }];
6
37
  var SPEAKING_POLL_MS = 120;
@@ -21,6 +52,13 @@ var VoiceMesh = class {
21
52
  micOn = true;
22
53
  selfSpeaking = false;
23
54
  running = false;
55
+ // Peering is HELD between acquiring the mic and announcing the join, because both things
56
+ // that happen in that window must land first: the relay's TURN credentials (a peer built
57
+ // without them is stuck on STUN for its whole life), and our own `__voice:join` (the relay
58
+ // drops a signal from a session that is not yet in the roster, and a dropped offer is never
59
+ // retried). Without the hold, another player joining voice in that window patches `__voice`
60
+ // and builds us a peer that can neither relay nor negotiate.
61
+ peeringHeld = false;
24
62
  constructor(transport) {
25
63
  this.transport = transport;
26
64
  }
@@ -51,8 +89,9 @@ var VoiceMesh = class {
51
89
  /** Acquire the microphone. Throws if the user denies it or no device exists — the caller
52
90
  * surfaces that; it is a normal outcome, not a bug. Must be called from a user gesture:
53
91
  * browsers gate both the permission prompt and audio playback on one. */
54
- async start(options = {}) {
92
+ async start(options = {}, holdPeering = false) {
55
93
  if (this.running) return;
94
+ this.peeringHeld = holdPeering;
56
95
  this.iceServers = options.iceServers?.length ? options.iceServers : DEFAULT_ICE;
57
96
  this.spatial = options.spatial === true;
58
97
  this.threshold = options.speakingThreshold ?? DEFAULT_SPEAKING_THRESHOLD;
@@ -70,6 +109,7 @@ var VoiceMesh = class {
70
109
  /** Tear everything down. Safe to call when not running. */
71
110
  stop() {
72
111
  this.running = false;
112
+ this.peeringHeld = false;
73
113
  if (this.levelTimer) {
74
114
  clearInterval(this.levelTimer);
75
115
  this.levelTimer = null;
@@ -85,10 +125,41 @@ var VoiceMesh = class {
85
125
  this.selfSpeaking = false;
86
126
  this.emit();
87
127
  }
128
+ /**
129
+ * Swap in ICE servers obtained after `start()` — in practice the relay's short-lived TURN
130
+ * credentials, which are fetched between acquiring the mic and announcing the join so the
131
+ * network round trip never spends the user gesture `getUserMedia` needs.
132
+ *
133
+ * Peers built from here on use them. Live ones are updated too, but `setConfiguration`
134
+ * governs FUTURE gathering only: an allocation already relaying media keeps the credential
135
+ * it was created with. That is why the credential's TTL is sized to outlast a session
136
+ * rather than to reach the next refresh.
137
+ */
138
+ setIceServers(servers) {
139
+ if (!servers.length) return;
140
+ this.iceServers = servers;
141
+ for (const peer of this.peers.values()) {
142
+ try {
143
+ peer.pc.setConfiguration({ iceServers: servers });
144
+ } catch {
145
+ }
146
+ }
147
+ }
148
+ /**
149
+ * Release the hold taken by `start(…, true)` and reconcile whatever roster arrived while it
150
+ * was up. The caller MUST reach this on every path, including the one where no credentials
151
+ * came back: a mesh that never peers is worse than one peering without TURN.
152
+ */
153
+ releasePeering() {
154
+ if (!this.peeringHeld) return;
155
+ this.peeringHeld = false;
156
+ this.setRoster(this.roster);
157
+ }
88
158
  /** The relay's authoritative roster changed: open connections to newcomers, drop leavers. */
89
159
  setRoster(ids) {
90
160
  this.roster = ids;
91
161
  if (!this.running) return;
162
+ if (this.peeringHeld) return;
92
163
  const self = this.transport.selfId();
93
164
  const wanted = new Set(ids.filter((id) => id !== self));
94
165
  for (const id of wanted) if (!this.peers.has(id)) this.createPeer(id);
@@ -187,7 +258,10 @@ var VoiceMesh = class {
187
258
  if (next !== peer.connection) {
188
259
  peer.connection = next;
189
260
  if (next === "failed") {
190
- console.warn(`[multiplayer] voice connection to ${id} failed (no TURN configured?)`);
261
+ const relayed = this.iceServers.some((s2) => hasTurnUrl(s2));
262
+ console.warn(
263
+ `[multiplayer] voice connection to ${id} failed` + (relayed ? "" : " \u2014 no TURN in the ICE list (relay offered none?)")
264
+ );
191
265
  }
192
266
  this.emit();
193
267
  }
@@ -286,6 +360,10 @@ var VoiceMesh = class {
286
360
  }
287
361
  }
288
362
  };
363
+ function hasTurnUrl(server) {
364
+ const urls = Array.isArray(server.urls) ? server.urls : [server.urls];
365
+ return urls.some((u) => typeof u === "string" && u.startsWith("turn"));
366
+ }
289
367
  function rms(analyser) {
290
368
  if (!analyser) return 0;
291
369
  const buf = new Uint8Array(analyser.fftSize);
@@ -329,6 +407,9 @@ var VOICE_JOIN_MSG = "__voice:join";
329
407
  var VOICE_LEAVE_MSG = "__voice:leave";
330
408
  var VOICE_SIGNAL_MSG = "__voice:signal";
331
409
  var VOICE_REJECTED_MSG = "__voice:rejected";
410
+ var VOICE_ICE_REQUEST_MSG = "__voice:ice:request";
411
+ var VOICE_ICE_MSG = "__voice:ice";
412
+ var ICE_REQUEST_TIMEOUT_MS = 3e3;
332
413
  var OBJECT_CONTROL_RESULT_MSG = "__object:result";
333
414
  var OBJECT_CONTROL_VERSION = 2;
334
415
  var STATE_SNAP_MSG = "__state:snap";
@@ -649,7 +730,9 @@ function attachSession(client, initialRoom) {
649
730
  VOICE_SIGNAL_MSG,
650
731
  VOICE_REJECTED_MSG,
651
732
  VOICE_JOIN_MSG,
652
- VOICE_LEAVE_MSG
733
+ VOICE_LEAVE_MSG,
734
+ VOICE_ICE_REQUEST_MSG,
735
+ VOICE_ICE_MSG
653
736
  ]);
654
737
  const emit = (event, ...args) => emitters.get(event)?.forEach((cb) => cb(...args));
655
738
  const lastShared = /* @__PURE__ */ new Map();
@@ -877,6 +960,24 @@ function attachSession(client, initialRoom) {
877
960
  sendSignal: (to, data) => send0(VOICE_SIGNAL_MSG, { to, data }, true)
878
961
  });
879
962
  voice.onChange(() => emit("voice", voice.participants));
963
+ let iceRefreshTimer = null;
964
+ const ice = createIceRequester(() => send0(VOICE_ICE_REQUEST_MSG, {}, true), ICE_REQUEST_TIMEOUT_MS);
965
+ const clearIceRefresh = () => {
966
+ if (iceRefreshTimer) clearTimeout(iceRefreshTimer);
967
+ iceRefreshTimer = null;
968
+ };
969
+ const scheduleIceRefresh = (ttl) => {
970
+ clearIceRefresh();
971
+ if (typeof ttl !== "number" || !Number.isFinite(ttl) || ttl <= 0) return;
972
+ iceRefreshTimer = setTimeout(() => {
973
+ if (!voice.active) return;
974
+ void ice.request().then((cfg) => {
975
+ if (!voice.active || !cfg?.iceServers?.length) return;
976
+ voice.setIceServers(cfg.iceServers);
977
+ scheduleIceRefresh(cfg.ttl);
978
+ });
979
+ }, Math.max(6e4, Math.floor(ttl * 800)));
980
+ };
880
981
  const chatHistory = [];
881
982
  const chatSeen = /* @__PURE__ */ new Set();
882
983
  const chatListeners = /* @__PURE__ */ new Set();
@@ -995,10 +1096,19 @@ function attachSession(client, initialRoom) {
995
1096
  if (msg && typeof msg.from === "string") void voice.handleSignal(msg.from, msg.data);
996
1097
  });
997
1098
  r.onMessage(VOICE_REJECTED_MSG, (msg) => {
1099
+ clearIceRefresh();
998
1100
  voice.stop();
999
1101
  emit("voice:rejected", msg ?? { reason: "full" });
1000
1102
  });
1001
- for (const inert of [VOICE_JOIN_MSG, VOICE_LEAVE_MSG, CHAT_HISTORY_REQUEST_MSG]) {
1103
+ r.onMessage(VOICE_ICE_MSG, (msg) => {
1104
+ ice.deliver(msg ?? null);
1105
+ });
1106
+ for (const inert of [
1107
+ VOICE_JOIN_MSG,
1108
+ VOICE_LEAVE_MSG,
1109
+ VOICE_ICE_REQUEST_MSG,
1110
+ CHAT_HISTORY_REQUEST_MSG
1111
+ ]) {
1002
1112
  r.onMessage(inert, () => {
1003
1113
  });
1004
1114
  }
@@ -1314,10 +1424,20 @@ function attachSession(client, initialRoom) {
1314
1424
  },
1315
1425
  voice: {
1316
1426
  async join(options) {
1317
- await voice.start(options);
1427
+ await voice.start(options, true);
1428
+ if (!options?.iceServers?.length) {
1429
+ const cfg = await ice.request();
1430
+ if (voice.active && cfg?.iceServers?.length) {
1431
+ voice.setIceServers(cfg.iceServers);
1432
+ scheduleIceRefresh(cfg.ttl);
1433
+ }
1434
+ }
1435
+ if (!voice.active) return;
1318
1436
  send0(VOICE_JOIN_MSG, {}, true);
1437
+ voice.releasePeering();
1319
1438
  },
1320
1439
  leave() {
1440
+ clearIceRefresh();
1321
1441
  send0(VOICE_LEAVE_MSG, {}, true);
1322
1442
  voice.stop();
1323
1443
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@genex-ai/multiplayer",
3
- "version": "0.15.0",
3
+ "version": "0.16.1",
4
4
  "description": "The multiplayer client wrapper + shared room schema for genex.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -23,6 +23,7 @@
23
23
  "scripts": {
24
24
  "build": "tsup",
25
25
  "typecheck": "tsc --noEmit",
26
+ "test": "node --test test/*.test.ts",
26
27
  "check": "pnpm build && publint && attw --pack . --profile esm-only",
27
28
  "prepack": "pnpm build && publint"
28
29
  },
@@ -48,9 +49,8 @@
48
49
  "typescript": "^5.8.0"
49
50
  },
50
51
  "repository": {
51
- "type": "git",
52
- "url": "git+https://github.com/me-ai-org/genex-demo.git",
53
- "directory": "packages/multiplayer"
52
+ "type": "git",
53
+ "url": "git+https://github.com/genex-games/genex.git"
54
54
  },
55
55
  "license": "MIT"
56
56
  }