@genex-ai/multiplayer 0.13.0 → 0.16.0

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
@@ -223,12 +223,85 @@ and seed its world from each object's `stateRaw`, so simulation survives host mi
223
223
 
224
224
  ## `send` — custom one-off events
225
225
 
226
- Fire-and-forget to all **other** clients (not stored in state). For shots, emotes, chat, pings.
226
+ Fire-and-forget to all **other** clients (not stored in state). For shots, emotes, pings.
227
227
 
228
228
  ```ts
229
229
  room.send('hit', { from: room.id, target: 'xyz', dmg: 10 });
230
230
  ```
231
231
 
232
+ Text chat has its own channel — use `room.chat`, not this.
233
+
234
+ ## `chat` — room text chat
235
+
236
+ ```ts
237
+ room.chat.send('on my way');
238
+
239
+ room.chat.on((m) => { // fires once per line you hadn't seen
240
+ addLine(`${m.name}: ${m.text}`); // m.text is already sanitized — render as TEXT
241
+ });
242
+
243
+ room.chat.history; // every known line, oldest first — render on mount
244
+ room.chat.mute(playerId); // local only; nothing is sent, nobody else is affected
245
+ ```
246
+
247
+ **Chat is not `send`** — three differences that matter:
248
+
249
+ - **Your own lines come back to you**, in server order with the server's timestamp. Render only
250
+ what arrives in `on`; appending your own line locally on submit shows it twice.
251
+ - **The relay stamps `from` and `name`** from the verified session. A player cannot post under
252
+ someone else's name, so the name is safe to display as-is.
253
+ - **History replays on connect** (last ~50 lines), so a player joining mid-game sees the recent
254
+ conversation. `on` fires for those too, de-duplicated by `id`, and a reconnect backfills exactly
255
+ what the blip missed — a handler that just appends stays correct.
256
+
257
+ The relay trims each line, strips control and bidi characters, and clamps to 500 code points.
258
+ Chat has its own rate budget (~2 lines/second), separate from gameplay traffic, so a busy room
259
+ can't starve it and a chat flood can't starve movement.
260
+
261
+ > **Render `m.text` with `textContent`, never `innerHTML`.** It is another player's input.
262
+
263
+ ## `voice` — mesh voice chat
264
+
265
+ ```ts
266
+ // MUST be inside a click/tap handler — browsers gate the mic prompt AND playback on a gesture.
267
+ joinBtn.onclick = async () => {
268
+ try { await room.voice.join(); } // rejects if the player denies the microphone
269
+ catch (err) { showNotice('Microphone needed for voice'); }
270
+ };
271
+
272
+ room.on('voice', (participants) => renderVoiceHud(participants)); // joined/left/speaking/connected
273
+ room.on('voice:rejected', (info) => showNotice(`Voice is full (${info.limit})`));
274
+
275
+ room.voice.setMicEnabled(false); // push-to-talk gate — instant, no renegotiation
276
+ room.voice.mute(playerId); // local only
277
+ room.voice.leave(); // releases the mic (turns the recording indicator off)
278
+ ```
279
+
280
+ **Party-sized, not room-sized — max 6.** Audio goes peer-to-peer in a full mesh: everyone
281
+ uploads to everyone. The relay carries no audio at all, only the roster and the WebRTC
282
+ handshake. Past a small party what breaks first is not bandwidth but reliability — a joiner
283
+ must negotiate with everyone already present, and every leg can fail. The 7th `join()` is
284
+ refused with `voice:rejected`; the room still holds 64 players.
285
+
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:
295
+
296
+ ```ts
297
+ await room.voice.join({ iceServers: [{ urls: 'turn:…', username: '…', credential: '…' }] });
298
+ ```
299
+
300
+ **Spatial voice** (`npx genex controller voice`) is the version worth shipping in a 3D game:
301
+ join with `{ spatial: true }` and the SDK stops playing audio flat, handing you `p.stream`
302
+ per participant to drive a `THREE.PositionalAudio`. Distance falloff also softens the cap —
303
+ six audible neighbours read as a populated world rather than as a limit.
304
+
232
305
  ## `leave`
233
306
 
