@genex-ai/multiplayer 0.16.1 → 0.17.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
@@ -8,6 +8,17 @@ Tiny realtime multiplayer for genex games.
8
8
  import { connect } from '@genex-ai/multiplayer';
9
9
  ```
10
10
 
11
+ **Wire protocol.** This SDK speaks Genex relay protocol 3 over Colyseus 0.18. The language-neutral
12
+ contract — room state, messages, limits, close codes, reconnection, interpolation — is
13
+ [`PROTOCOL.md`](./PROTOCOL.md), and its machine-readable constants ship as
14
+ `@genex-ai/multiplayer/protocol` (the shared room schema as `@genex-ai/multiplayer/schema`).
15
+ Another engine's client conforms to that, not to this README.
16
+
17
+ > **0.17.0 is a breaking release: it needs a protocol-3 relay (Colyseus 0.18).** 0.16.x speaks
18
+ > protocol 2 on Colyseus 0.17 and is refused by the new relay, and 0.17.0 cannot join an old one —
19
+ > rebuild and republish a game to move it across. The game-facing API is unchanged except for the
20
+ > additions marked *0.17.0* below.
21
+
11
22
  ---
12
23
 
13
24
  ## Connect
@@ -65,6 +76,13 @@ for (const [id, p] of room.players) {
65
76
 
66
77
  Remote players glide instead of teleporting between network updates — see **[Smoothing remote players](#smoothing-remote-players)**.
67
78
 
79
+ Each player carries `p.uid` (*0.17.0*): their stable id **in this game** — the same across reloads,
80
+ devices and rooms of the game, different in every other game, never the account id. Key anything
81
+ that outlives a session (scores, colours, saves, "is this me") by `uid`, never by `id`, which is a
82
+ session id and changes on every reload. It is opaque; never parse it. `p.team` (*0.17.0*) is the
83
+ team index the relay assigned (`-1` = none): the `open` preset's `teams: N` knob (kept by `uid` for the
84
+ room's life) or the `teams` preset's current round.
85
+
68
86
  Each player also carries `p.avatarUrl` — the VRM avatar that player picked on their genex profile (guests get a per-session pick), verified server-side from their identity token so it can't be spoofed. It's the **fallback** body, not the default one: in a game that generated its own character, every player wears that character, and `p.avatarUrl` renders each player's own look only when the game has none. The genex controller kit's `loadRemotePlayerCharacter({ avatarUrl: p.avatarUrl })` makes that decision for you and shares one parsed base across remotes. The field is `''` when unknown (older relay/API), so always keep a bundled fallback. Don't put an avatar URL in your `me.set` state — this field replaces that.
69
87
 
70
88
  ## `shared` — shared room state
@@ -112,24 +130,22 @@ room.objects.snap('ball', { x: 0, y: 1, z: 0, q: [0, 0, 0, 1] });
112
130
  Keep object state **flat** (top-level numbers + a 4-number quaternion) so it smooths, same as a
113
131
  player. `state` is render-only and smoothed; `stateRaw` is the raw latest for every gameplay read
114
132
  (hit-tests, reach, pickups, physics adoption, discrete values). `objects.ids()` lists them.
115
- `objects.set()` and `objects.snap()` are owner-only: SDK 0.10.2+ ignores an unowned call and warns
133
+ `objects.set()` and `objects.snap()` are owner-only: the SDK ignores an unowned call and warns
116
134
  once per object/operation. Await `claimConfirmed()` and check `accepted` before an irreversible
117
- write; legacy `claim(); set()` remains valid because `claim()` is optimistic locally.
135
+ write; `claim(); set()` remains valid because `claim()` is optimistic locally.
118
136
 
119
137
  The relay enforces a short minimum ownership hold to prevent contact thrash. A current-owner
120
- reassert is a true no-op: it does not extend that hold. Legacy `claim()` remains optimistic for
121
- 0.8.x compatibility; use the confirmed methods when feedback, impulses, seats, scoring, release,
122
- or removal must not lie. Confirmed requests have stable request ids internally and are resent after
123
- a reconnect, so an accepted operation is idempotent. A rejected result reports the current owner and,
124
- for `held`, `retryAfterMs`.
138
+ reassert is a true no-op: it does not extend that hold. `claim()`, `release()` and `remove()` are
139
+ fire-and-forget: they send the same request as the confirmed methods and ignore the answer — a lost
140
+ `claim()` reverts its optimistic ownership. Use the confirmed methods when feedback, impulses, seats,
141
+ scoring, release, or removal must not lie. Every request carries a stable request id and is resent
142
+ after a reconnect, so an accepted operation is idempotent. A rejected result reports the current
143
+ owner and, for `held` / `rate-limited`, `retryAfterMs`.
125
144
 
126
145
  A `timeout` result means the outcome is **unknown**, not "failed": the relay may have applied the
127
146
  operation and only the answer was lost. Shared owner truth (`objects.get(id)?.isMine` /
128
147
  `owner`) converges through the state mirror regardless — treat it, not the timed-out promise, as
129
- authoritative before tearing anything down. On a relay that predates protocol v2, `me.snap` /
130
- `objects.snap` degrade gracefully: the discontinuity write itself is not stored (peers converge on
131
- your next ordinary `set`, without the history reset) — which is why the relay must be upgraded
132
- before games ship an SDK that relies on snaps (see the migration note).
148
+ authoritative before tearing anything down.
133
149
 
134
150
  `claimConfirmed(id)` is an ordinary fair player claim, including when the caller happens to be host.
135
151
  Only host-owned lifecycle work such as initial seeding or a goal reset should request
@@ -153,7 +169,9 @@ room.on('host', (id) => {}); // host changed (migrat
153
169
  ```
