@genex-ai/multiplayer 0.16.2 → 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 });
@@ -369,6 +397,44 @@ bounded extrapolation.
369
397
 
370
398
  ---
371
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
+
372
438
  ## Complete minimal example (move a cube, see others as cubes)
373
439
 
374
440
  ```ts
@@ -430,6 +496,9 @@ mm.on("spectate", () => {/* DEPRECATED — never fires: there are no server-side
430
496
  An overflow searcher now waits in the queue instead of watching. */});
431
497
  mm.on("error", (e) => {/* queue join failed even after the SDK's automatic retries — persistent
432
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 */});
433
502
 
434
503
  // Report ONLY your own outcome — the server adjudicates. Pick the call that matches the win condition:
435
504
  mm.eliminated(); // I'm out (lastStanding)
@@ -442,9 +511,11 @@ mm.cancel(); // stop searching / leave the match
442
511
 
443
512
  A failed queue join is **retried automatically** with backoff (~10s total), so transient failures
444
513
  (a token mid-rotation, identity still settling on page load) never surface. Only a persistent
445
- 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).
446
517
 
447
- **`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`.
448
519
 
449
520
  Configure once in the game's `package.json` (reported at `genex preview` AND `genex publish`;
450
521
  removing it clears the stored config on the next preview/publish — never sent from the client):
@@ -461,7 +532,7 @@ removing it clears the stored config on the next preview/publish — never sent
461
532
 
462
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.
463
534
 
464
- **`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.
465
536
 
466
537
  ### Private lobbies — `createPrivate` / `joinPrivate`
467
538
 
@@ -514,6 +585,25 @@ closed, or its host is on another region's relay than this player's nearest one.
514
585
  and show the menu. Keep the typed code as the second way in: a shell that does not pass the
515
586
  link's parameter on yet opens the game without it.
516
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
+
517
607
  ## Rules of thumb
518
608
 
519
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 };