@genex-ai/multiplayer 0.13.0 → 0.15.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,79 @@ 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
+ **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:
289
+
290
+ ```ts
291
+ await room.voice.join({ iceServers: [{ urls: 'turn:…', username: '…', credential: '…' }] });
292
+ ```
293
+
294
+ **Spatial voice** (`npx genex controller voice`) is the version worth shipping in a 3D game:
295
+ join with `{ spatial: true }` and the SDK stops playing audio flat, handing you `p.stream`
296
+ per participant to drive a `THREE.PositionalAudio`. Distance falloff also softens the cap —
297
+ six audible neighbours read as a populated world rather than as a limit.
298
+
232
299
  ## `leave`
233
300
 
234
301
  ```ts
package/dist/index.d.ts CHANGED
@@ -1,6 +1,42 @@
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
+ * 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.
24
+ */
25
+ iceServers?: RTCIceServer[];
26
+ /** Join with the mic gated off — the right default for push-to-talk games. */
27
+ startMuted?: boolean;
28
+ /**
29
+ * Spatial mode: the SDK does NOT play remote audio, it only hands you `p.stream` so you
30
+ * can drive a THREE.PositionalAudio yourself (`genex controller voice` does this). A
31
+ * muted sink element is still created per peer — without one attached, Chrome does not
32
+ * flow a remote WebRTC stream into WebAudio at all, which reads as "spatial voice is
33
+ * silent" and is the single most confusing bug in this lane.
34
+ */
35
+ spatial?: boolean;
36
+ /** RMS threshold for the speaking flag, 0..1. Default 0.02. */
37
+ speakingThreshold?: number;
38
+ };
39
+
4
40
  type ColyseusAuth = {
5
41
  embedToken: string;
6
42
  };
@@ -181,6 +217,15 @@ type Session<TState extends Record<string, unknown>> = {
181
217
  on(event: 'object', cb: (id: string) => void): () => void;
182
218
  /** The room host changed (fires with the new host's id, or `undefined`). */
183
219
  on(event: 'host', cb: (host: string | undefined) => void): () => void;
220
+ /** The voice roster or a participant's state changed (joined, left, started/stopped
221
+ * speaking, connected, muted). Fires with the full participant list — re-render from it. */
222
+ on(event: 'voice', cb: (participants: VoiceParticipant[]) => void): () => void;
223
+ /** Your `voice.join()` was refused by the relay — the room is at the 6-participant cap.
224
+ * The mic is already released; show the reason rather than retrying in a loop. */
225
+ on(event: 'voice:rejected', cb: (info: {
226
+ reason: string;
227
+ limit?: number;
228
+ }) => void): () => void;
184
229
  /**
185
230
  * The connection dropped unexpectedly and auto-reconnect is underway (attempts run
186
231
  * ~0.5s→8s backoff inside the server's grace window). Show a "reconnecting…" overlay;
@@ -204,6 +249,10 @@ type Session<TState extends Record<string, unknown>> = {
204
249
  * by the relay itself (`state`, `shared`, `claim`, `obj`, `release`, `destroy`, and the
205
250
  * `match:`/`__` prefixes) are refused with a console warning — pick another name. */
206
251
  send(type: string, payload?: unknown): void;
252
+ /** Text chat for this room. See {@link ChatApi}. */
253
+ chat: ChatApi;
254
+ /** Mesh voice chat for this room. See {@link VoiceApi}. */
255
+ voice: VoiceApi;
207
256
  /**
208
257
  * HOST ONLY. Stop (or resume) the matchmaker placing NEW players into THIS room.
209
258
  *
@@ -220,6 +269,82 @@ type Session<TState extends Record<string, unknown>> = {
220
269
  /** Leave the room (deliberate — no auto-reconnect, no `disconnect` event). */
221
270
  leave(): void;
222
271
  };
272
+ /** One chat line, as the relay stamped it. Only `text` came from the sender's client —
273
+ * `from` and `name` are filled in server-side from the verified session, so a player
274
+ * cannot post under someone else's name. */
275
+ type ChatMessage = {
276
+ /** Monotonic per room, ascending. Stable across a reconnect — use it as a render key. */
277
+ id: number;
278
+ /** Sender's player id (`room.id` for your own lines). */
279
+ from: string;
280
+ /** Sender's verified display name. */
281
+ name: string;
282
+ /** Sanitized text: ≤500 code points, no control or bidi characters, single-spaced. */
283
+ text: string;
284
+ /** Server timestamp (epoch ms). */
285
+ at: number;
286
+ };
287
+ /**
288
+ * Room text chat. The relay keeps the last ~50 lines and replays them on connect, so a
289
+ * player joining mid-game sees the recent conversation.
290
+ *
291
+ * IMPORTANT — chat is NOT `send()`: your own lines come back to you, in server order
292
+ * with the server's timestamp. Render only what arrives here; do not also append your
293
+ * own line locally on submit, or it will appear twice.
294
+ */
295
+ type ChatApi = {
296
+ /** Send a line. Empty/whitespace-only text is ignored; the relay trims, strips control
297
+ * and bidi characters, and clamps to 500 code points. Over ~2 lines/second the relay
298
+ * drops the excess (and warns via `rate:drop`). */
299
+ send(text: string): void;
300
+ /**
301
+ * Fires once per line you had not seen — live messages, plus any the replayed history
302
+ * fills in on connect or after a reconnect blip. Append-only and de-duplicated by `id`,
303
+ * so a handler that just appends stays correct across reconnects. Returns a disposer.
304
+ */
305
+ on(cb: (message: ChatMessage) => void): () => void;
306
+ /** Every line known to this client, oldest first, de-duplicated. Safe to render on mount. */
307
+ readonly history: ChatMessage[];
308
+ /** Hide a player's lines locally (client-side only — nothing is sent to the server and
309
+ * other players are unaffected). Applies to messages received from now on. */
310
+ mute(playerId: string): void;
311
+ unmute(playerId: string): void;
312
+ /** Currently muted player ids. */
313
+ readonly muted: string[];
314
+ };
315
+ /**
316
+ * Mesh voice chat — real-time talking between players. Audio travels peer-to-peer; the
317
+ * relay only keeps the roster and couriers the WebRTC handshake.
318
+ *
319
+ * **Party-sized, not room-sized.** The relay caps voice at 6 participants because a mesh
320
+ * makes every participant upload to every other one, and a joiner has to negotiate with
321
+ * everyone already present — reliability, not bandwidth, is what breaks first. A 7th
322
+ * `join()` is refused with `voice:rejected`; the room itself still holds up to 64 players.
323
+ *
324
+ * `join()` must be called from a click or tap: browsers gate both the microphone prompt
325
+ * and audio playback on a user gesture. It rejects if the player denies the mic — that is
326
+ * an ordinary outcome to show in the UI, not an error to swallow.
327
+ */
328
+ type VoiceApi = {
329
+ /** Ask for the mic and enter the room's voice roster. Rejects if permission is denied. */
330
+ join(options?: VoiceJoinOptions): Promise<void>;
331
+ /** Leave voice and release the microphone (this is what turns the recording indicator off). */
332
+ leave(): void;
333
+ readonly active: boolean;
334
+ /** Everyone else in voice, with their stream, speaking flag and connection state. */
335
+ readonly participants: VoiceParticipant[];
336
+ /** Push-to-talk: gate your outgoing audio instantly, without renegotiating. */
337
+ setMicEnabled(on: boolean): void;
338
+ readonly micEnabled: boolean;
339
+ /** True while YOU are above the speaking threshold and un-gated. */
340
+ readonly speaking: boolean;
341
+ /** Silence a player locally. Nothing is sent to them, to the relay, or to anyone else. */
342
+ mute(playerId: string): void;
343
+ unmute(playerId: string): void;
344
+ /** 0..1 playback volume for one player (ignored in `spatial` mode — you own the audio then). */
345
+ setVolume(playerId: string, volume: number): void;
346
+ readonly muted: string[];
347
+ };
223
348
  /**
224
349
  * Connect to a genex multiplayer room. You must supply your player-state type:
225
350
  *
@@ -343,4 +468,4 @@ declare function createPrivate<TState extends Record<string, unknown> = never>(o
343
468
  */
344
469
  declare function joinPrivate<TState extends Record<string, unknown> = never>(code: string, opts: MatchmakeOptions): Promise<Matchmaking<TState>>;
345
470
 
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 };
471
+ 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,304 @@
1
1
  // src/index.ts
2
2
  import { Client, getStateCallbacks } from "@colyseus/sdk";
3
+
4
+ // src/voice.ts
5
+ var DEFAULT_ICE = [{ urls: "stun:stun.l.google.com:19302" }];
6
+ var SPEAKING_POLL_MS = 120;
7
+ var DEFAULT_SPEAKING_THRESHOLD = 0.02;
8
+ var SPEAKING_RELEASE_RATIO = 0.6;
9
+ var VoiceMesh = class {
10
+ transport;
11
+ peers = /* @__PURE__ */ new Map();
12
+ listeners = /* @__PURE__ */ new Set();
13
+ roster = [];
14
+ local = null;
15
+ audio = null;
16
+ localAnalyser = null;
17
+ levelTimer = null;
18
+ iceServers = DEFAULT_ICE;
19
+ spatial = false;
20
+ threshold = DEFAULT_SPEAKING_THRESHOLD;
21
+ micOn = true;
22
+ selfSpeaking = false;
23
+ running = false;
24
+ constructor(transport) {
25
+ this.transport = transport;
26
+ }
27
+ get active() {
28
+ return this.running;
29
+ }
30
+ get micEnabled() {
31
+ return this.micOn;
32
+ }
33
+ get speaking() {
34
+ return this.selfSpeaking;
35
+ }
36
+ get participants() {
37
+ return [...this.peers.values()].map((p) => ({
38
+ id: p.id,
39
+ name: this.transport.nameOf(p.id),
40
+ stream: p.stream,
41
+ speaking: p.speaking,
42
+ muted: p.muted,
43
+ volume: p.volume,
44
+ connection: p.connection
45
+ }));
46
+ }
47
+ onChange(cb) {
48
+ this.listeners.add(cb);
49
+ return () => this.listeners.delete(cb);
50
+ }
51
+ /** Acquire the microphone. Throws if the user denies it or no device exists — the caller
52
+ * surfaces that; it is a normal outcome, not a bug. Must be called from a user gesture:
53
+ * browsers gate both the permission prompt and audio playback on one. */
54
+ async start(options = {}) {
55
+ if (this.running) return;
56
+ this.iceServers = options.iceServers?.length ? options.iceServers : DEFAULT_ICE;
57
+ this.spatial = options.spatial === true;
58
+ this.threshold = options.speakingThreshold ?? DEFAULT_SPEAKING_THRESHOLD;
59
+ this.micOn = options.startMuted !== true;
60
+ this.local = await navigator.mediaDevices.getUserMedia({
61
+ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true },
62
+ video: false
63
+ });
64
+ for (const t of this.local.getAudioTracks()) t.enabled = this.micOn;
65
+ this.audio = new AudioContext();
66
+ this.localAnalyser = this.makeAnalyser(this.local);
67
+ this.running = true;
68
+ this.levelTimer = setInterval(() => this.pollLevels(), SPEAKING_POLL_MS);
69
+ }
70
+ /** Tear everything down. Safe to call when not running. */
71
+ stop() {
72
+ this.running = false;
73
+ if (this.levelTimer) {
74
+ clearInterval(this.levelTimer);
75
+ this.levelTimer = null;
76
+ }
77
+ for (const id of [...this.peers.keys()]) this.destroyPeer(id);
78
+ for (const t of this.local?.getTracks() ?? []) t.stop();
79
+ this.local = null;
80
+ this.localAnalyser = null;
81
+ void this.audio?.close().catch(() => {
82
+ });
83
+ this.audio = null;
84
+ this.roster = [];
85
+ this.selfSpeaking = false;
86
+ this.emit();
87
+ }
88
+ /** The relay's authoritative roster changed: open connections to newcomers, drop leavers. */
89
+ setRoster(ids) {
90
+ this.roster = ids;
91
+ if (!this.running) return;
92
+ const self = this.transport.selfId();
93
+ const wanted = new Set(ids.filter((id) => id !== self));
94
+ for (const id of wanted) if (!this.peers.has(id)) this.createPeer(id);
95
+ for (const id of [...this.peers.keys()]) if (!wanted.has(id)) this.destroyPeer(id);
96
+ this.emit();
97
+ }
98
+ /** Push-to-talk. Gates the track rather than renegotiating — instant, and it keeps the
99
+ * peer connections warm so the first syllable after a key press is not clipped. */
100
+ setMicEnabled(on) {
101
+ this.micOn = on;
102
+ for (const t of this.local?.getAudioTracks() ?? []) t.enabled = on;
103
+ if (!on && this.selfSpeaking) {
104
+ this.selfSpeaking = false;
105
+ this.emit();
106
+ }
107
+ }
108
+ mute(id) {
109
+ this.setMuted(id, true);
110
+ }
111
+ unmute(id) {
112
+ this.setMuted(id, false);
113
+ }
114
+ setVolume(id, volume) {
115
+ const p = this.peers.get(id);
116
+ if (!p) return;
117
+ p.volume = Math.max(0, Math.min(1, volume));
118
+ if (p.el) p.el.volume = p.volume;
119
+ this.emit();
120
+ }
121
+ /** A signal arrived from `from` (the relay stamped it — it cannot be spoofed). */
122
+ async handleSignal(from, raw) {
123
+ if (!this.running) return;
124
+ const signal = raw;
125
+ if (!signal || typeof signal !== "object" || typeof signal.t !== "string") return;
126
+ const peer = this.peers.get(from) ?? (this.roster.includes(from) ? this.createPeer(from) : void 0);
127
+ if (!peer) return;
128
+ try {
129
+ if (signal.t === "offer") {
130
+ await peer.pc.setRemoteDescription(signal.sdp);
131
+ await this.flushIce(peer);
132
+ const answer = await peer.pc.createAnswer();
133
+ await peer.pc.setLocalDescription(answer);
134
+ this.transport.sendSignal(from, { t: "answer", sdp: answer });
135
+ } else if (signal.t === "answer") {
136
+ if (peer.pc.signalingState !== "have-local-offer") return;
137
+ await peer.pc.setRemoteDescription(signal.sdp);
138
+ await this.flushIce(peer);
139
+ } else if (signal.t === "ice") {
140
+ if (peer.pc.remoteDescription) await peer.pc.addIceCandidate(signal.c);
141
+ else peer.pendingIce.push(signal.c);
142
+ }
143
+ } catch (err) {
144
+ console.warn(`[multiplayer] voice signaling failed with ${from}`, err);
145
+ }
146
+ }
147
+ // ---- internals ----
148
+ setMuted(id, muted) {
149
+ const p = this.peers.get(id);
150
+ if (!p || p.muted === muted) return;
151
+ p.muted = muted;
152
+ if (p.el) p.el.muted = muted || this.spatial;
153
+ if (muted && p.speaking) p.speaking = false;
154
+ this.emit();
155
+ }
156
+ createPeer(id) {
157
+ const pc = new RTCPeerConnection({ iceServers: this.iceServers });
158
+ const peer = {
159
+ id,
160
+ pc,
161
+ el: null,
162
+ stream: null,
163
+ analyser: null,
164
+ pendingIce: [],
165
+ speaking: false,
166
+ muted: false,
167
+ volume: 1,
168
+ connection: "connecting"
169
+ };
170
+ this.peers.set(id, peer);
171
+ for (const track of this.local?.getAudioTracks() ?? []) {
172
+ if (this.local) pc.addTrack(track, this.local);
173
+ }
174
+ pc.onicecandidate = (e) => {
175
+ if (e.candidate) this.transport.sendSignal(id, { t: "ice", c: e.candidate.toJSON() });
176
+ };
177
+ pc.ontrack = (e) => {
178
+ const stream = e.streams[0] ?? new MediaStream([e.track]);
179
+ peer.stream = stream;
180
+ peer.el = this.attachSink(stream, peer);
181
+ peer.analyser = this.makeAnalyser(stream);
182
+ this.emit();
183
+ };
184
+ pc.onconnectionstatechange = () => {
185
+ const s = pc.connectionState;
186
+ const next = s === "connected" ? "connected" : s === "failed" || s === "closed" ? "failed" : "connecting";
187
+ if (next !== peer.connection) {
188
+ peer.connection = next;
189
+ if (next === "failed") {
190
+ console.warn(`[multiplayer] voice connection to ${id} failed (no TURN configured?)`);
191
+ }
192
+ this.emit();
193
+ }
194
+ };
195
+ if (this.transport.selfId() < id) void this.offer(peer);
196
+ return peer;
197
+ }
198
+ async offer(peer) {
199
+ try {
200
+ const offer = await peer.pc.createOffer();
201
+ await peer.pc.setLocalDescription(offer);
202
+ this.transport.sendSignal(peer.id, { t: "offer", sdp: offer });
203
+ } catch (err) {
204
+ console.warn(`[multiplayer] voice offer to ${peer.id} failed`, err);
205
+ }
206
+ }
207
+ async flushIce(peer) {
208
+ const queued = peer.pendingIce.splice(0);
209
+ for (const c of queued) {
210
+ try {
211
+ await peer.pc.addIceCandidate(c);
212
+ } catch {
213
+ }
214
+ }
215
+ }
216
+ destroyPeer(id) {
217
+ const peer = this.peers.get(id);
218
+ if (!peer) return;
219
+ this.peers.delete(id);
220
+ peer.pc.onicecandidate = null;
221
+ peer.pc.ontrack = null;
222
+ peer.pc.onconnectionstatechange = null;
223
+ try {
224
+ peer.pc.close();
225
+ } catch {
226
+ }
227
+ if (peer.el) {
228
+ peer.el.srcObject = null;
229
+ peer.el.remove();
230
+ }
231
+ }
232
+ /** Attach the stream to an element. Required even in spatial mode (see VoiceJoinOptions). */
233
+ attachSink(stream, peer) {
234
+ const el = document.createElement("audio");
235
+ el.autoplay = true;
236
+ el.srcObject = stream;
237
+ el.muted = this.spatial || peer.muted;
238
+ el.volume = peer.volume;
239
+ el.style.display = "none";
240
+ document.body.appendChild(el);
241
+ void el.play().catch((err) => {
242
+ console.warn("[multiplayer] voice playback was blocked \u2014 join voice from a click/tap", err);
243
+ });
244
+ return el;
245
+ }
246
+ makeAnalyser(stream) {
247
+ if (!this.audio) return null;
248
+ try {
249
+ const src = this.audio.createMediaStreamSource(stream);
250
+ const analyser = this.audio.createAnalyser();
251
+ analyser.fftSize = 512;
252
+ src.connect(analyser);
253
+ return analyser;
254
+ } catch {
255
+ return null;
256
+ }
257
+ }
258
+ pollLevels() {
259
+ let changed = false;
260
+ const localLevel = this.micOn ? rms(this.localAnalyser) : 0;
261
+ const nextSelf = this.crossed(localLevel, this.selfSpeaking);
262
+ if (nextSelf !== this.selfSpeaking) {
263
+ this.selfSpeaking = nextSelf;
264
+ changed = true;
265
+ }
266
+ for (const peer of this.peers.values()) {
267
+ const next = peer.muted ? false : this.crossed(rms(peer.analyser), peer.speaking);
268
+ if (next !== peer.speaking) {
269
+ peer.speaking = next;
270
+ changed = true;
271
+ }
272
+ }
273
+ if (changed) this.emit();
274
+ }
275
+ /** Threshold with hysteresis so a name tag does not strobe between syllables. */
276
+ crossed(level, was) {
277
+ return level >= (was ? this.threshold * SPEAKING_RELEASE_RATIO : this.threshold);
278
+ }
279
+ emit() {
280
+ for (const cb of [...this.listeners]) {
281
+ try {
282
+ cb();
283
+ } catch (err) {
284
+ console.error("[multiplayer] a voice listener threw", err);
285
+ }
286
+ }
287
+ }
288
+ };
289
+ function rms(analyser) {
290
+ if (!analyser) return 0;
291
+ const buf = new Uint8Array(analyser.fftSize);
292
+ analyser.getByteTimeDomainData(buf);
293
+ let sum = 0;
294
+ for (let i = 0; i < buf.length; i++) {
295
+ const v = ((buf[i] ?? 128) - 128) / 128;
296
+ sum += v * v;
297
+ }
298
+ return Math.sqrt(sum / buf.length);
299
+ }
300
+
301
+ // src/index.ts
3
302
  var RELAY_ROOM_TYPE = "play";
4
303
  var QUEUE_ROOM_TYPE = "queue";
5
304
  var MATCH_ROOM_TYPE = "match";
@@ -22,6 +321,14 @@ var MATCH_KEY = "__match";
22
321
  var INPUT_MSG = "__input";
23
322
  var CAPABILITIES_MSG = "__capabilities";
24
323
  var CAPABILITIES_REQUEST_MSG = "__capabilities:request";
324
+ var CHAT_MSG = "__chat";
325
+ var CHAT_HISTORY_MSG = "__chat:history";
326
+ var CHAT_HISTORY_REQUEST_MSG = "__chat:history:request";
327
+ var VOICE_KEY = "__voice";
328
+ var VOICE_JOIN_MSG = "__voice:join";
329
+ var VOICE_LEAVE_MSG = "__voice:leave";
330
+ var VOICE_SIGNAL_MSG = "__voice:signal";
331
+ var VOICE_REJECTED_MSG = "__voice:rejected";
25
332
  var OBJECT_CONTROL_RESULT_MSG = "__object:result";
26
333
  var OBJECT_CONTROL_VERSION = 2;
27
334
  var STATE_SNAP_MSG = "__state:snap";
@@ -320,6 +627,10 @@ function attachSession(client, initialRoom) {
320
627
  "reconnecting",
321
628
  "reconnected",
322
629
  "disconnect",
630
+ // Locally emitted by the voice mesh — never relay message types. Without these here,
631
+ // room.on('voice', …) would register a stray room.onMessage('voice') as well.
632
+ "voice",
633
+ "voice:rejected",
323
634
  INPUT_MSG,
324
635
  // inputs.on() has its own channel — never bridged as a custom event
325
636
  "rate:drop",
@@ -328,7 +639,17 @@ function attachSession(client, initialRoom) {
328
639
  CAPABILITIES_REQUEST_MSG,
329
640
  OBJECT_CONTROL_RESULT_MSG,
330
641
  STATE_SNAP_MSG,
331
- OBJECT_SNAP_MSG
642
+ OBJECT_SNAP_MSG,
643
+ // chat.on() has its own channel. Bridging these would re-register room.onMessage for
644
+ // the same type and clobber the chat listener, silently killing chat for that game.
645
+ CHAT_MSG,
646
+ CHAT_HISTORY_MSG,
647
+ CHAT_HISTORY_REQUEST_MSG,
648
+ // voice.* owns these; bridging them would clobber the mesh's own listeners.
649
+ VOICE_SIGNAL_MSG,
650
+ VOICE_REJECTED_MSG,
651
+ VOICE_JOIN_MSG,
652
+ VOICE_LEAVE_MSG
332
653
  ]);
333
654
  const emit = (event, ...args) => emitters.get(event)?.forEach((cb) => cb(...args));
334
655
  const lastShared = /* @__PURE__ */ new Map();
@@ -344,6 +665,12 @@ function attachSession(client, initialRoom) {
344
665
  }
345
666
  return;
346
667
  }
668
+ if (key === VOICE_KEY) {
669
+ const ids = parseValue(value);
670
+ voice.setRoster(Array.isArray(ids) ? ids.filter((v) => typeof v === "string") : []);
671
+ emit("voice", voice.participants);
672
+ return;
673
+ }
347
674
  if (key.startsWith(OBJ_PREFIX)) {
348
675
  const id = key.slice(OBJ_PREFIX.length);
349
676
  const blob = parseValue(value);
@@ -544,6 +871,37 @@ function attachSession(client, initialRoom) {
544
871
  }
545
872
  if (countIfDropped) droppedWhileOffline++;
546
873
  };
874
+ const voice = new VoiceMesh({
875
+ selfId: () => room.sessionId,
876
+ nameOf: (id) => room.state?.players?.get(id)?.name ?? "",
877
+ sendSignal: (to, data) => send0(VOICE_SIGNAL_MSG, { to, data }, true)
878
+ });
879
+ voice.onChange(() => emit("voice", voice.participants));
880
+ const chatHistory = [];
881
+ const chatSeen = /* @__PURE__ */ new Set();
882
+ const chatListeners = /* @__PURE__ */ new Set();
883
+ const chatMuted = /* @__PURE__ */ new Set();
884
+ const isChatMessage = (m) => !!m && typeof m === "object" && typeof m.id === "number" && typeof m.from === "string" && typeof m.name === "string" && typeof m.text === "string";
885
+ const acceptChat = (message) => {
886
+ if (!isChatMessage(message)) return;
887
+ if (chatSeen.has(message.id)) return;
888
+ if (chatMuted.has(message.from)) return;
889
+ chatSeen.add(message.id);
890
+ const last = chatHistory[chatHistory.length - 1];
891
+ if (!last || message.id > last.id) chatHistory.push(message);
892
+ else {
893
+ let i = chatHistory.length;
894
+ while (i > 0 && (chatHistory[i - 1]?.id ?? 0) > message.id) i--;
895
+ chatHistory.splice(i, 0, message);
896
+ }
897
+ for (const cb of [...chatListeners]) {
898
+ try {
899
+ cb(message);
900
+ } catch (err) {
901
+ console.error("[multiplayer] a chat listener threw", err);
902
+ }
903
+ }
904
+ };
547
905
  const wireRoom = (r) => {
548
906
  let hydrated;
549
907
  const $ = getStateCallbacks(r);
@@ -633,6 +991,23 @@ function attachSession(client, initialRoom) {
633
991
  r.onMessage(CAPABILITIES_REQUEST_MSG, () => {
634
992
  });
635
993
  send0(CAPABILITIES_REQUEST_MSG, { objectControl: OBJECT_CONTROL_VERSION });
994
+ r.onMessage(VOICE_SIGNAL_MSG, (msg) => {
995
+ if (msg && typeof msg.from === "string") void voice.handleSignal(msg.from, msg.data);
996
+ });
997
+ r.onMessage(VOICE_REJECTED_MSG, (msg) => {
998
+ voice.stop();
999
+ emit("voice:rejected", msg ?? { reason: "full" });
1000
+ });
1001
+ for (const inert of [VOICE_JOIN_MSG, VOICE_LEAVE_MSG, CHAT_HISTORY_REQUEST_MSG]) {
1002
+ r.onMessage(inert, () => {
1003
+ });
1004
+ }
1005
+ r.onMessage(CHAT_MSG, (msg) => acceptChat(msg));
1006
+ r.onMessage(CHAT_HISTORY_MSG, (msg) => {
1007
+ if (!Array.isArray(msg?.messages)) return;
1008
+ for (const m of msg.messages) acceptChat(m);
1009
+ });
1010
+ send0(CHAT_HISTORY_REQUEST_MSG, {});
636
1011
  r.onMessage(OBJECT_CONTROL_RESULT_MSG, (msg) => {
637
1012
  if (!msg || typeof msg.requestId !== "string") return;
638
1013
  const pending = pendingControls.get(msg.requestId);
@@ -914,11 +1289,72 @@ function attachSession(client, initialRoom) {
914
1289
  }
915
1290
  send0(type, payload, true);
916
1291
  },
1292
+ chat: {
1293
+ send(text) {
1294
+ if (typeof text !== "string") return;
1295
+ if (!text.trim()) return;
1296
+ send0(CHAT_MSG, { text }, true);
1297
+ },
1298
+ on(cb) {
1299
+ chatListeners.add(cb);
1300
+ return () => chatListeners.delete(cb);
1301
+ },
1302
+ get history() {
1303
+ return [...chatHistory];
1304
+ },
1305
+ mute(playerId) {
1306
+ if (typeof playerId === "string" && playerId) chatMuted.add(playerId);
1307
+ },
1308
+ unmute(playerId) {
1309
+ chatMuted.delete(playerId);
1310
+ },
1311
+ get muted() {
1312
+ return [...chatMuted];
1313
+ }
1314
+ },
1315
+ voice: {
1316
+ async join(options) {
1317
+ await voice.start(options);
1318
+ send0(VOICE_JOIN_MSG, {}, true);
1319
+ },
1320
+ leave() {
1321
+ send0(VOICE_LEAVE_MSG, {}, true);
1322
+ voice.stop();
1323
+ },
1324
+ get active() {
1325
+ return voice.active;
1326
+ },
1327
+ get participants() {
1328
+ return voice.participants;
1329
+ },
1330
+ setMicEnabled(on2) {
1331
+ voice.setMicEnabled(on2 === true);
1332
+ },
1333
+ get micEnabled() {
1334
+ return voice.micEnabled;
1335
+ },
1336
+ get speaking() {
1337
+ return voice.speaking;
1338
+ },
1339
+ mute(playerId) {
1340
+ voice.mute(playerId);
1341
+ },
1342
+ unmute(playerId) {
1343
+ voice.unmute(playerId);
1344
+ },
1345
+ setVolume(playerId, volume) {
1346
+ voice.setVolume(playerId, volume);
1347
+ },
1348
+ get muted() {
1349
+ return voice.participants.filter((p) => p.muted).map((p) => p.id);
1350
+ }
1351
+ },
917
1352
  setRoomOpen(open) {
918
1353
  room.send(ROOM_OPEN_MSG, { open });
919
1354
  },
920
1355
  leave() {
921
1356
  intentionalLeave = true;
1357
+ voice.stop();
922
1358
  connected = false;
923
1359
  syncHostTicks();
924
1360
  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.15.0",
4
4
  "description": "The multiplayer client wrapper + shared room schema for genex.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -44,7 +44,8 @@
44
44
  "devDependencies": {
45
45
  "@arethetypeswrong/cli": "^0.18.0",
46
46
  "publint": "^0.3.0",
47
- "tsup": "^8.0.0"
47
+ "tsup": "^8.0.0",
48
+ "typescript": "^5.8.0"
48
49
  },
49
50
  "repository": {
50
51
  "type": "git",