154
170
 
155
171
  `isHost`/`host` settle within the first patch after `connect()` — read them in your loop (or react
156
- to `on('host')`), not once at startup.
172
+ to `on('host')`), not once at startup. `isHost` is `false` while the session is reconnecting, until
173
+ the fresh snapshot says who the host is now (*0.17.0*), so a write behind it is never a stale
174
+ ex-host's; `host` is the raw field.
157
175
 
158
176
  ## `on` — events
159
177
 
@@ -166,6 +184,8 @@ room.on('object', (id) => {}); // an object's owner changed — the rend
166
184
  room.on('host', (id) => {}); // the room host changed
167
185
  room.on('hit', (payload) => {}); // a custom event someone sent (see `send`)
168
186
 
187
+ room.on('room:open', ({ open }) => {}); // the host opened/closed the room to newcomers (setRoomOpen)
188
+
169
189
  room.on('reconnecting', ({ attempt }) => {}); // connection dropped — auto-reconnect underway
170
190
  room.on('reconnected', () => {}); // back in the SAME seat: id, objects, host intact
171
191
  room.on('disconnect', (code) => {}); // terminal — connect() fresh to play again
@@ -176,7 +196,8 @@ room.on('disconnect', (code) => {}); // terminal — connect() fresh to
176
196
  ## Reconnection (automatic)
177
197
 
178
198
  A dropped socket (Wi-Fi blip, brief signal loss) is **not** immediately a leave: the relay holds your
179
- seat for a grace window (~30s) and the SDK reconnects with backoff. A short blip keeps the same session
199
+ seat for a grace window (~30s) and the Colyseus client reconnects the same room in place, with backoff
200
+ (~0.25s→4s, nine attempts, the last one ~24s in — inside the grace). A short blip keeps the same session
180
201
  id, owned objects, and host status. Simulation availability is a separate lease: if the disconnected
181
202
  seat is host for longer than the relay's short host-failover grace, an active peer becomes host and
182
203
  host-owned objects move once. A returning old host keeps its seat but does not steal authority back.
@@ -184,15 +205,20 @@ Host-tick callbacks pause while their socket is reconnecting and resume only if
184
205
  the host role. After a long reconnect, remote interpolation histories are rebased to current truth so
185
206
  they cannot draw a whole-map catch-up streak.
186
207
 
