@genex-ai/multiplayer 0.16.1 → 0.16.2

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.
Files changed (3) hide show
  1. package/README.md +80 -6
  2. package/dist/index.js +37 -6
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -263,10 +263,20 @@ can't starve it and a chat flood can't starve movement.
263
263
  ## `voice` — mesh voice chat
264
264
 
265
265
  ```ts
266
+ import { getShell } from '@genex-ai/embed-sdk'; // 0.32.0+
267
+
268
+ // Voice is not available inside the Genex app yet — do not draw the button there.
269
+ joinBtn.hidden = getShell() === 'app';
270
+
266
271
  // MUST be inside a click/tap handler — browsers gate the mic prompt AND playback on a gesture.
267
272
  joinBtn.onclick = async () => {
268
- try { await room.voice.join(); } // rejects if the player denies the microphone
269
- catch (err) { showNotice('Microphone needed for voice'); }
273
+ try { await room.voice.join(); }
274
+ catch (err) {
275
+ // The page around the game withheld the microphone and the player was never asked:
276
+ // this build did not declare it (see below). Nothing the player can allow.
277
+ if ((err as Error).name === 'VoiceNotDelegatedError') showNotice('Voice chat is not available here');
278
+ else showNotice('Microphone needed for voice'); // the player said no, or has no microphone
279
+ }
270
280
  };
271
281
 
272
282
  room.on('voice', (participants) => renderVoiceHud(participants)); // joined/left/speaking/connected
@@ -277,6 +287,36 @@ room.voice.mute(playerId); // local only
277
287
  room.voice.leave(); // releases the mic (turns the recording indicator off)
278
288
  ```
279
289
 
290
+ **The microphone is declared per build.** On its Genex page a game runs in a frame, and the
291
+ page gives that frame a microphone only when the build said it needs one. A build that did
292
+ not is refused at once, with no prompt — while the very same build works on the game's own
293
+ link and on a dev server, which is why it is easy to ship without noticing. The declaration
294
+ is made at `genex preview` and `genex publish` (CLI **1.36.6+**), which read the game's own
295
+ source and declare the microphone when they find `.voice.join()` called with nothing or
296
+ with an options object written at the call — on any `.voice`, so `mm.session.voice.join()`
297
+ counts. Both print what the build is given on its Genex page, so a missing declaration
298
+ shows before a player meets it. A call the CLI cannot see (`voice.join(opts)` with the
299
+ options in a variable, `const { voice } = room; voice.join()`, or code outside the game's
300
+ `src/`) is declared by hand, in the game's `package.json`:
301
+
302
+ ```jsonc
303
+ "genex": {
304
+ "capabilities": ["microphone"]
305
+ }
306
+ ```
307
+
308
+ The declaration belongs to the build, so preview or publish again after adding it. The
309
+ spatial pack (`npx genex controller voice`) declares the microphone by itself.
310
+
311
+ The refusal names itself: since **0.16.2**, `join()` rejects with an error whose `name` is
312
+ `VoiceNotDelegatedError` when it was the page, not the player, that said no (the browser's
313
+ own error is its `cause`). Chromium browsers can make that distinction; in the others the
314
+ page's refusal arrives as the browser's own error, the same as a player's.
315
+
316
+ **Not in the Genex app yet.** Inside the Genex mobile app the microphone is refused for
317
+ every game, declared or not, and `join()` rejects as if the player had said no. Hide the
318
+ voice button there, as above.
319
+
280
320
  **Party-sized, not room-sized — max 6.** Audio goes peer-to-peer in a full mesh: everyone
281
321
  uploads to everyone. The relay carries no audio at all, only the roster and the WebRTC
282
322
  handshake. Past a small party what breaks first is not bandwidth but reliability — a joiner
@@ -429,17 +469,51 @@ For `preset: 'private'` games, skip the queue: a host mints an invite **code**,
429
469
 
430
470
  ```ts
431
471
  import { createPrivate, joinPrivate } from "@genex-ai/multiplayer";
472
+ import { share, getEntry } from "@genex-ai/embed-sdk"; // 0.32.0+
473
+
474
+ const opts = { urls, room: slug, auth: () => getColyseusAuth() };
432
475
 
433
476
  // host
434
- const lobby = await createPrivate<State>({ urls, room: slug, auth: () => getColyseusAuth() });
435
- share(lobby.code); // e.g. show it in the UI
477
+ const lobby = await createPrivate<State>(opts);
478
+ showCode(lobby.code); // show the code as well: the way in when a link will not do
479
+
480
+ // host's Invite button. share() has to start inside the click, before any await.
481
+ inviteBtn.onclick = async () => {
482
+ const { status, url } = await share({ text: "Join my lobby", params: { lobby: lobby.code } });
483
+ if (status === "copied") toast("Invite link copied");
484
+ if (status === "unavailable") showLink(url); // no share sheet and no clipboard: show the link
485
+ };
436
486
 
437
- // friend (elsewhere)
438
- const joined = await joinPrivate<State>(code, { urls, room: slug, auth: () => getColyseusAuth() });
487
+ // friend: one way in, for the code from a link and for a code typed by hand
488
+ async function enter(code: string) {
489
+ if (!/^[\w-]{1,32}$/.test(code)) return null; // anyone can write a link: take a code, nothing else
490
+ try {
491
+ return await joinPrivate<State>(code, opts);
492
+ } catch {
493
+ showNotice("That lobby is no longer open"); // closed, or out of this player's reach
494
+ return null;
495
+ }
496
+ }
497
+
498
+ // at boot, after waitForPlayer(): were they sent an invite link?
499
+ const invited = getEntry().params.get("lobby");
500
+ const joined = invited ? await enter(invited) : null; // null: the menu, with its "Enter code" field
439
501
 
440
502
  // then identical to matchmake(): .session, .matchmaking, eliminated()/score()/finish(), cancel()
441
503
  ```
