@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/PROTOCOL.md +624 -0
- package/README.md +191 -27
- package/dist/bridge.d.ts +89 -0
- package/dist/bridge.global.js +14 -0
- package/dist/bridge.js +558 -0
- package/dist/chunk-C52XFIB6.js +312 -0
- package/dist/chunk-CTDU4IJ5.js +2191 -0
- package/dist/index.d.ts +110 -18
- package/dist/index.js +7 -1778
- package/dist/protocol.d.ts +419 -0
- package/dist/protocol.js +58 -0
- package/dist/schema.d.ts +122 -68
- package/dist/schema.js +95 -16
- package/package.json +27 -10
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
|
|
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;
|
|
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.
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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(); }
|
|
269
|
-
catch (err) {
|
|
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
|
|
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>(
|
|
435
|
-
|
|
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
|
-
//
|
|
438
|
-
const
|
|
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.
|
package/dist/bridge.d.ts
ADDED
|
@@ -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 };
|