208
+ Every drop takes this path, however long the session has been up. `reconnecting` fires once per
209
+ attempt; `reconnected` fires only after the room's state is current again, so `isHost`, owners and the
210
+ roster are safe to read in it. Nothing you send while disconnected is queued: player and object state
211
+ self-heal on the next publish, and lost `send()` events / inputs are counted in a console warning.
212
+
187
213
  Render `reconnecting`/`reconnected` as an overlay and keep your loop running. `disconnect` is terminal
188
- (server restart, revoked session, or the link never recovered): read a fresh token and `connect()`
189
- again. A deliberate `leave()` never reconnects. The relay also broadcasts `server:restart` right before
214
+ (server restart, revoked session `4401`, or the link never recovered `4003`): read a fresh token and
215
+ `connect()` again. A deliberate `leave()` never reconnects. The relay also broadcasts `server:restart` right before
190
216
  a deploy — flush saves when you see it.
191
217
 
192
218
  **One seat per player:** the relay enforces a single session per identity per room — a
193
219
  second tab / device / reloaded page supersedes the previous session (old one gets
194
- `disconnect`, code 4409), so the same player never appears twice and reloads never leave
195
- a ghost. Reload/close sockets ("going away") get only a ~5s seat-hold; genuine network
220
+ `disconnect`, code 4409 — do not reconnect automatically on it: the player is playing in the newer
221
+ tab), so the same player never appears twice and reloads never leave a ghost. Reload/close sockets ("going away") get only a ~5s seat-hold; genuine network
196
222
  drops keep the full reconnection grace.
197
223
 
198
224
  ## `inputs` + `onHostTick` — host-authoritative physics (contested objects)
@@ -223,7 +249,9 @@ and seed its world from each object's `stateRaw`, so simulation survives host mi
223
249
 
224
250
  ## `send` — custom one-off events
225
251
 
226
- Fire-and-forget to all **other** clients (not stored in state). For shots, emotes, pings.
252
+ Fire-and-forget to all **other** clients (not stored in state). For shots, emotes, pings. The relay's
253
+ own names are refused with a warning: `state`, `shared`, `claim`, `release`, `destroy`, `obj`,
254
+ `queue`, `seat`, anything starting `__`, `match:`, `server:` or `rate:`, and names over 128 characters.
227
255
 
228
256
  ```ts
229
257
  room.send('hit', { from: room.id, target: 'xyz', dmg: 10 });
@@ -263,10 +291,20 @@ can't starve it and a chat flood can't starve movement.
263
291
  ## `voice` — mesh voice chat
264
292
 
265
293
  ```ts
294
+ import { getShell } from '@genex-ai/embed-sdk'; // 0.32.0+
295
+
296
+ // Voice is not available inside the Genex app yet — do not draw the button there.
297
+ joinBtn.hidden = getShell() === 'app';
298
+
266
299
  // MUST be inside a click/tap handler — browsers gate the mic prompt AND playback on a gesture.
267
300
  joinBtn.onclick = async () => {
268
- try { await room.voice.join(); } // rejects if the player denies the microphone
269
- catch (err) { showNotice('Microphone needed for voice'); }
301
+ try { await room.voice.join(); }
302
+ catch (err) {
303
+ // The page around the game withheld the microphone and the player was never asked:
304
+ // this build did not declare it (see below). Nothing the player can allow.
305
+ if ((err as Error).name === 'VoiceNotDelegatedError') showNotice('Voice chat is not available here');
306
+ else showNotice('Microphone needed for voice'); // the player said no, or has no microphone
307
+ }
270
308
  };
271
309
 
272
310
  room.on('voice', (participants) => renderVoiceHud(participants)); // joined/left/speaking/connected
@@ -277,6 +315,36 @@ room.voice.mute(playerId); // local only
277
315
  room.voice.leave(); // releases the mic (turns the recording indicator off)
