@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 +9 -3
- package/dist/index.d.ts +4 -3
- package/dist/index.js +125 -5
- package/package.json +4 -4
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
|
-
**
|
|
287
|
-
cannot connect peer-to-peer at all.
|
|
288
|
-
|
|
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
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
52
|
-
|
|
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
|
}
|