@genex-ai/multiplayer 0.16.2 → 0.17.1

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.
@@ -0,0 +1,312 @@
1
+ // src/protocol.ts
2
+ var PROTOCOL_VERSION = 3;
3
+ var MIN_PROTOCOL_VERSION = 3;
4
+ var RoomType = {
5
+ /** The plain relay room: joinOrCreate, fullest-first placement. */
6
+ PLAY: "play",
7
+ /** The matchmaking search pool. No game session — it only hands out seat reservations. */
8
+ QUEUE: "queue",
9
+ /** A capped match arena: seat reservation (matchmade), create (private lobby) or joinById (invite). */
10
+ MATCH: "match"
11
+ };
12
+ var JoinOption = {
13
+ /** The game's slug. Must equal the embed token's `slug` claim. */
14
+ GAME_ID: "gameId",
15
+ /** A fresh `embed:play` JWT. Required — there is no anonymous join. */
16
+ EMBED_TOKEN: "embedToken",
17
+ /** Display-name fallback when the token carries none. */
18
+ NAME: "name",
19
+ /** {@link PROTOCOL_VERSION}. Required. */
20
+ PROTOCOL: "protocol"
21
+ };
22
+ var SERVER_OWNED_JOIN_OPTIONS = ["clients", "preset", "winCondition", "config", "matchmade"];
23
+ var JoinError = {
24
+ /** A server-owned option was sent. */
25
+ BAD_OPTION: 400,
26
+ /** No token, an invalid/expired token, or a revoked session. */
27
+ AUTH_REQUIRED: 401,
28
+ /** Token for another game ('wrong game'), or the room's guest cap is full. */
29
+ FORBIDDEN: 403,
30
+ /** `protocol` missing or outside [MIN_PROTOCOL_VERSION, PROTOCOL_VERSION]. Update the client. */
31
+ PROTOCOL_UNSUPPORTED: 426
32
+ };
33
+ var STAGING_ROOM_PREFIX = "preview--";
34
+ var roomKeyFor = (slug, channel) => channel === "staging" ? `${STAGING_ROOM_PREFIX}${slug}` : slug;
35
+ var slugOfRoomKey = (key) => key.startsWith(STAGING_ROOM_PREFIX) ? key.slice(STAGING_ROOM_PREFIX.length) : key;
36
+ var ClientMessage = {
37
+ STATE: "state",
38
+ STATE_SNAP: "__state:snap",
39
+ SHARED: "shared",
40
+ CLAIM: "claim",
41
+ RELEASE: "release",
42
+ DESTROY: "destroy",
43
+ OBJECT: "obj",
44
+ OBJECT_SNAP: "__object:snap",
45
+ INPUT: "__input",
46
+ ROOM_OPEN: "__room:open",
47
+ CHAT: "__chat",
48
+ CHAT_HISTORY_REQUEST: "__chat:history:request",
49
+ VOICE_ICE_REQUEST: "__voice:ice:request",
50
+ VOICE_JOIN: "__voice:join",
51
+ VOICE_LEAVE: "__voice:leave",
52
+ VOICE_SIGNAL: "__voice:signal",
53
+ // match rooms only — self-reports, accepted only from an active seat
54
+ MATCH_ELIMINATED: "match:eliminated",
55
+ MATCH_SCORE: "match:score",
56
+ MATCH_FINISH: "match:finish",
57
+ // optional typed pose (§6.6) — appended in protocol 3
58
+ POSE: "__pose",
59
+ POSE_SNAP: "__pose:snap"
60
+ };
61
+ var ServerMessage = {
62
+ OBJECT_RESULT: "__object:result",
63
+ INPUT: "__input",
64
+ ROOM_OPEN: "__room:open",
65
+ CHAT: "__chat",
66
+ CHAT_HISTORY: "__chat:history",
67
+ VOICE_ICE: "__voice:ice",
68
+ VOICE_SIGNAL: "__voice:signal",
69
+ VOICE_REJECTED: "__voice:rejected",
70
+ RATE_DROP: "rate:drop",
71
+ SERVER_RESTART: "server:restart",
72
+ // match rooms only
73
+ MATCH_START: "match:start",
74
+ MATCH_ENDED: "match:ended",
75
+ MATCH_REQUEUE: "match:requeue",
76
+ // queue room only
77
+ QUEUE: "queue",
78
+ SEAT: "seat"
79
+ };
80
+ var RESERVED_EVENT_PREFIXES = ["__", "match:", "server:", "rate:"];
81
+ var RESERVED_EVENT_TYPES = ["state", "shared", "claim", "release", "destroy", "obj", "queue", "seat"];
82
+ function isReservedEventType(type) {
83
+ return RESERVED_EVENT_TYPES.includes(type) || RESERVED_EVENT_PREFIXES.some((prefix) => type.startsWith(prefix));
84
+ }
85
+ var RESERVED_SHARED_PREFIX = "__";
86
+ var SEND_TIME_KEY = "__t";
87
+ var RATE_LANES = {
88
+ state: { rate: 30, burst: 60, softZone: 0 },
89
+ control: { rate: 40, burst: 80, softZone: 0 },
90
+ input: { rate: 60, burst: 120, softZone: 0 },
91
+ bulk: { rate: 120, burst: 240, softZone: 60 },
92
+ chat: { rate: 2, burst: 5, softZone: 0 },
93
+ voice: { rate: 25, burst: 60, softZone: 0 },
94
+ /** `__pose` / `__pose:snap` (§6.6). Its own reserved lane, outside the global bucket like
95
+ * `state`: a posed player publishes pose AND (low-rate) `data`, and neither may starve the other. */
96
+ pose: { rate: 30, burst: 60, softZone: 0 }
97
+ };
98
+ var GLOBAL_RATE = { rate: 180, burst: 360 };
99
+ var MAX_MESSAGES_PER_SECOND = 1200;
100
+ var MESSAGE_LANE = {
101
+ [ClientMessage.STATE]: "state",
102
+ [ClientMessage.STATE_SNAP]: "state",
103
+ [ClientMessage.SHARED]: "bulk",
104
+ [ClientMessage.CLAIM]: "control",
105
+ [ClientMessage.RELEASE]: "control",
106
+ [ClientMessage.DESTROY]: "control",
107
+ [ClientMessage.OBJECT]: "bulk",
108
+ [ClientMessage.OBJECT_SNAP]: "bulk",
109
+ [ClientMessage.INPUT]: "input",
110
+ [ClientMessage.ROOM_OPEN]: "control",
111
+ [ClientMessage.CHAT]: "chat",
112
+ [ClientMessage.CHAT_HISTORY_REQUEST]: "control",
113
+ [ClientMessage.VOICE_ICE_REQUEST]: "voice",
114
+ [ClientMessage.VOICE_JOIN]: "voice",
115
+ [ClientMessage.VOICE_LEAVE]: "voice",
116
+ [ClientMessage.VOICE_SIGNAL]: "voice",
117
+ [ClientMessage.MATCH_ELIMINATED]: "bulk",
118
+ [ClientMessage.MATCH_SCORE]: "bulk",
119
+ [ClientMessage.MATCH_FINISH]: "bulk",
120
+ [ClientMessage.POSE]: "pose",
121
+ [ClientMessage.POSE_SNAP]: "pose"
122
+ };
123
+ var CloseCode = {
124
+ // reconnectable (the seat is held for the grace window)
125
+ GOING_AWAY: 1001,
126
+ NO_STATUS: 1005,
127
+ ABNORMAL: 1006,
128
+ /** Colyseus: the server is going down, come back. The one ≥4000 code that is not terminal. */
129
+ MAY_TRY_RECONNECT: 4010,
130
+ // terminal
131
+ NORMAL: 1e3,
132
+ /** A deliberate leave (Colyseus CONSENTED) — and the code a match room boots a requeued player
133
+ * with when it ignored `match:requeue` (Genex MATCH_BOOT). Same number, same meaning: seat gone. */
134
+ CONSENTED: 4e3,
135
+ MATCH_BOOT: 4e3,
136
+ SERVER_SHUTDOWN: 4001,
137
+ /** Colyseus: protocol error, or the socket exceeded MAX_MESSAGES_PER_SECOND. */
138
+ WITH_ERROR: 4002,
139
+ /** Colyseus SDK: every reconnection attempt failed (the grace expired). */
140
+ FAILED_TO_RECONNECT: 4003,
141
+ /** The player's session was revoked (signed out from the dashboard). Do not rejoin with it. */
142
+ REVOKED: 4401,
143
+ /** A newer session of the same player took the seat (second tab, or the page reloaded). */
144
+ SUPERSEDED: 4409
145
+ };
146
+ var Limits = {
147
+ ROOM_MAX_CLIENTS: 64,
148
+ MAX_NAME_CHARS: 64,
149
+ MAX_AVATAR_URL_CHARS: 512,
150
+ MAX_UID_CHARS: 64,
151
+ /** `state` / `__state:snap` payload (the JSON text). */
152
+ MAX_STATE_BYTES: 16 * 1024,
153
+ MAX_SHARED_KEY_CHARS: 256,
154
+ MAX_SHARED_VALUE_BYTES: 64 * 1024,
155
+ /** Distinct game keys a room's `shared` map may ever hold (keys are never deleted). */
156
+ MAX_SHARED_KEYS: 256,
157
+ MAX_OBJECT_ID_CHARS: 128,
158
+ /** An object's `d` as stored (JSON text). */
159
+ MAX_OBJECT_STATE_BYTES: 64 * 1024,
160
+ /** Live objects per room (an id counts from its first accepted claim until `destroy`). */
161
+ MAX_OBJECTS: 128,
162
+ MAX_REQUEST_ID_CHARS: 96,
163
+ /** Game events and `__input`: the payload's JSON encoding. */
164
+ MAX_EVENT_BYTES: 16 * 1024,
165
+ MAX_EVENT_TYPE_CHARS: 128,
166
+ /** JSON payload shape bounds: nesting depth and total node count. */
167
+ MAX_JSON_DEPTH: 32,
168
+ MAX_JSON_NODES: 4096,
169
+ /** Code points per chat line after normalization. */
170
+ MAX_CHAT_CHARS: 500,
171
+ CHAT_HISTORY_MAX: 50,
172
+ CHAT_HISTORY_MAX_BYTES: 8 * 1024,
173
+ MAX_VOICE_PARTICIPANTS: 6,
174
+ MAX_VOICE_SIGNAL_BYTES: 8 * 1024,
175
+ /** `match:score` delta is clamped to ±this. */
176
+ MAX_SCORE_DELTA: 1e3,
177
+ /** Results kept per session for request-id replay, and how long. */
178
+ CONTROL_RESULTS_PER_SESSION: 128,
179
+ CONTROL_RESULT_TTL_MS: 6e4,
180
+ /** Open preset `teams` knob range (0 = no teams). */
181
+ MAX_TEAMS: 8,
182
+ /** `__pose` `anim` string (§6.6). */
183
+ MAX_ANIM_CHARS: 64,
184
+ /** Every `__pose` number must be finite with |v| ≤ this (float32 holds it without overflowing to
185
+ * Infinity; past ~1e7 a float32 position is coarser than a metre anyway). */
186
+ MAX_POSE_ABS: 1e9
187
+ };
188
+ var RelayTiming = {
189
+ /** State patch cadence: every accumulated change goes out on this tick. */
190
+ PATCH_INTERVAL_MS: 1e3 / 30,
191
+ /** Seat grace after an abnormal drop (1006 etc.). */
192
+ RECONNECT_GRACE_S: 30,
193
+ /** Seat grace after a 1001 close (reload/navigate) — long enough to rejoin and supersede. */
194
+ GOING_AWAY_GRACE_S: 5,
195
+ /** A dropped host keeps host for this long before the relay fails over. */
196
+ HOST_FAILOVER_GRACE_MS: 1500,
197
+ /** A freshly changed owner cannot be displaced by an ordinary claim for this long. */
198
+ MIN_OWNERSHIP_HOLD_MS: 300,
199
+ /** After `server:restart`, clients are dropped this much later. */
200
+ SHUTDOWN_DRAIN_MS: 3e3,
201
+ /** A requeued match player that has not left is booted (MATCH_BOOT) after this. */
202
+ REQUEUE_GRACE_MS: 5e3,
203
+ /** A seat reservation must be consumed within this. */
204
+ SEAT_RESERVATION_S: 15,
205
+ /** The queue room's matchmaking pass. */
206
+ QUEUE_TICK_MS: 1e3,
207
+ /** Embed tokens are re-checked for revocation on this cadence (REVOKED close on hit). */
208
+ REVOCATION_SWEEP_MS: 3e4
209
+ };
210
+ var ClientReconnection = {
211
+ enabled: true,
212
+ minUptime: 0,
213
+ delay: 125,
214
+ minDelay: 100,
215
+ maxDelay: 4e3,
216
+ maxRetries: 9,
217
+ maxEnqueuedMessages: 0,
218
+ /** After the SDK reports reconnected, wait at most this for the first state decode (§8). */
219
+ firstStateTimeoutMs: 2e3
220
+ };
221
+ var POSE_KEYS = ["x", "y", "z", "yaw", "vx", "vy", "vz", "anim"];
222
+ var Interpolation = {
223
+ /** Render delay target = RATIO × median publish interval of that sender… */
224
+ RATIO: 1.5,
225
+ /** …but at least JITTER_SAFETY × (p90 − min) of that sender's (arrival − sendTime)… */
226
+ JITTER_SAFETY: 1.25,
227
+ /** …clamped to [MIN_MS, MAX_MS], and DEFAULT_MS before anything is measured. */
228
+ MIN_MS: 90,
229
+ MAX_MS: 300,
230
+ DEFAULT_MS: 100,
231
+ /** Per-sample fraction the delay moves toward its target (no pops). */
232
+ EASE: 0.1,
233
+ /**
234
+ * The min-filtered clock-offset window, kept PER SENDER: it holds at least the last CLOCK_WINDOW
235
+ * samples AND at least CLOCK_WINDOW_MS of them, whichever reaches further back, so a sender that
236
+ * owns many streams never shrinks the time the window covers. The jitter term reads each stream's
237
+ * own last CLOCK_WINDOW samples.
238
+ */
239
+ CLOCK_WINDOW: 80,
240
+ CLOCK_WINDOW_MS: 5e3,
241
+ /** Hard cap on one sender's window (memory bound for a sender publishing very many streams). */
242
+ CLOCK_WINDOW_MAX: 4096,
243
+ /** Publish-gap history for the median; gaps outside (MIN_GAP_MS, MAX_GAP_MS) are ignored. */
244
+ INTERVAL_WINDOW: 30,
245
+ MIN_GAP_MS: 5,
246
+ MAX_GAP_MS: 2e3,
247
+ /** Snapshot history kept per stream (never fewer than 2). */
248
+ HISTORY_MS: 1500,
249
+ /** Past the newest snapshot: dead-reckon position at most this far, rotation this far. */
250
+ MAX_EXTRAP_MS: 250,
251
+ ROT_EXTRAP_MS: 150,
252
+ /** Output smoothing time constants (alpha = 1 − e^(−dt/τ), dt clamped to 250 ms). */
253
+ SMOOTH_TAU_POS_MS: 30,
254
+ SMOOTH_TAU_ROT_MS: 70,
255
+ /** A head snapshot this far in the future of the newest is a foreign-clock poison: drop it. */
256
+ POISON_GUARD_MS: 2e3,
257
+ /** A reconnect gap longer than this discards every stream's history (one deliberate rebase). */
258
+ REBASE_AFTER_GAP_MS: 1e3,
259
+ /** Host tick dt is clamped to this (a backgrounded tab must not tunnel the simulation). */
260
+ MAX_HOST_TICK_DT_MS: 250
261
+ };
262
+ var ClockSync = {
263
+ /** The heartbeat rate a client SHOULD send at, from a wall-clock timer (never the render loop). */
264
+ HEARTBEAT_HZ: 10,
265
+ /** A client that sends heartbeats MUST stay within [MIN_HEARTBEAT_HZ, MAX_HEARTBEAT_HZ] while its
266
+ * page is foregrounded. Below MIN the offset and RTT refresh too slowly; above MAX buys nothing,
267
+ * and the relay's small buffer (RELAY_BUFFER) folds the excess away. */
268
+ MIN_HEARTBEAT_HZ: 5,
269
+ MAX_HEARTBEAT_HZ: 20,
270
+ /** Each interval is the nominal one × (1 ± up to this), clamped to the band above. A fixed interval
271
+ * that divides the patch interval phase-locks to the patch tick (every RTT sample then carries the
272
+ * same hold); the dither walks the phase so near-zero holds keep turning up. */
273
+ HEARTBEAT_DITHER: 0.15,
274
+ /** Relay `defineInput({ bufferMaxSize })`. Overflow counts as consumed, so a burst still acks. */
275
+ RELAY_BUFFER: 4,
276
+ /** Acked heartbeats (RTT-valid samples) before a client treats `serverNow` as synced and uses it
277
+ * for deadlines (≈3 s at 10 Hz). The room clock seeds its offset from the first TIMED frame, which
278
+ * is biased by the one-way latency, and then moves by an EMA (α = 0.1) per RTT sample, so after k
279
+ * samples 0.9^k of that bias remains: ≈35% at 10, ≈4% at 30 (≈6 ms at 150 ms one way). 30 is also
280
+ * where the room clock's RTT gate stops being bypassed (its warmup). */
281
+ WARMUP_ACKS: 30
282
+ };
283
+
284
+ export {
285
+ PROTOCOL_VERSION,
286
+ MIN_PROTOCOL_VERSION,
287
+ RoomType,
288
+ JoinOption,
289
+ SERVER_OWNED_JOIN_OPTIONS,
290
+ JoinError,
291
+ STAGING_ROOM_PREFIX,
292
+ roomKeyFor,
293
+ slugOfRoomKey,
294
+ ClientMessage,
295
+ ServerMessage,
296
+ RESERVED_EVENT_PREFIXES,
297
+ RESERVED_EVENT_TYPES,
298
+ isReservedEventType,
299
+ RESERVED_SHARED_PREFIX,
300
+ SEND_TIME_KEY,
301
+ RATE_LANES,
302
+ GLOBAL_RATE,
303
+ MAX_MESSAGES_PER_SECOND,
304
+ MESSAGE_LANE,
305
+ CloseCode,
306
+ Limits,
307
+ RelayTiming,
308
+ ClientReconnection,
309
+ POSE_KEYS,
310
+ Interpolation,
311
+ ClockSync
312
+ };
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
- export { PlayerState, RoomState } from './schema.js';
1
+ import { ObjectControlOperation as ObjectControlOperation$1, ObjectControlReason as ObjectControlReason$1 } from './protocol.js';
2
+ export { ObjectState, PlayerState, RoomState } from './schema.js';
2
3
  import '@colyseus/schema';