278
316
  ```
279
317
 
318
+ **The microphone is declared per build.** On its Genex page a game runs in a frame, and the
319
+ page gives that frame a microphone only when the build said it needs one. A build that did
320
+ not is refused at once, with no prompt — while the very same build works on the game's own
321
+ link and on a dev server, which is why it is easy to ship without noticing. The declaration
322
+ is made at `genex preview` and `genex publish` (CLI **1.36.6+**), which read the game's own
323
+ source and declare the microphone when they find `.voice.join()` called with nothing or
324
+ with an options object written at the call — on any `.voice`, so `mm.session.voice.join()`
325
+ counts. Both print what the build is given on its Genex page, so a missing declaration
326
+ shows before a player meets it. A call the CLI cannot see (`voice.join(opts)` with the
327
+ options in a variable, `const { voice } = room; voice.join()`, or code outside the game's
328
+ `src/`) is declared by hand, in the game's `package.json`:
329
+
330
+ ```jsonc
331
+ "genex": {
332
+ "capabilities": ["microphone"]
333
+ }
334
+ ```
335
+
336
+ The declaration belongs to the build, so preview or publish again after adding it. The
337
+ spatial pack (`npx genex controller voice`) declares the microphone by itself.
338
+
339
+ The refusal names itself: since **0.16.2**, `join()` rejects with an error whose `name` is
340
+ `VoiceNotDelegatedError` when it was the page, not the player, that said no (the browser's
341
+ own error is its `cause`). Chromium browsers can make that distinction; in the others the
342
+ page's refusal arrives as the browser's own error, the same as a player's.
343
+
344
+ **Not in the Genex app yet.** Inside the Genex mobile app the microphone is refused for
345
+ every game, declared or not, and `join()` rejects as if the player had said no. Hide the
346
+ voice button there, as above.
347
+
280
348
  **Party-sized, not room-sized — max 6.** Audio goes peer-to-peer in a full mesh: everyone
281
349
  uploads to everyone. The relay carries no audio at all, only the roster and the WebRTC
282
350
  handshake. Past a small party what breaks first is not bandwidth but reliability — a joiner
@@ -329,6 +397,44 @@ bounded extrapolation.
329
397
 
330
398
  ---
331
399
 
400
+ ## Experimental options (off by default)
401
+
402
+ These are A/B arms of the netcode bench ([PROTOCOL.md](PROTOCOL.md) §15). Each stays off by default
403
+ until it wins its gate, and one that loses is marked rejected below; leave them alone unless you are
404
+ running that experiment. They are accepted by
405
+ `connect()`, `matchmake()`, `createPrivate()` and `joinPrivate()`.
406
+
407
+ **`pose: true` — typed pose** (PROTOCOL.md §6.6). The reserved keys `x y z yaw vx vy vz anim` of
408
+ `me.set()` / `me.snap()` travel as typed relay fields (float32 deltas instead of the whole JSON state
409
+ re-sent on every tick); everything else is `data`, sent only when it changes. Omit `vx/vy/vz` and they
410
+ are derived from successive positions. Turn it on for **every** client of a game at once. With it on,
411
+ remote players interpolate **only** the pose (`yaw` in radians, along the shortest arc; `anim` switches
412
+ when the rendered pose reaches it) and **snap** everything else — a rotation synced as a quaternion is
413
+ no longer smoothed, so put the heading in `yaw`. A remote posed player's `state` always carries all
414
+ eight pose keys. Needs a relay that accepts `__pose`.
415
+
416
+ ```ts
417
+ const room = await connect<State>({ urls, room: gameId, auth, pose: true });
418
+ room.me.set({ x, y, z, yaw, anim: 'run', hp }); // x..anim → typed pose, hp → data
419
+ ```
420
+
421
+ **`smoothing: 'predict'` — REJECTED, bench-only. Do not ship it.** It lost its A/B: with the clock
422
+ off, remote players under it showed 18–23 teleports and a speed CV of about 1.9–2.0 on broadband,
423
+ mixed-ping skew and a background-tab sender, against 0 teleports and about 0.40 for the default engine,
424
+ and on a burst stall a 753 ms freeze (default 436 ms). The numbers are in the
425
+ [netcode-bench README](../netcode-bench/README.md#predict-rejected). The option stays only so the bench
426
+ can rerun the comparison; games keep the default `'genex'`. (It hands posed players' numeric pose to
427
+ Colyseus `Predict`: receive-time stamps, a fixed delay, no dead reckoning past the newest sample.)
428
+
429
+ **Server clock** (PROTOCOL.md §16). Against a relay that publishes its clock, every session sends a
430
+ small heartbeat at 10 Hz and exposes `room.serverNow()` (ms since the room was created), `room.rtt`
431
+ (smoothed round trip in ms) and `room.clockSynced`. Against any other relay they are `undefined` /
432
+ `false` and nothing is sent. `room.timeUntil(epochMs)` works everywhere: milliseconds until a relay
433
+ deadline, on the server clock once synced and on `Date.now()` before. Remote smoothing never uses the
434
+ server clock.
435
+
436
+ ---
437
+
332
438
  ## Complete minimal example (move a cube, see others as cubes)
333
439
 
334
440
  ```ts