442
504
 
505
+ `share()` builds the link itself: the game's page on Genex plus `?lobby=<code>` (on a
506
+ creator's own domain, that address). `getEntry()` gives the parameter back to whoever opens
507
+ it, on the game page, on the game's own link and across the sign-in round trip. Do not
508
+ build the link from `location.href`: inside the game page that is the frame's bare address,
509
+ not the page a friend should land on. Any parameter name works except the platform's own
510
+ (`embed`, `ref`, `s`, `from`, `remix`, `slug`, and everything starting `genex_`).
511
+
512
+ `joinPrivate` rejects when the code does not lead to a lobby this player can reach: it has
513
+ closed, or its host is on another region's relay than this player's nearest one. Catch it
514
+ and show the menu. Keep the typed code as the second way in: a shell that does not pass the
515
+ link's parameter on yet opens the game without it.
516
+
443
517
  ## Rules of thumb
444
518
 
445
519
  - **`me` / `shared` are synced; everything else is local.** Movement input → mutate a local object → `me.set` it on the tick.
package/dist/index.js CHANGED
@@ -33,6 +33,13 @@ function createIceRequester(send, timeoutMs) {
33
33
  }
34
34
 
35
35
  // src/voice.ts
36
+ var NOT_DELEGATED = "The microphone was refused by the page that frames this game, not by the player: the frame was not given it, so the browser never asked. On its Genex page a build is given the microphone only when it declares it. An up-to-date genex CLI declares it at `genex preview` and `genex publish` when the game's own source calls voice.join(); to declare it by hand, add \"microphone\" to genex.capabilities in the game's package.json. Then preview or publish again.";
37
+ var VoiceNotDelegatedError = class extends Error {
38
+ constructor(cause) {
39
+ super(NOT_DELEGATED, { cause });
40
+ this.name = "VoiceNotDelegatedError";
41
+ }
42
+ };
36
43
  var DEFAULT_ICE = [{ urls: "stun:stun.l.google.com:19302" }];
37
44
  var SPEAKING_POLL_MS = 120;
38
45
  var DEFAULT_SPEAKING_THRESHOLD = 0.02;
@@ -87,8 +94,10 @@ var VoiceMesh = class {
87
94
  return () => this.listeners.delete(cb);
88
95
  }
89
96
  /** 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. */
97
+ * surfaces that; it is a normal outcome, not a bug. The one refusal that IS a bug throws a
98
+ * VoiceNotDelegatedError instead: the page framing the game withheld the microphone, because
99
+ * the build never declared it. Must be called from a user gesture: browsers gate both the
100
+ * permission prompt and audio playback on one. */
92
101
  async start(options = {}, holdPeering = false) {
93
102
  if (this.running) return;
94
103
  this.peeringHeld = holdPeering;
@@ -96,10 +105,14 @@ var VoiceMesh = class {
96
105
  this.spatial = options.spatial === true;
97
106
  this.threshold = options.speakingThreshold ?? DEFAULT_SPEAKING_THRESHOLD;
98
107
  this.micOn = options.startMuted !== true;
99
- this.local = await navigator.mediaDevices.getUserMedia({
100
- audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true },
101
- video: false
102
- });
108
+ try {
109
+ this.local = await navigator.mediaDevices.getUserMedia({
110
+ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true },
111
+ video: false
112
+ });
113
+ } catch (err) {
114
+ throw micRefusal(err);
115
+ }
103
116
  for (const t of this.local.getAudioTracks()) t.enabled = this.micOn;
104
117
  this.audio = new AudioContext();
105
118
  this.localAnalyser = this.makeAnalyser(this.local);
@@ -360,6 +373,24 @@ var VoiceMesh = class {
360
373
  }
361
374
  }
362
375
  };
376
+ function isFramed() {
377
+ try {
378
+ return window.self !== window.top;
379
+ } catch {
380
+ return true;
381
+ }
382
+ }
383
+ function micRefusal(err) {
384
+ if (err?.name !== "NotAllowedError") return err;
385
+ try {
386
+ if (!isFramed()) return err;
387
+ const policy = document.featurePolicy;
388
+ if (policy?.allowsFeature?.("microphone") !== false) return err;
389
+ } catch {
390
+ return err;
391
+ }
392
+ return new VoiceNotDelegatedError(err);
393
+ }
363
394
  function hasTurnUrl(server) {
364
395
  const urls = Array.isArray(server.urls) ? server.urls : [server.urls];
365
396
  return urls.some((u) => typeof u === "string" && u.startsWith("turn"));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@genex-ai/multiplayer",
3
- "version": "0.16.1",
3
+ "version": "0.16.2",
4
4
  "description": "The multiplayer client wrapper + shared room schema for genex.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",