3
4
 
4
5
  /** One remote participant in the voice mesh. */
@@ -43,13 +44,14 @@ type ColyseusAuth = {
43
44
  };
44
45
  type AuthSource = ColyseusAuth | (() => ColyseusAuth | undefined);
45
46
  type ConnectOptions = {
46
- /** Regional relay urls (pass `getColyseusUrls()`) — the SDK pings each and connects to the
47
- * lowest-latency one; a single entry just connects. */
47
+ /** Regional relay urls (pass `getColyseusUrls()`) — the SDK probes each and connects to the
48
+ * lowest-latency one (a probe that times out falls back, it never fails the join); a single entry
49
+ * just connects. */
48
50
  urls: string[];
49
- /** Cross-region invite override: connect to THIS exact relay url, skipping latency selection. Two
50
- * players are only in the same room if they share one region's relay, so an invitee must follow the
51
- * inviter's region (carried in the invite) instead of picking its own nearest. Omit for normal
52
- * nearest-region play. */
51
+ /** Override: connect to THIS exact relay url, skipping latency selection. Relays that share
52
+ * presence (global matchmaking) seat players together whichever relay each enters through, so
53
+ * this is only needed against relays that do not — there two players meet only on one relay, and
54
+ * an invitee follows the inviter's region. Omit for normal nearest-region play. */
53
55
  url?: string;
54
56
  /** Game id — everyone who passes the same id shares a room; different ids are
55
57
  * fully isolated. Use your project slug (or any stable per-game string). */
@@ -70,6 +72,16 @@ type ConnectOptions = {
70
72
  auth?: AuthSource;
71
73
  /** Extra join options passed to the room. */
72
74
  options?: Record<string, unknown>;
75
+ } & SessionOptions;
76
+ /** How remote posed players are smoothed (EXPERIMENTAL, see {@link SessionOptions.smoothing}). */
77
+ type PredictSmoothing = {
78
+ engine: 'predict';
79
+ /** Colyseus `Predict` mode. Default `'lerp'`. */
80
+ mode?: 'lerp' | 'damped';
81
+ /** `lerp` render delay in ms (Predict's default 100). */
82
+ delay?: number;
83
+ /** Predict's output spring / damped chase rate in ms (Predict's defaults when omitted). */
84
+ smoothMs?: number;
73
85
  };
74
86
  type PlayerView<TState> = {
75
87
  id: string;
@@ -81,6 +93,14 @@ type PlayerView<TState> = {
81
93
  avatarUrl: string;
82
94
  /** Transport is live now. False while this reserved seat is inside reconnect grace. */
83
95
  connected: boolean;
96
+ /** This player's stable id IN THIS GAME: the same across reloads, devices and rooms of the
97
+ * game, different in every other game, never the account id. Key anything that outlives a
98
+ * session (scores, colours, saves, "is this me") by it — `id` is a session id and changes on
99
+ * every reload. Opaque, ≤64 chars: never parse it. Relay-stamped from the verified token. */
100
+ uid: string;
101
+ /** Team index the relay assigned, or `-1` for no team: the `open` preset's `teams: N` (kept by
102
+ * `uid` for the room's life, never reshuffled) or the `teams` preset's current round. */
103
+ team: number;
84
104
  /** RENDER ONLY — for remote players this is **auto-smoothed** (interpolated) and delayed;
85
105
  * for yourself it's live. Numeric fields are lerped; everything else snaps. Use `stateRaw`
86
106
  * for every gameplay read (hits, reach, pickups, flags, physics adoption). */
@@ -103,8 +123,12 @@ type ObjectView<TState> = {
103
123
  /** The latest raw snapshot (never smoothed/delayed) — for hit-tests / logic. */
104
124
  stateRaw: TState;
105
125
  };
106
- type ObjectControlOperation = 'claim' | 'release' | 'remove';
107
- type ObjectControlReason = 'accepted' | 'already-owner' | 'held' | 'rate-limited' | 'not-owner' | 'capacity' | 'invalid' | 'missing' | 'host-authority' | 'not-host' | 'unsupported' | 'timeout' | 'aborted' | 'disconnected';
126
+ type ObjectControlOperation = ObjectControlOperation$1;
127
+ /** The relay's reasons (PROTOCOL.md §7.2), plus the ones this client settles a request with
128
+ * itself: `timeout`, `aborted` (your signal, or `leave()`), `disconnected` (the session ended). */
129
+ type ObjectControlReason = ObjectControlReason$1
130
+ /** @deprecated Never produced since protocol 3 — every relay answers control requests. */
131
+ | 'unsupported' | 'timeout' | 'aborted' | 'disconnected';
108
132
  type ObjectControlResult = {
109
133
  requestId: string;
110
134
  operation: ObjectControlOperation;
@@ -185,7 +209,8 @@ type Session<TState extends Record<string, unknown>> = {
185
209
  /** True if you are the elected room host — the earliest signed-in player still present
186
210
  * (guests host only while no account is in the room); can migrate on join as well as leave.
187
211
  * Settles within the first state patch after connect — read it in your loop or react to
188
- * `on('host')`, not once. */
212
+ * `on('host')`, not once. False while the session is reconnecting (until the fresh snapshot
213
+ * says who the host is now) and after it ends, so a write behind `isHost` is never stale. */
189
214
  readonly isHost: boolean;
190
215
  /** Session id of the current room host, or `undefined` until the first host patch lands. */
191
216
  readonly host: string | undefined;
@@ -228,27 +253,35 @@ type Session<TState extends Record<string, unknown>> = {
228
253
  limit?: number;
229
254
  }) => void): () => void;
230
255
  /**
231
- * The connection dropped unexpectedly and auto-reconnect is underway (attempts run
232
- * ~0.5s→8s backoff inside the server's grace window). Show a "reconnecting…" overlay;
233
- * keep your loop running — the world resumes seamlessly on `reconnected`.
256
+ * The connection dropped unexpectedly and auto-reconnect is underway (one event per
257
+ * attempt; the attempts back off ~0.25s→4s and all land inside the relay's 30s seat grace).
258
+ * Show a "reconnecting…" overlay; keep your loop running — the world resumes seamlessly on
259
+ * `reconnected`. Host ticks pause until then.
234
260
  */
235
261
  on(event: 'reconnecting', cb: (info: {
236
262
  attempt: number;
237
263
  code: number;
238
264
  }) => void): () => void;
239
- /** Auto-reconnect succeeded: same session id, same seat — nothing was torn down. */
265
+ /** Auto-reconnect succeeded: same session id, same seat — nothing was torn down. Fires once
266
+ * the room's state is current again (host, owners, players), so it is safe to read here. */
240
267
  on(event: 'reconnected', cb: () => void): () => void;
241
268
  /**
242
269
  * The connection is gone for good: the server closed it on purpose (restart, revoked
243
- * session, match eviction) or every reconnect attempt failed. The session is dead —
244
- * to play again, read a FRESH `getColyseusAuth()` and `connect()` anew.
270
+ * session 4401, a newer tab took the seat 4409, match eviction) or every reconnect attempt
271
+ * failed (4003). The session is dead — to play again, read a FRESH `getColyseusAuth()` and
272
+ * `connect()` anew (but not automatically after 4409: the player is playing elsewhere).
245
273
  */
246
274
  on(event: 'disconnect', cb: (code: number) => void): () => void;
275
+ /** The host opened or closed this room to new matchmaking placements (`setRoomOpen`). */
276
+ on(event: 'room:open', cb: (info: {
277
+ open: boolean;
278
+ }) => void): () => void;
247
279
  /** A custom event another client sent via `send(type, ...)`. */
248
280
  on(event: string & {}, cb: (payload: unknown) => void): () => void;
249
281
  /** Fire-and-forget event to all OTHER clients (not stored in state). Event names used
250
- * by the relay itself (`state`, `shared`, `claim`, `obj`, `release`, `destroy`, and the
251
- * `match:`/`__` prefixes) are refused with a console warning — pick another name. */
282
+ * by the relay itself (`state`, `shared`, `claim`, `release`, `destroy`, `obj`, `queue`,
283
+ * `seat`, and the `__`/`match:`/`server:`/`rate:` prefixes) and names over 128 characters
284
+ * are refused with a console warning — pick another name. */
252
285
  send(type: string, payload?: unknown): void;
253
286
  /** Text chat for this room. See {@link ChatApi}. */
254
287
  chat: ChatApi;
@@ -263,12 +296,56 @@ type Session<TState extends Record<string, unknown>> = {
263
296
  * keeps playing, and a reconnecting player still gets back in (reconnection uses a
264
297
  * reserved seat, not matchmaking).
265
298
  *
266
- * Ignored (with no error) when called by a non-host — read `isHost` first. All
267
- * clients are told either way, so you can render "closed" from the `room:open` event.
299
+ * Ignored (with no error) when called by a non-host — read `isHost` first. When the host
300
+ * calls it every client is told, so you can render "closed" from the `room:open` event.
268
301
  */
269
302
  setRoomOpen(open: boolean): void;
270
303
  /** Leave the room (deliberate — no auto-reconnect, no `disconnect` event). */
271
304
  leave(): void;
305
+ /**
306
+ * EXPERIMENTAL — the relay's server clock (PROTOCOL.md §16): milliseconds since the room was
307
+ * created, estimated from this client's heartbeat acks. `undefined` against a relay that does not
308
+ * publish its clock. For server-stamped deadlines prefer {@link timeUntil}; never use this to drive
309
+ * rendering (remote players stay on their senders' clocks).
310
+ */
311
+ serverNow(): number | undefined;
312
+ /** EXPERIMENTAL — smoothed round-trip time to the relay in ms, from heartbeat acks. `undefined`
313
+ * until the first ack, and always against a relay that does not publish its clock. It includes
314
+ * the relay's hold until its next patch (0 to ~33 ms), so read it as an upper bound. */
315
+ readonly rtt: number | undefined;
316
+ /** EXPERIMENTAL — true once enough heartbeats were acked (~3 s) that `serverNow()` is within a few ms of the room clock. */
317
+ readonly clockSynced: boolean;
318
+ /**
319
+ * Milliseconds from now until a relay deadline stamped in epoch ms (a match's `startsAt` /
320
+ * `endsBy`; negative once it passed). Uses the server clock once it is synced — immune to a wrong
321
+ * clock on the player's device — and falls back to `deadline − Date.now()` otherwise.
322
+ */
323
+ timeUntil(epochMs: number): number;
324
+ };
325
+ /** Session behaviour options shared by `connect()`, `matchmake()`, `createPrivate()` and
326
+ * `joinPrivate()`. Every one is EXPERIMENTAL and off by default: each is an A/B arm of the
327
+ * netcode bench (PROTOCOL.md §15) and stays opt-in until it wins its gate. */
328
+ type SessionOptions = {
329
+ /**
330
+ * EXPERIMENTAL — typed pose (PROTOCOL.md §6.6). Default `false`. When `true`, the reserved keys
331
+ * `x y z yaw vx vy vz anim` of `me.set()` / `me.snap()` are published as typed relay fields
332
+ * (float32 deltas instead of a re-sent JSON blob) and everything else as `data`, which is then sent
333
+ * only when it changes. If you omit `vx/vy/vz` they are derived from successive positions.
334
+ *
335
+ * Opt in for EVERY client of the game at once. Receivers then interpolate only the pose — `yaw`
336
+ * along the shortest arc (radians), `anim` switches when the rendered pose reaches it — and SNAP
337
+ * everything else in your state: a rotation you sync as a quaternion is no longer smoothed, so put
338
+ * the heading in `yaw`. A remote posed player's `state` always carries all eight pose keys.
339
+ */
340
+ pose?: boolean;
341
+ /**
342
+ * EXPERIMENTAL — remote smoothing engine for POSED players. Default `'genex'`: the SDK's own
343
+ * sender-clock interpolation (PROTOCOL.md §10.1), which every other stream keeps whatever this says.
344
+ * `'predict'` (or a {@link PredictSmoothing}) hands posed players' `x y z vx vy vz` and `yaw` to
345
+ * Colyseus `Predict` instead — receive-time stamps, a fixed delay, no dead reckoning past the newest
346
+ * sample, and `anim`/`data` applied on arrival. It exists so the bench can A/B the two engines.
347
+ */
348
+ smoothing?: 'genex' | 'predict' | PredictSmoothing;
272
349
  };
273
350
  /** One chat line, as the relay stamped it. Only `text` came from the sender's client —
274
351
  * `from` and `name` are filled in server-side from the verified session, so a player
@@ -397,6 +474,16 @@ type MatchmakingView = {
397
474
  /** Winner of the round shown while `status === 'ended'` (competitor id; null on a draw). */
398
475
  winnerId: string | null;
399
476
  message: string;
477
+ /** The relay's epoch-ms `startsAt` — when the countdown (or the result screen before the next
478
+ * round) ends — else null. */
479
+ startsAt: number | null;
480
+ /** Round time limit: the relay's epoch-ms `endsBy` (presets with a round limit), else null. */
481
+ endsBy: number | null;
482
+ /** Milliseconds until `startsAt` as of this read (≥ 0), or null. Measured on the relay's server
483
+ * clock once it is synced, so a wrong clock on the player's device cannot skew a countdown. */
484
+ startsInMs: number | null;
485
+ /** Milliseconds until `endsBy` as of this read (≥ 0), or null. Same clock as `startsInMs`. */
486
+ endsInMs: number | null;
400
487
  };
401
488
  type Matchmaking<TState extends Record<string, unknown>> = {
402
489
  /** The live game session, or null while searching for a match. */
@@ -406,11 +493,17 @@ type Matchmaking<TState extends Record<string, unknown>> = {
406
493
  /**
407
494
  * searching: (re)entered the queue. queue: your position changed ({position,size}).
408
495
  * matched: `session` is now live. matchStart: a round began ({roundId}).
409
- * matchEnded: {winnerId,loserId,draw,scores}. spectate: you're watching, not playing.
496
+ * matchEnded: {winnerId,losers,draw,scores}. spectate: you're watching, not playing.
410
497
  * error: joining the queue failed even after the SDK's automatic retries (persistent —
411
498
  * e.g. auth is broken or the server is unreachable); fix the cause, then call `retry()`.
499
+ * A relay that refuses this SDK's protocol (code 426) fails at once, without retries: the
500
+ * game needs an SDK update.
501
+ * disconnect: the match seat ended with 4409 (this player opened the game in another tab or
502
+ * device) or 4401 (signed out). The handle stops and does NOT requeue; `session` is null. Tell
503
+ * the player, and call `retry()` only if they choose to play here again. Every other terminal
504
+ * drop requeues automatically.
412
505
  */
413
- on(event: 'searching' | 'queue' | 'matched' | 'matchStart' | 'matchEnded' | 'spectate' | 'error', cb: (payload?: unknown) => void): () => void;
506
+ on(event: 'searching' | 'queue' | 'matched' | 'matchStart' | 'matchEnded' | 'spectate' | 'error' | 'disconnect', cb: (payload?: unknown) => void): () => void;
414
507
  /** Report your OWN elimination (the `lastStanding` win condition). */
415
508
  eliminated(): void;
416
509
  /** Report a score delta for yourself (the `firstToScore` / `highScoreInTime` conditions). */
@@ -426,8 +519,8 @@ type Matchmaking<TState extends Record<string, unknown>> = {
426
519
  type MatchmakeOptions = {
427
520
  /** Regional relay urls (pass `getColyseusUrls()`) — pings each, connects to the lowest-latency one. */
428
521
  urls: string[];
429
- /** Cross-region override: matchmake on THIS exact relay url, skipping latency selection — an invitee
430
- * follows the inviter's region so both land on the same relay. Omit for normal nearest-region play. */
522
+ /** Override: matchmake on THIS exact relay url, skipping latency selection — only needed against
523
+ * relays that do not share presence (see ConnectOptions.url). Omit for normal nearest-region play. */
431
524
  url?: string;
432
525
  /** Game id (your project slug/id) — isolates this game's matchmaking. */
433
526
  room: string;
@@ -439,7 +532,7 @@ type MatchmakeOptions = {
439
532
  * token; a static object is read only once and will be rejected once it expires.
440
533
  */
441
534
  auth?: AuthSource;
442
- };
535
+ } & SessionOptions;
443
536
  /**
444
537
  * Join a game's matchmaking. Returns immediately in the SEARCHING state (`session` is null); the
445
538
  * matchmaker seats you into a match room, at which point `session` becomes a live Session and a
@@ -469,4 +562,4 @@ declare function createPrivate<TState extends Record<string, unknown> = never>(o
469
562
  */
470
563
  declare function joinPrivate<TState extends Record<string, unknown> = never>(code: string, opts: MatchmakeOptions): Promise<Matchmaking<TState>>;
471
564
 
472
- export { type AuthSource, type ChatApi, type ChatMessage, type ColyseusAuth, type ConnectOptions, type MatchStatus, type MatchmakeOptions, type Matchmaking, type MatchmakingView, type ObjectControlOperation, type ObjectControlOptions, type ObjectControlReason, type ObjectControlResult, type ObjectView, type PlayerView, type PrivateMatch, type Session, type VoiceApi, type VoiceJoinOptions, type VoiceParticipant, connect, createPrivate, joinPrivate, matchmake };
565
+ export { type AuthSource, type ChatApi, type ChatMessage, type ColyseusAuth, type ConnectOptions, type MatchStatus, type MatchmakeOptions, type Matchmaking, type MatchmakingView, type ObjectControlOperation, type ObjectControlOptions, type ObjectControlReason, type ObjectControlResult, type ObjectView, type PlayerView, type PredictSmoothing, type PrivateMatch, type Session, type SessionOptions, type VoiceApi, type VoiceJoinOptions, type VoiceParticipant, connect, createPrivate, joinPrivate, matchmake };