@@ -390,6 +496,9 @@ mm.on("spectate", () => {/* DEPRECATED — never fires: there are no server-side
390
496
  An overflow searcher now waits in the queue instead of watching. */});
391
497
  mm.on("error", (e) => {/* queue join failed even after the SDK's automatic retries — persistent
392
498
  (broken auth / server unreachable); fix the cause, then mm.retry() */});
499
+ mm.on("disconnect", (code) => {/* *0.17.0*: the match seat ended with 4409 (opened in another tab or
500
+ device) or 4401 (signed out). The handle stops and does NOT requeue —
501
+ tell the player; mm.retry() only if they choose to play here again */});
393
502
 
394
503
  // Report ONLY your own outcome — the server adjudicates. Pick the call that matches the win condition:
395
504
  mm.eliminated(); // I'm out (lastStanding)
@@ -402,9 +511,11 @@ mm.cancel(); // stop searching / leave the match
402
511
 
403
512
  A failed queue join is **retried automatically** with backoff (~10s total), so transient failures
404
513
  (a token mid-rotation, identity still settling on page load) never surface. Only a persistent
405
- failure emits `error`; the handle then idles until you call `mm.retry()`.
514
+ failure emits `error`; the handle then idles until you call `mm.retry()`. A relay that refuses this
515
+ SDK's protocol (error code `426`) emits `error` at once — the game needs an SDK update, and retrying
516
+ cannot help (`connect()` rejects with the same code).
406
517
 
407
- **`mm.matchmaking`** (read every frame for your HUD): `status` (`searching` | `waiting` | `countdown` | `playing` | `ended`; the legacy `spectating` is never emitted — server-side spectators were removed), `queue` (`{ position, size }`), `players` (reserved seats), `connectedPlayers` (live transports), `opponents`, `teams`, `myTeam`, `scores`, `winCondition`, `config` (numeric knobs), `roundId`, `winnerId`, `message`. `championId`/`challengerId` are 1v1 aliases. During an automatic reconnect, the SDK retains the last coherent roster and match view until the replacement room hydrates, so status does not transiently regress to `waiting`.
518
+ **`mm.matchmaking`** (read every frame for your HUD): `status` (`searching` | `waiting` | `countdown` | `playing` | `ended`; the legacy `spectating` is never emitted — server-side spectators were removed), `queue` (`{ position, size }`), `players` (reserved seats), `connectedPlayers` (live transports), `opponents`, `teams`, `myTeam`, `scores`, `winCondition`, `config` (numeric knobs), `roundId`, `winnerId`, `message`, and the relay's deadlines: `startsAt` / `endsBy` (epoch ms, or `null`) with `startsInMs` / `endsInMs` — the time left as of this read, measured on the relay's server clock once it is synced, so a countdown is right even when the player's device clock is wrong. Draw countdowns from `startsInMs` / `endsInMs`, never from `startsAt − Date.now()`. `championId`/`challengerId` are 1v1 aliases. During an automatic reconnect the room keeps its decoded state, so status does not transiently regress to `waiting`.
408
519
 
409
520
  Configure once in the game's `package.json` (reported at `genex preview` AND `genex publish`;
410
521
  removing it clears the stored config on the next preview/publish — never sent from the client):
@@ -421,7 +532,7 @@ removing it clears the stored config on the next preview/publish — never sent
421
532
 
422
533
  Presets: **open** (a room of N players, your rules — see below), **duel** (1v1 winner-stays), **arena** (N-player FFA, join-anytime), **teams** (balanced N-v-N), **private** (invite-code lobby). Win conditions (for the batteries-included presets): **lastStanding**, **firstToScore**, **highScoreInTime**, **firstToFinish**; a round that hits the time cap undecided is a draw.