234
307
  ```ts
package/dist/index.d.ts CHANGED
@@ -1,6 +1,43 @@
1
1
  export { PlayerState, RoomState } from './schema.js';
2
2
  import '@colyseus/schema';
3
3
 
4
+ /** One remote participant in the voice mesh. */
5
+ type VoiceParticipant = {
6
+ id: string;
7
+ /** Relay-verified display name (same source as `players`). */
8
+ name: string;
9
+ /** Their audio. Null until the peer connection produces a track. */
10
+ stream: MediaStream | null;
11
+ /** True while they are above the speaking threshold. */
12
+ speaking: boolean;
13
+ /** Muted by YOU, locally. Nothing is sent to them or to the relay. */
14
+ muted: boolean;
15
+ /** 0..1, applied to the default playback element (ignored in spatial mode). */
16
+ volume: number;
17
+ connection: 'connecting' | 'connected' | 'failed';
18
+ };
19
+ type VoiceJoinOptions = {
20
+ /**
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.
25
+ */
26
+ iceServers?: RTCIceServer[];
27
+ /** Join with the mic gated off — the right default for push-to-talk games. */
28
+ startMuted?: boolean;
29
+ /**
30
+ * Spatial mode: the SDK does NOT play remote audio, it only hands you `p.stream` so you
31
+ * can drive a THREE.PositionalAudio yourself (`genex controller voice` does this). A
32
+ * muted sink element is still created per peer — without one attached, Chrome does not
33
+ * flow a remote WebRTC stream into WebAudio at all, which reads as "spatial voice is
34
+ * silent" and is the single most confusing bug in this lane.
35
+ */
36
+ spatial?: boolean;
37
+ /** RMS threshold for the speaking flag, 0..1. Default 0.02. */
38
+ speakingThreshold?: number;
39
+ };
40
+
4
41
  type ColyseusAuth = {
5
42
  embedToken: string;
6
43
  };
@@ -181,6 +218,15 @@ type Session<TState extends Record<string, unknown>> = {
181
218
  on(event: 'object', cb: (id: string) => void): () => void;
182
219
  /** The room host changed (fires with the new host's id, or `undefined`). */
183
220
  on(event: 'host', cb: (host: string | undefined) => void): () => void;
221
+ /** The voice roster or a participant's state changed (joined, left, started/stopped
222
+ * speaking, connected, muted). Fires with the full participant list — re-render from it. */
223
+ on(event: 'voice', cb: (participants: VoiceParticipant[]) => void): () => void;
224
+ /** Your `voice.join()` was refused by the relay — the room is at the 6-participant cap.
225
+ * The mic is already released; show the reason rather than retrying in a loop. */
226
+ on(event: 'voice:rejected', cb: (info: {
227
+ reason: string;
228
+ limit?: number;
229
+ }) => void): () => void;
184
230
  /**
185
231
  * The connection dropped unexpectedly and auto-reconnect is underway (attempts run
186
232
  * ~0.5s→8s backoff inside the server's grace window). Show a "reconnecting…" overlay;
@@ -204,6 +250,10 @@ type Session<TState extends Record<string, unknown>> = {
204
250
  * by the relay itself (`state`, `shared`, `claim`, `obj`, `release`, `destroy`, and the
205
251
  * `match:`/`__` prefixes) are refused with a console warning — pick another name. */
206
252
  send(type: string, payload?: unknown): void;
253
+ /** Text chat for this room. See {@link ChatApi}. */
254
+ chat: ChatApi;
255
+ /** Mesh voice chat for this room. See {@link VoiceApi}. */
256
+ voice: VoiceApi;
207
257
  /**
208
258
  * HOST ONLY. Stop (or resume) the matchmaker placing NEW players into THIS room.
209
259
  *
@@ -220,6 +270,82 @@ type Session<TState extends Record<string, unknown>> = {
220
270
  /** Leave the room (deliberate — no auto-reconnect, no `disconnect` event). */
221
271
  leave(): void;
222
272
  };
273
+ /** One chat line, as the relay stamped it. Only `text` came from the sender's client —
274
+ * `from` and `name` are filled in server-side from the verified session, so a player
275
+ * cannot post under someone else's name. */
276
+ type ChatMessage = {
277
+ /** Monotonic per room, ascending. Stable across a reconnect — use it as a render key. */
278
+ id: number;
279
+ /** Sender's player id (`room.id` for your own lines). */
280
+ from: string;
281
+ /** Sender's verified display name. */
282
+ name: string;
283
+ /** Sanitized text: ≤500 code points, no control or bidi characters, single-spaced. */
284
+ text: string;
285
+ /** Server timestamp (epoch ms). */
286
+ at: number;
287
+ };
288
+ /**
289
+ * Room text chat. The relay keeps the last ~50 lines and replays them on connect, so a
290
+ * player joining mid-game sees the recent conversation.
291
+ *
292
+ * IMPORTANT — chat is NOT `send()`: your own lines come back to you, in server order
293
+ * with the server's timestamp. Render only what arrives here; do not also append your
294
+ * own line locally on submit, or it will appear twice.
295
+ */
296
+ type ChatApi = {
297
+ /** Send a line. Empty/whitespace-only text is ignored; the relay trims, strips control
298
+ * and bidi characters, and clamps to 500 code points. Over ~2 lines/second the relay
299
+ * drops the excess (and warns via `rate:drop`). */
300
+ send(text: string): void;
301
+ /**
302
+ * Fires once per line you had not seen — live messages, plus any the replayed history
303
+ * fills in on connect or after a reconnect blip. Append-only and de-duplicated by `id`,
304
+ * so a handler that just appends stays correct across reconnects. Returns a disposer.
305
+ */
306
+ on(cb: (message: ChatMessage) => void): () => void;
307
+ /** Every line known to this client, oldest first, de-duplicated. Safe to render on mount. */
308
+ readonly history: ChatMessage[];
309
+ /** Hide a player's lines locally (client-side only — nothing is sent to the server and
310
+ * other players are unaffected). Applies to messages received from now on. */
311
+ mute(playerId: string): void;
312
+ unmute(playerId: string): void;
313
+ /** Currently muted player ids. */
314
+ readonly muted: string[];
315
+ };
316
+ /**
317
+ * Mesh voice chat — real-time talking between players. Audio travels peer-to-peer; the
318
+ * relay only keeps the roster and couriers the WebRTC handshake.
319
+ *
320
+ * **Party-sized, not room-sized.** The relay caps voice at 6 participants because a mesh
321
+ * makes every participant upload to every other one, and a joiner has to negotiate with
322
+ * everyone already present — reliability, not bandwidth, is what breaks first. A 7th
323
+ * `join()` is refused with `voice:rejected`; the room itself still holds up to 64 players.
324
+ *
325
+ * `join()` must be called from a click or tap: browsers gate both the microphone prompt
326
+ * and audio playback on a user gesture. It rejects if the player denies the mic — that is
327
+ * an ordinary outcome to show in the UI, not an error to swallow.
328
+ */
329
+ type VoiceApi = {
330
+ /** Ask for the mic and enter the room's voice roster. Rejects if permission is denied. */
331
+ join(options?: VoiceJoinOptions): Promise<void>;
332
+ /** Leave voice and release the microphone (this is what turns the recording indicator off). */
333
+ leave(): void;
334
+ readonly active: boolean;
335
+ /** Everyone else in voice, with their stream, speaking flag and connection state. */
336
+ readonly participants: VoiceParticipant[];
337
+ /** Push-to-talk: gate your outgoing audio instantly, without renegotiating. */
338
+ setMicEnabled(on: boolean): void;
339
+ readonly micEnabled: boolean;
340
+ /** True while YOU are above the speaking threshold and un-gated. */
341
+ readonly speaking: boolean;
342
+ /** Silence a player locally. Nothing is sent to them, to the relay, or to anyone else. */
343
+ mute(playerId: string): void;
344
+ unmute(playerId: string): void;
345
+ /** 0..1 playback volume for one player (ignored in `spatial` mode — you own the audio then). */
346
+ setVolume(playerId: string, volume: number): void;
347
+ readonly muted: string[];
348
+ };
223
349
  /**
224
350
  * Connect to a genex multiplayer room. You must supply your player-state type:
225
351
  *
@@ -343,4 +469,4 @@ declare function createPrivate<TState extends Record<string, unknown> = never>(o
343
469
  */
344
470
  declare function joinPrivate<TState extends Record<string, unknown> = never>(code: string, opts: MatchmakeOptions): Promise<Matchmaking<TState>>;
345
471
 
346
- export { type AuthSource, type ColyseusAuth, type ConnectOptions, type MatchStatus, type MatchmakeOptions, type Matchmaking, type MatchmakingView, type ObjectControlOperation, type ObjectControlOptions, type ObjectControlReason, type ObjectControlResult, type ObjectView, type PlayerView, type PrivateMatch, type Session, connect, createPrivate, joinPrivate, matchmake };
472
+ export { type AuthSource, type ChatApi, type ChatMessage, type ColyseusAuth, type ConnectOptions, type MatchStatus, type MatchmakeOptions, type Matchmaking, type MatchmakingView, type ObjectControlOperation, type ObjectControlOptions, type ObjectControlReason, type ObjectControlResult, type ObjectView, type PlayerView, type PrivateMatch, type Session, type VoiceApi, type VoiceJoinOptions, type VoiceParticipant, connect, createPrivate, joinPrivate, matchmake };
package/dist/index.js CHANGED
@@ -1,5 +1,382 @@
1
1
  // src/index.ts
2
2
  import { Client, getStateCallbacks } from "@colyseus/sdk";
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
+
35
+ // src/voice.ts
36
+ var DEFAULT_ICE = [{ urls: "stun:stun.l.google.com:19302" }];
37
+ var SPEAKING_POLL_MS = 120;
38
+ var DEFAULT_SPEAKING_THRESHOLD = 0.02;
39
+ var SPEAKING_RELEASE_RATIO = 0.6;
40
+ var VoiceMesh = class {
41
+ transport;
42
+ peers = /* @__PURE__ */ new Map();
43
+ listeners = /* @__PURE__ */ new Set();
44
+ roster = [];
45
+ local = null;
46
+ audio = null;
47
+ localAnalyser = null;
48
+ levelTimer = null;
49
+ iceServers = DEFAULT_ICE;
50
+ spatial = false;
51
+ threshold = DEFAULT_SPEAKING_THRESHOLD;
52
+ micOn = true;
53
+ selfSpeaking = false;
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;
62
+ constructor(transport) {
63
+ this.transport = transport;
64
+ }
65
+ get active() {
66
+ return this.running;
67
+ }
68
+ get micEnabled() {
69
+ return this.micOn;
70
+ }
71
+ get speaking() {
72
+ return this.selfSpeaking;
73
+ }
74
+ get participants() {
75
+ return [...this.peers.values()].map((p) => ({
76
+ id: p.id,
77
+ name: this.transport.nameOf(p.id),
78
+ stream: p.stream,
79
+ speaking: p.speaking,
80
+ muted: p.muted,
81
+ volume: p.volume,
82
+ connection: p.connection
83
+ }));
84
+ }
85
+ onChange(cb) {
86
+ this.listeners.add(cb);
87
+ return () => this.listeners.delete(cb);
88
+ }
89
+ /** Acquire the microphone. Throws if the user denies it or no device exists — the caller
90
+ * surfaces that; it is a normal outcome, not a bug. Must be called from a user gesture:
91
+ * browsers gate both the permission prompt and audio playback on one. */
92
+ async start(options = {}, holdPeering = false) {
93
+ if (this.running) return;
94
+ this.peeringHeld = holdPeering;
95
+ this.iceServers = options.iceServers?.length ? options.iceServers : DEFAULT_ICE;
96
+ this.spatial = options.spatial === true;
97
+ this.threshold = options.speakingThreshold ?? DEFAULT_SPEAKING_THRESHOLD;
98
+ this.micOn = options.startMuted !== true;
99
+ this.local = await navigator.mediaDevices.getUserMedia({
100
+ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true },
101
+ video: false
102
+ });
103
+ for (const t of this.local.getAudioTracks()) t.enabled = this.micOn;
104
+ this.audio = new AudioContext();
105
+ this.localAnalyser = this.makeAnalyser(this.local);
106
+ this.running = true;
107
+ this.levelTimer = setInterval(() => this.pollLevels(), SPEAKING_POLL_MS);
108
+ }
109
+ /** Tear everything down. Safe to call when not running. */
110
+ stop() {
111
+ this.running = false;
112
+ this.peeringHeld = false;
113
+ if (this.levelTimer) {
114
+ clearInterval(this.levelTimer);
115
+ this.levelTimer = null;
116
+ }
117
+ for (const id of [...this.peers.keys()]) this.destroyPeer(id);
118
+ for (const t of this.local?.getTracks() ?? []) t.stop();
119
+ this.local = null;
120
+ this.localAnalyser = null;
121
+ void this.audio?.close().catch(() => {
122
+ });
123
+ this.audio = null;
124
+ this.roster = [];
125
+ this.selfSpeaking = false;
126
+ this.emit();
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
+ }
158
+ /** The relay's authoritative roster changed: open connections to newcomers, drop leavers. */
159
+ setRoster(ids) {
160
+ this.roster = ids;
161
+ if (!this.running) return;
162
+ if (this.peeringHeld) return;
163
+ const self = this.transport.selfId();
164
+ const wanted = new Set(ids.filter((id) => id !== self));
165
+ for (const id of wanted) if (!this.peers.has(id)) this.createPeer(id);
166
+ for (const id of [...this.peers.keys()]) if (!wanted.has(id)) this.destroyPeer(id);
167
+ this.emit();
168
+ }
169
+ /** Push-to-talk. Gates the track rather than renegotiating — instant, and it keeps the
170
+ * peer connections warm so the first syllable after a key press is not clipped. */
171
+ setMicEnabled(on) {
172
+ this.micOn = on;
173
+ for (const t of this.local?.getAudioTracks() ?? []) t.enabled = on;
174
+ if (!on && this.selfSpeaking) {
175
+ this.selfSpeaking = false;
176
+ this.emit();
177
+ }
178
+ }
179
+ mute(id) {
180
+ this.setMuted(id, true);
181
+ }
182
+ unmute(id) {
183
+ this.setMuted(id, false);
184
+ }
185
+ setVolume(id, volume) {
186
+ const p = this.peers.get(id);
187
+ if (!p) return;
188
+ p.volume = Math.max(0, Math.min(1, volume));
189
+ if (p.el) p.el.volume = p.volume;
190
+ this.emit();
191
+ }
192
+ /** A signal arrived from `from` (the relay stamped it — it cannot be spoofed). */
193
+ async handleSignal(from, raw) {
194
+ if (!this.running) return;
195
+ const signal = raw;
196
+ if (!signal || typeof signal !== "object" || typeof signal.t !== "string") return;
197
+ const peer = this.peers.get(from) ?? (this.roster.includes(from) ? this.createPeer(from) : void 0);
198
+ if (!peer) return;
199
+ try {
200
+ if (signal.t === "offer") {
201
+ await peer.pc.setRemoteDescription(signal.sdp);
202
+ await this.flushIce(peer);
203
+ const answer = await peer.pc.createAnswer();
204
+ await peer.pc.setLocalDescription(answer);
205
+ this.transport.sendSignal(from, { t: "answer", sdp: answer });
206
+ } else if (signal.t === "answer") {
207
+ if (peer.pc.signalingState !== "have-local-offer") return;
208
+ await peer.pc.setRemoteDescription(signal.sdp);
209
+ await this.flushIce(peer);
210
+ } else if (signal.t === "ice") {
211
+ if (peer.pc.remoteDescription) await peer.pc.addIceCandidate(signal.c);
212
+ else peer.pendingIce.push(signal.c);
213
+ }
214
+ } catch (err) {
215
+ console.warn(`[multiplayer] voice signaling failed with ${from}`, err);
216
+ }
217
+ }
218
+ // ---- internals ----
219
+ setMuted(id, muted) {
220
+ const p = this.peers.get(id);
221
+ if (!p || p.muted === muted) return;
222
+ p.muted = muted;
223
+ if (p.el) p.el.muted = muted || this.spatial;
224
+ if (muted && p.speaking) p.speaking = false;
225
+ this.emit();
226
+ }
227
+ createPeer(id) {
228
+ const pc = new RTCPeerConnection({ iceServers: this.iceServers });
229
+ const peer = {
230
+ id,
231
+ pc,
232
+ el: null,
233
+ stream: null,
234
+ analyser: null,
235
+ pendingIce: [],
236
+ speaking: false,
237
+ muted: false,
238
+ volume: 1,
239
+ connection: "connecting"
240
+ };
241
+ this.peers.set(id, peer);
242
+ for (const track of this.local?.getAudioTracks() ?? []) {
243
+ if (this.local) pc.addTrack(track, this.local);
244
+ }
245
+ pc.onicecandidate = (e) => {
246
+ if (e.candidate) this.transport.sendSignal(id, { t: "ice", c: e.candidate.toJSON() });
247
+ };
248
+ pc.ontrack = (e) => {
249
+ const stream = e.streams[0] ?? new MediaStream([e.track]);
250
+ peer.stream = stream;
251
+ peer.el = this.attachSink(stream, peer);
252
+ peer.analyser = this.makeAnalyser(stream);
253
+ this.emit();
254
+ };
255
+ pc.onconnectionstatechange = () => {
256
+ const s = pc.connectionState;
257
+ const next = s === "connected" ? "connected" : s === "failed" || s === "closed" ? "failed" : "connecting";
258
+ if (next !== peer.connection) {
259
+ peer.connection = next;
260
+ if (next === "failed") {
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
+ );
265
+ }
266
+ this.emit();
267
+ }
268
+ };
269
+ if (this.transport.selfId() < id) void this.offer(peer);
270
+ return peer;
271
+ }
272
+ async offer(peer) {
273
+ try {
274
+ const offer = await peer.pc.createOffer();
275
+ await peer.pc.setLocalDescription(offer);
276
+ this.transport.sendSignal(peer.id, { t: "offer", sdp: offer });
277
+ } catch (err) {
278
+ console.warn(`[multiplayer] voice offer to ${peer.id} failed`, err);
279
+ }
280
+ }
281
+ async flushIce(peer) {
282
+ const queued = peer.pendingIce.splice(0);
283
+ for (const c of queued) {
284
+ try {
285
+ await peer.pc.addIceCandidate(c);
286
+ } catch {
287
+ }
288
+ }
289
+ }
290
+ destroyPeer(id) {
291
+ const peer = this.peers.get(id);
292
+ if (!peer) return;
293
+ this.peers.delete(id);
294
+ peer.pc.onicecandidate = null;
295
+ peer.pc.ontrack = null;
296
+ peer.pc.onconnectionstatechange = null;
297
+ try {
298
+ peer.pc.close();
299
+ } catch {
300
+ }
301
+ if (peer.el) {
302
+ peer.el.srcObject = null;
303
+ peer.el.remove();
304
+ }
305
+ }
306
+ /** Attach the stream to an element. Required even in spatial mode (see VoiceJoinOptions). */
307
+ attachSink(stream, peer) {
308
+ const el = document.createElement("audio");
309
+ el.autoplay = true;
310
+ el.srcObject = stream;
311
+ el.muted = this.spatial || peer.muted;
312
+ el.volume = peer.volume;
313
+ el.style.display = "none";
314
+ document.body.appendChild(el);
315
+ void el.play().catch((err) => {
316
+ console.warn("[multiplayer] voice playback was blocked \u2014 join voice from a click/tap", err);
317
+ });
318
+ return el;
319
+ }
320
+ makeAnalyser(stream) {
321
+ if (!this.audio) return null;
322
+ try {
323
+ const src = this.audio.createMediaStreamSource(stream);
324
+ const analyser = this.audio.createAnalyser();
325
+ analyser.fftSize = 512;
326
+ src.connect(analyser);
327
+ return analyser;
328
+ } catch {
329
+ return null;
330
+ }
331
+ }
332
+ pollLevels() {
333
+ let changed = false;
334
+ const localLevel = this.micOn ? rms(this.localAnalyser) : 0;
335
+ const nextSelf = this.crossed(localLevel, this.selfSpeaking);
336
+ if (nextSelf !== this.selfSpeaking) {
337
+ this.selfSpeaking = nextSelf;
338
+ changed = true;
339
+ }
340
+ for (const peer of this.peers.values()) {
341
+ const next = peer.muted ? false : this.crossed(rms(peer.analyser), peer.speaking);
342
+ if (next !== peer.speaking) {
343
+ peer.speaking = next;
344
+ changed = true;
345
+ }
346
+ }
347
+ if (changed) this.emit();
348
+ }
349
+ /** Threshold with hysteresis so a name tag does not strobe between syllables. */
350
+ crossed(level, was) {
351
+ return level >= (was ? this.threshold * SPEAKING_RELEASE_RATIO : this.threshold);
352
+ }
353
+ emit() {
354
+ for (const cb of [...this.listeners]) {
355
+ try {
356
+ cb();
357
+ } catch (err) {
358
+ console.error("[multiplayer] a voice listener threw", err);
359
+ }
360
+ }
361
+ }
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
+ }
367
+ function rms(analyser) {
368
+ if (!analyser) return 0;
369
+ const buf = new Uint8Array(analyser.fftSize);
370
+ analyser.getByteTimeDomainData(buf);
371
+ let sum = 0;
372
+ for (let i = 0; i < buf.length; i++) {
373
+ const v = ((buf[i] ?? 128) - 128) / 128;
374
+ sum += v * v;
375
+ }
376
+ return Math.sqrt(sum / buf.length);
377
+ }
378
+
379
+ // src/index.ts
3
380
  var RELAY_ROOM_TYPE = "play";
4
381
  var QUEUE_ROOM_TYPE = "queue";
5
382
  var MATCH_ROOM_TYPE = "match";
@@ -22,6 +399,17 @@ var MATCH_KEY = "__match";
22
399
  var INPUT_MSG = "__input";
23
400
  var CAPABILITIES_MSG = "__capabilities";
24
401
  var CAPABILITIES_REQUEST_MSG = "__capabilities:request";
402
+ var CHAT_MSG = "__chat";
403
+ var CHAT_HISTORY_MSG = "__chat:history";
404
+ var CHAT_HISTORY_REQUEST_MSG = "__chat:history:request";
405
+ var VOICE_KEY = "__voice";
406
+ var VOICE_JOIN_MSG = "__voice:join";
407
+ var VOICE_LEAVE_MSG = "__voice:leave";
408
+ var VOICE_SIGNAL_MSG = "__voice:signal";
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;
25
413
  var OBJECT_CONTROL_RESULT_MSG = "__object:result";
26
414
  var OBJECT_CONTROL_VERSION = 2;
27
415
  var STATE_SNAP_MSG = "__state:snap";
@@ -320,6 +708,10 @@ function attachSession(client, initialRoom) {
320
708
  "reconnecting",
321
709
  "reconnected",
322
710
  "disconnect",
711
+ // Locally emitted by the voice mesh — never relay message types. Without these here,
712
+ // room.on('voice', …) would register a stray room.onMessage('voice') as well.
713
+ "voice",
714
+ "voice:rejected",
323
715
  INPUT_MSG,
324
716
  // inputs.on() has its own channel — never bridged as a custom event
325
717
  "rate:drop",
@@ -328,7 +720,19 @@ function attachSession(client, initialRoom) {
328
720
  CAPABILITIES_REQUEST_MSG,
329
721
  OBJECT_CONTROL_RESULT_MSG,
330
722
  STATE_SNAP_MSG,
331
- OBJECT_SNAP_MSG
723
+ OBJECT_SNAP_MSG,
724
+ // chat.on() has its own channel. Bridging these would re-register room.onMessage for
725
+ // the same type and clobber the chat listener, silently killing chat for that game.
726
+ CHAT_MSG,
727
+ CHAT_HISTORY_MSG,
728
+ CHAT_HISTORY_REQUEST_MSG,
729
+ // voice.* owns these; bridging them would clobber the mesh's own listeners.
730
+ VOICE_SIGNAL_MSG,
731
+ VOICE_REJECTED_MSG,
732
+ VOICE_JOIN_MSG,
733
+ VOICE_LEAVE_MSG,
734
+ VOICE_ICE_REQUEST_MSG,
735
+ VOICE_ICE_MSG
332
736
  ]);
333
737
  const emit = (event, ...args) => emitters.get(event)?.forEach((cb) => cb(...args));
334
738
  const lastShared = /* @__PURE__ */ new Map();
@@ -344,6 +748,12 @@ function attachSession(client, initialRoom) {
344
748
  }
345
749
  return;
346
750
  }
751
+ if (key === VOICE_KEY) {
752
+ const ids = parseValue(value);
753
+ voice.setRoster(Array.isArray(ids) ? ids.filter((v) => typeof v === "string") : []);
754
+ emit("voice", voice.participants);
755
+ return;
756
+ }
347
757
  if (key.startsWith(OBJ_PREFIX)) {
348
758
  const id = key.slice(OBJ_PREFIX.length);
349
759
  const blob = parseValue(value);
@@ -544,6 +954,55 @@ function attachSession(client, initialRoom) {
544
954
  }
545
955
  if (countIfDropped) droppedWhileOffline++;
546
956
  };
957
+ const voice = new VoiceMesh({
958
+ selfId: () => room.sessionId,
959
+ nameOf: (id) => room.state?.players?.get(id)?.name ?? "",
960
+ sendSignal: (to, data) => send0(VOICE_SIGNAL_MSG, { to, data }, true)
961
+ });
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
+ };
981
+ const chatHistory = [];
982
+ const chatSeen = /* @__PURE__ */ new Set();
983
+ const chatListeners = /* @__PURE__ */ new Set();
984
+ const chatMuted = /* @__PURE__ */ new Set();
985
+ const isChatMessage = (m) => !!m && typeof m === "object" && typeof m.id === "number" && typeof m.from === "string" && typeof m.name === "string" && typeof m.text === "string";
986
+ const acceptChat = (message) => {
987
+ if (!isChatMessage(message)) return;
988
+ if (chatSeen.has(message.id)) return;
989
+ if (chatMuted.has(message.from)) return;
990
+ chatSeen.add(message.id);
991
+ const last = chatHistory[chatHistory.length - 1];
992
+ if (!last || message.id > last.id) chatHistory.push(message);
993
+ else {
994
+ let i = chatHistory.length;
995
+ while (i > 0 && (chatHistory[i - 1]?.id ?? 0) > message.id) i--;
996
+ chatHistory.splice(i, 0, message);
997
+ }
998
+ for (const cb of [...chatListeners]) {
999
+ try {
1000
+ cb(message);
1001
+ } catch (err) {
1002
+ console.error("[multiplayer] a chat listener threw", err);
1003
+ }
1004
+ }
1005
+ };
547
1006
  const wireRoom = (r) => {
548
1007
  let hydrated;
549
1008
  const $ = getStateCallbacks(r);
@@ -633,6 +1092,32 @@ function attachSession(client, initialRoom) {
633
1092
  r.onMessage(CAPABILITIES_REQUEST_MSG, () => {
634
1093
  });
635
1094
  send0(CAPABILITIES_REQUEST_MSG, { objectControl: OBJECT_CONTROL_VERSION });
1095
+ r.onMessage(VOICE_SIGNAL_MSG, (msg) => {
1096
+ if (msg && typeof msg.from === "string") void voice.handleSignal(msg.from, msg.data);
1097
+ });
1098
+ r.onMessage(VOICE_REJECTED_MSG, (msg) => {
1099
+ clearIceRefresh();
1100
+ voice.stop();
1101
+ emit("voice:rejected", msg ?? { reason: "full" });
1102
+ });
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
+ ]) {
1112
+ r.onMessage(inert, () => {
1113
+ });
1114
+ }
1115
+ r.onMessage(CHAT_MSG, (msg) => acceptChat(msg));
1116
+ r.onMessage(CHAT_HISTORY_MSG, (msg) => {
1117
+ if (!Array.isArray(msg?.messages)) return;
1118
+ for (const m of msg.messages) acceptChat(m);
1119
+ });
1120
+ send0(CHAT_HISTORY_REQUEST_MSG, {});
636
1121
  r.onMessage(OBJECT_CONTROL_RESULT_MSG, (msg) => {
637
1122
  if (!msg || typeof msg.requestId !== "string") return;
638
1123
  const pending = pendingControls.get(msg.requestId);
@@ -914,11 +1399,82 @@ function attachSession(client, initialRoom) {
914
1399
  }
915
1400
  send0(type, payload, true);
916
1401
  },
1402
+ chat: {
1403
+ send(text) {
1404
+ if (typeof text !== "string") return;
1405
+ if (!text.trim()) return;
1406
+ send0(CHAT_MSG, { text }, true);
1407
+ },
1408
+ on(cb) {
1409
+ chatListeners.add(cb);
1410
+ return () => chatListeners.delete(cb);
1411
+ },
1412
+ get history() {
1413
+ return [...chatHistory];
1414
+ },
1415
+ mute(playerId) {
1416
+ if (typeof playerId === "string" && playerId) chatMuted.add(playerId);
1417
+ },
1418
+ unmute(playerId) {
1419
+ chatMuted.delete(playerId);
1420
+ },
1421
+ get muted() {
1422
+ return [...chatMuted];
1423
+ }
1424
+ },
1425
+ voice: {
1426
+ async join(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;
1436
+ send0(VOICE_JOIN_MSG, {}, true);
1437
+ voice.releasePeering();
1438
+ },
1439
+ leave() {
1440
+ clearIceRefresh();
1441
+ send0(VOICE_LEAVE_MSG, {}, true);
1442
+ voice.stop();
1443
+ },
1444
+ get active() {
1445
+ return voice.active;
1446
+ },
1447
+ get participants() {
1448
+ return voice.participants;
1449
+ },
1450
+ setMicEnabled(on2) {
1451
+ voice.setMicEnabled(on2 === true);
1452
+ },
1453
+ get micEnabled() {
1454
+ return voice.micEnabled;
1455
+ },
1456
+ get speaking() {
1457
+ return voice.speaking;
1458
+ },
1459
+ mute(playerId) {
1460
+ voice.mute(playerId);
1461
+ },
1462
+ unmute(playerId) {
1463
+ voice.unmute(playerId);
1464
+ },
1465
+ setVolume(playerId, volume) {
1466
+ voice.setVolume(playerId, volume);
1467
+ },
1468
+ get muted() {
1469
+ return voice.participants.filter((p) => p.muted).map((p) => p.id);
1470
+ }
1471
+ },
917
1472
  setRoomOpen(open) {
918
1473
  room.send(ROOM_OPEN_MSG, { open });
919
1474
  },
920
1475
  leave() {
921
1476
  intentionalLeave = true;
1477
+ voice.stop();
922
1478
  connected = false;
923
1479
  syncHostTicks();
924
1480
  settleAllControls("aborted");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@genex-ai/multiplayer",
3
- "version": "0.13.0",
3
+ "version": "0.16.0",
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
  },
@@ -44,7 +45,8 @@
44
45
  "devDependencies": {
45
46
  "@arethetypeswrong/cli": "^0.18.0",
46
47
  "publint": "^0.3.0",
47
- "tsup": "^8.0.0"
48
+ "tsup": "^8.0.0",
49
+ "typescript": "^5.8.0"
48
50
  },
49
51
  "repository": {
50
52
  "type": "git",