@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.
@@ -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
+ };