423
534
 
424
- **`open` — bring your own rules.** The minimal preset: the server owns only seating, capacity, and refill; you build teams, rounds, scoring, and win logic yourself on `session.shared` + host election. It runs no match loop, so `status` only goes `waiting`→`playing` (never `ended`), `scores`/`winnerId` stay empty, and **nobody is ever evicted** (no `matchEnded`, no requeue). Config knobs: `minPlayers` (quorum to flip to `playing`; default 1), `maxPlayers` (seat cap, clamped 2–64), `fill` (`1` = keep the room full — grow to max and refill a seat a leaver frees, the default; `0` = lock the room for good once it is simultaneously full (max players at once) — a match with no substitutes; a freed seat stays closed). Pick `open` when you want your own rules; pick duel/arena/teams for a ready-made match loop.
535
+ **`open` — bring your own rules.** The minimal preset: the server owns only seating, capacity, and refill; you build teams, rounds, scoring, and win logic yourself on `session.shared` + host election. It runs no match loop, so `status` only goes `waiting`→`playing` (never `ended`), `scores`/`winnerId` stay empty, and **nobody is ever evicted** (no `matchEnded`, no requeue). Config knobs: `minPlayers` (quorum to flip to `playing`; default 1), `maxPlayers` (seat cap, clamped 2–64), `fill` (`1` = keep the room full — grow to max and refill a seat a leaver frees, the default; `0` = lock the room for good once it is simultaneously full (max players at once) — a match with no substitutes; a freed seat stays closed), and `teams` (*0.17.0*; `2`…`min(8, maxPlayers)`: the relay puts each seated player on the smallest team and reports it as `p.team` and in `matchmaking.teams`, never moves a seated player, and gives a returning player — matched by `uid` — the team they had; any other value means no teams). Pick `open` when you want your own rules; pick duel/arena/teams for a ready-made match loop.
425
536
 
426
537
  ### Private lobbies — `createPrivate` / `joinPrivate`
427
538
 
@@ -429,17 +540,70 @@ For `preset: 'private'` games, skip the queue: a host mints an invite **code**,
429
540
 
430
541
  ```ts
431
542
  import { createPrivate, joinPrivate } from "@genex-ai/multiplayer";
543
+ import { share, getEntry } from "@genex-ai/embed-sdk"; // 0.32.0+
544
+
545
+ const opts = { urls, room: slug, auth: () => getColyseusAuth() };
432
546
 
433
547
  // host
434
- const lobby = await createPrivate<State>({ urls, room: slug, auth: () => getColyseusAuth() });
435
- share(lobby.code); // e.g. show it in the UI
548
+ const lobby = await createPrivate<State>(opts);
549
+ showCode(lobby.code); // show the code as well: the way in when a link will not do
550
+
551
+ // host's Invite button. share() has to start inside the click, before any await.
552
+ inviteBtn.onclick = async () => {
553
+ const { status, url } = await share({ text: "Join my lobby", params: { lobby: lobby.code } });
554
+ if (status === "copied") toast("Invite link copied");
555
+ if (status === "unavailable") showLink(url); // no share sheet and no clipboard: show the link
556
+ };
557
+
558
+ // friend: one way in, for the code from a link and for a code typed by hand
559
+ async function enter(code: string) {
560
+ if (!/^[\w-]{1,32}$/.test(code)) return null; // anyone can write a link: take a code, nothing else
561
+ try {
562
+ return await joinPrivate<State>(code, opts);
563
+ } catch {
564
+ showNotice("That lobby is no longer open"); // closed, or out of this player's reach
565
+ return null;
566
+ }
567
+ }
436
568
 
437
- // friend (elsewhere)
438
- const joined = await joinPrivate<State>(code, { urls, room: slug, auth: () => getColyseusAuth() });
569
+ // at boot, after waitForPlayer(): were they sent an invite link?
570
+ const invited = getEntry().params.get("lobby");
571
+ const joined = invited ? await enter(invited) : null; // null: the menu, with its "Enter code" field
439
572
 
440
573
  // then identical to matchmake(): .session, .matchmaking, eliminated()/score()/finish(), cancel()
441
574
  ```
442
575
 
576
+ `share()` builds the link itself: the game's page on Genex plus `?lobby=<code>` (on a
577
+ creator's own domain, that address). `getEntry()` gives the parameter back to whoever opens
578
+ it, on the game page, on the game's own link and across the sign-in round trip. Do not
579
+ build the link from `location.href`: inside the game page that is the frame's bare address,
580
+ not the page a friend should land on. Any parameter name works except the platform's own
581
+ (`embed`, `ref`, `s`, `from`, `remix`, `slug`, and everything starting `genex_`).
582
+
583
+ `joinPrivate` rejects when the code does not lead to a lobby this player can reach: it has
584
+ closed, or its host is on another region's relay than this player's nearest one. Catch it
585
+ and show the menu. Keep the typed code as the second way in: a shell that does not pass the
586
+ link's parameter on yet opens the game without it.
587
+
588
+ ## Engine bridge — `@genex-ai/multiplayer/bridge`
589
+
590
+ For engines that build to the web and cannot hold JS objects: Rust (`wasm-bindgen`), Godot web
591
+ exports. A flat facade over this SDK: a session is a numeric handle, every value crosses as a JSON
592
+ string, a number or a boolean, and `drainEventsJson(handle)` hands over everything that happened
593
+ since the last call, once per frame. Identity comes from `@genex-ai/embed-sdk` (an optional peer of
594
+ this package; install it to use the bridge), with a fresh token for every join. The contract is
595
+ [`sdks/rust/BRIDGE.md`](../../sdks/rust/BRIDGE.md).
596
+
597
+ ```js
598
+ import { connect, setMe, playersJson, drainEventsJson } from '@genex-ai/multiplayer/bridge';
599
+ const h = await connect(JSON.stringify({ gameId: 'my-game', name: 'ada', urls: [] }));
600
+ setMe(h, JSON.stringify({ x: 1 }));
601
+ for (const e of JSON.parse(drainEventsJson(h))) { /* { type: 'playerJoined', id }, … */ }
602
+ ```
603
+
604
+ Pages with no bundler load `dist/bridge.global.js` (the SDK and embed-sdk bundled in), which sets
605
+ `window.GenexMultiplayerBridge`. A JS game should use the SDK directly, not the bridge.
606
+
443
607
  ## Rules of thumb
444
608
 
445
609
  - **`me` / `shared` are synced; everything else is local.** Movement input → mutate a local object → `me.set` it on the tick.
@@ -0,0 +1,89 @@
1
+ /** The contract version (BRIDGE.md). Bumped only on a breaking change to a signature or a shape. */
2
+ declare const version = 1;
3
+ type Json = Record<string, unknown>;
4
+ /** One queued event: `{ type, ...fields }` (BRIDGE.md, "Events"). */
5
+ type BridgeEvent = {
6
+ type: string;
7
+ } & Json;
8
+ /**
9
+ * Where the bridge gets the player's identity. The default is `@genex-ai/embed-sdk`; a host page
10
+ * or a test replaces it with {@link setIdentity}.
11
+ */
12
+ type BridgeIdentity = {
13
+ /** Resolves once a player exists (guest or signed in); rejects when there never will be one. */
14
+ ready(gameId: string): Promise<unknown> | unknown;
15
+ /** The join credential, read fresh for EVERY join (never cached). `undefined` = tokenless. */
16
+ auth(): {
17
+ embedToken: string;
18
+ } | undefined;
19
+ /** The relay URLs to use when the engine passes none. */
20
+ urls(): string[];
21
+ };
22
+ /**
23
+ * Replace the identity source (`null` restores embed-sdk). For a host page that manages identity
24
+ * itself, and for tests against a token-free local relay. Not part of the engine contract.
25
+ */
26
+ declare function setIdentity(next: BridgeIdentity | null): void;
27
+ /**
28
+ * Start a join and return its handle at once. The outcome arrives as a `joined` (or, for
29
+ * `room: "queue"`, a `seated` then `joined` when a match is found) or a `joinFailed` event.
30
+ * Throws only for malformed options.
31
+ */
32
+ declare function open(optsJson: string): number;
33
+ /**
34
+ * Wait for the player's identity, then join. Resolves with the handle once seated (a queue join
35
+ * resolves once it is searching). Rejects with an `Error` carrying `code` when the join is
36
+ * refused (§3.4; 426 = never retry).
37
+ */
38
+ declare function connect(optsJson: string): Promise<number>;
39
+ /** Leave with consent: `disconnected` (4000, "left") follows, as on native. */
40
+ declare function leave(handle: number): void;
41
+ /**
42
+ * Leave (if still seated) and forget the handle, freeing its queue and views: the engine
43
+ * dropped its session object. Afterwards the handle answers as an unknown one. Idempotent.
44
+ */
45
+ declare function dispose(handle: number): void;
46
+ declare function setMe(handle: number, json: string): boolean;
47
+ declare function snapMe(handle: number, json: string): boolean;
48
+ declare function setShared(handle: number, key: string, json: string): boolean;
49
+ declare function setObject(handle: number, id: string, json: string): boolean;
50
+ declare function snapObject(handle: number, id: string, json: string): boolean;
51
+ /** A game event to every other client. `false` for a reserved type or with no live session. */
52
+ declare function send(handle: number, type: string, json: string): boolean;
53
+ /**
54
+ * Kept for engine API parity, and deliberately a no-op: every game event already arrives as a
55
+ * `message` through the room's `*` handler. Subscribing by name went through `session.on(type)`,
56
+ * which for a name the session emits itself (`join`, `change`, `leave`, `host`, …) attached to
57
+ * that LOCAL event and queued a fake `message` per peer join or state change — while the real
58
+ * game event of the same name still arrived through `*`.
59
+ */
60
+ declare function listen(_handle: number, _type: string): void;
61
+ declare function sendInput(handle: number, json: string): boolean;
62
+ declare function setRoomOpen(handle: number, open: boolean): boolean;
63
+ declare function chatSend(handle: number, text: string): boolean;
64
+ /** Ask for ownership; the answer is a `controlResult` event carrying the CALLER's `requestId`. */
65
+ declare function claim(handle: number, id: string, requestId: string): void;
66
+ /** `claim` with host authority: displaces an active owner's hold (seeding and resets only). */
67
+ declare function claimAsHost(handle: number, id: string, requestId: string): void;
68
+ declare function release(handle: number, id: string, requestId: string): void;
69
+ /** Owner-only removal; its result's `operation` is `"remove"`. */
70
+ declare function destroy(handle: number, id: string, requestId: string): void;
71
+ declare function reportEliminated(handle: number): boolean;
72
+ declare function reportScore(handle: number, delta?: number): boolean;
73
+ declare function reportFinish(handle: number): boolean;
74
+ /** The relay room clock (ms since the room was created), or `null` without one (§16). */
75
+ declare function serverNow(handle: number): number | null;
76
+ /** Milliseconds until a relay epoch-ms deadline: on the server clock once synced (§16.3). */
77
+ declare function timeUntil(handle: number, epochMs: number): number;
78
+ declare function sessionJson(handle: number): string;
79
+ declare function playersJson(handle: number): string;
80
+ declare function objectsJson(handle: number): string;
81
+ declare function sharedJson(handle: number): string;
82
+ /**
83
+ * Every event queued since the last call, oldest first, as a JSON array. Never throws; an unknown
84
+ * or finished handle answers `[]`. A finished handle keeps only its ids and queue; handles are never
85
+ * reused.
86
+ */
87
+ declare function drainEventsJson(handle: number): string;
88
+
89
+ export { type BridgeEvent, type BridgeIdentity, chatSend, claim, claimAsHost, connect, destroy, dispose, drainEventsJson, leave, listen, objectsJson, open, playersJson, release, reportEliminated, reportFinish, reportScore, send, sendInput, serverNow, sessionJson, setIdentity, setMe, setObject, setRoomOpen, setShared, sharedJson, snapMe, snapObject, timeUntil, version };