@genex-ai/multiplayer 0.8.8 → 0.10.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
@@ -13,13 +13,13 @@ import { connect } from '@genex-ai/multiplayer';
13
13
  ## Connect
14
14
 
15
15
  ```ts
16
- import { waitForPlayer, getColyseusAuth } from '@genex-ai/embed-sdk';
16
+ import { waitForPlayer, getColyseusAuth, getColyseusUrls } from '@genex-ai/embed-sdk';
17
17
 
18
18
  type State = { x: number; y: number; z: number }; // YOUR per-player state — any JSON shape
19
19
 
20
20
  await waitForPlayer(); // identity gate — resolves for guests AND accounts
21
21
  const room = await connect<State>({
22
- url: 'wss://demo-colyseus.glotech.world', // the genex multiplayer server
22
+ urls: getColyseusUrls(), // regional relays for this session — SDK joins the fastest
23
23
  room: '<game-id>', // everyone with the same id shares a room (use the project slug)
24
24
  name: 'ada', // optional display name
25
25
  auth: getColyseusAuth()!, // REQUIRED — the relay rejects tokenless joins
@@ -41,6 +41,7 @@ Replaces your state wholesale. Call on a **fixed tick (~10–20Hz)**, not every
41
41
 
42
42
  ```ts
43
43
  room.me.set({ x, y, z });
44
+ room.me.snap({ x: spawnX, y: spawnY, z: spawnZ }); // respawn/teleport: hard-reset remote history
44
45
  ```
45
46
 
46
47
  ## `players` — everyone in the room
@@ -74,21 +75,57 @@ everyone else reads it **auto-smoothed**, on the same interpolation as remote pl
74
75
  survives the owner leaving — the object is reassigned to the room host.
75
76
 
76
77
  ```ts
77
- if (iKicked) room.objects.claim('ball'); // take ownership (last claim wins)
78
+ // Legacy fire-and-forget claim: optimistic locally, corrected by the relay if rejected.
79
+ if (iTouched) room.objects.claim('ball');
80
+
81
+ // Irreversible action: wait for the authoritative result.
82
+ const result = await room.objects.claimConfirmed('ball');
83
+ if (result.accepted) applyKick();
84
+ else if (result.reason === 'held') retryWhileStillTouching(result.retryAfterMs);
78
85
 
79
86
  if (room.objects.get('ball')?.isMine) {
80
87
  room.objects.set('ball', simulateBall()); // only the owner's writes land
81
88
  }
82
- const ball = room.objects.get('ball'); // { owner, isMine, state, stateRaw }
89
+ const ball = room.objects.get('ball'); // { owner, isMine, epoch, state, stateRaw }
83
90
  drawBall(ball.state); // smoothed for everyone (live for the owner)
84
91
 
85
92
  room.objects.release('ball'); // give it up (stays until reclaimed)
86
93
  room.objects.remove('bullet'); // destroy transient objects when done
94
+
95
+ await room.objects.releaseConfirmed('ball'); // acknowledged + idempotent under reconnect
96
+ await room.objects.removeConfirmed('bullet'); // acknowledged owner-only removal
97
+
98
+ // Deliberate discontinuity: one hard reseed, never an old-to-new interpolation sweep.
99
+ room.objects.snap('ball', { x: 0, y: 1, z: 0, q: [0, 0, 0, 1] });
87
100
  ```
88
101
 
89
102
  Keep object state **flat** (top-level numbers + a 4-number quaternion) so it smooths, same as a
90
103
  player. `state` is smoothed; `stateRaw` is the raw latest (hit-tests). `objects.ids()` lists them.
91
104
 
105
+ The relay enforces a short minimum ownership hold to prevent contact thrash. A current-owner
106
+ reassert is a true no-op: it does not extend that hold. Legacy `claim()` remains optimistic for
107
+ 0.8.x compatibility; use the confirmed methods when feedback, impulses, seats, scoring, release,
108
+ or removal must not lie. Confirmed requests have stable request ids internally and are resent after
109
+ a reconnect, so an accepted operation is idempotent. A rejected result reports the current owner and,
110
+ for `held`, `retryAfterMs`.
111
+
112
+ A `timeout` result means the outcome is **unknown**, not "failed": the relay may have applied the
113
+ operation and only the answer was lost. Shared owner truth (`objects.get(id)?.isMine` /
114
+ `owner`) converges through the state mirror regardless — treat it, not the timed-out promise, as
115
+ authoritative before tearing anything down. On a relay that predates protocol v2, `me.snap` /
116
+ `objects.snap` degrade gracefully: the discontinuity write itself is not stored (peers converge on
117
+ your next ordinary `set`, without the history reset) — which is why the relay must be upgraded
118
+ before games ship an SDK that relies on snaps (see the migration note).
119
+
120
+ `claimConfirmed(id)` is an ordinary fair player claim, including when the caller happens to be host.
121
+ Only host-owned lifecycle work such as initial seeding or a goal reset should request
122
+ `claimConfirmed(id, { authority: 'host' })`; the relay accepts that bypass only from the current host.
123
+ When it actually bypasses an active hold the result reason is `host-authority`; a non-host request is
124
+ rejected as `not-host`. Do not use host authority for ordinary host-player collisions.
125
+
126
+ `me.snap` and `objects.snap` are for discontinuities only: respawn, teleport, reset, or a player mode
127
+ edge such as exiting a vehicle. Ordinary movement must use `set`, otherwise motion will visibly snap.
128
+
92
129
  ## `isHost` / `host` — the elected room authority
93
130
 
94
131
  One client is the **host** — the earliest **signed-in** player still present (guests host only
@@ -124,14 +161,19 @@ room.on('disconnect', (code) => {}); // terminal — connect() fresh to
124
161
 
125
162
  ## Reconnection (automatic)
126
163
 
127
- A dropped socket (Wi-Fi blip, brief signal loss) is **not** a leave: the relay holds your
128
- seat for a grace window (~30s) and the SDK reconnects with backoff. On success nothing
129
- changed — same session id, owned objects still yours, host status intact, and mid-match a
130
- blip is **not a forfeit**. Render `reconnecting`/`reconnected` as an overlay and keep your
131
- loop running. `disconnect` is terminal (server restart, revoked session, or the link never
132
- recovered): read a fresh token and `connect()` again. A deliberate `leave()` never
133
- reconnects. The relay also broadcasts `server:restart` right before a deploy — flush saves
134
- when you see it.
164
+ A dropped socket (Wi-Fi blip, brief signal loss) is **not** immediately a leave: the relay holds your
165
+ seat for a grace window (~30s) and the SDK reconnects with backoff. A short blip keeps the same session
166
+ id, owned objects, and host status. Simulation availability is a separate lease: if the disconnected
167
+ seat is host for longer than the relay's short host-failover grace, an active peer becomes host and
168
+ host-owned objects move once. A returning old host keeps its seat but does not steal authority back.
169
+ Host-tick callbacks pause while their socket is reconnecting and resume only if that session still owns
170
+ the host role. After a long reconnect, remote interpolation histories are rebased to current truth so
171
+ they cannot draw a whole-map catch-up streak.
172
+
173
+ Render `reconnecting`/`reconnected` as an overlay and keep your loop running. `disconnect` is terminal
174
+ (server restart, revoked session, or the link never recovered): read a fresh token and `connect()`
175
+ again. A deliberate `leave()` never reconnects. The relay also broadcasts `server:restart` right before
176
+ a deploy — flush saves when you see it.
135
177
 
136
178
  **One seat per player:** the relay enforces a single session per identity per room — a
137
179
  second tab / device / reloaded page supersedes the previous session (old one gets
@@ -160,8 +202,10 @@ room.onHostTick(30, (dtMs) => { // runs only on t
160
202
  });
161
203
  ```
162
204
 
163
- `onHostTick` auto-starts on election and auto-stops on demotion; a new host seeds its world
164
- from each object's `stateRaw`, so the simulation survives host migration without a snap.
205
+ `onHostTick` auto-starts on election, pauses during reconnect, resumes after reconnect only if still
206
+ host, and stops on demotion, deliberate `leave()`, or terminal disconnect. The disposer is still useful
207
+ when the game removes that subsystem earlier. A new host must confirm an explicit host-authority claim
208
+ and seed its world from each object's `stateRaw`, so simulation survives host migration without a snap.
165
209
 
166
210
  ## `send` — custom one-off events
167
211
 
@@ -183,6 +227,11 @@ room.leave();
183
227
 
184
228
  Remote players are **smoothed automatically** — nothing to configure. `room.players` gives each remote player's `state` rendered slightly in the past and interpolated between snapshots, so motion glides between network updates (you send ~10–20Hz but render at 60). Interpolation runs on the **sender's clock** (each update is timestamped and mapped to your local clock), the way Valve/Unity/Mirror do it — so jitter doesn't distort the motion. The delay **self-tunes to both the live snapshot rate and the link's measured jitter**, so it stays smooth whether you send at 5Hz or 30Hz and whether the connection is a clean LAN or a jittery mobile link — a deeper buffer is used only where the jitter demands it.
185
229
 
230
+ Do not add another lerp, snapshot buffer, or remote physics predictor on top. `state` is already the
231
+ render value. This relay does not implement classic client-side prediction plus server reconciliation:
232
+ your own player is client-authoritative and renders locally; remote entities use interpolation and
233
+ bounded extrapolation.
234
+
186
235
  - `player.state` is the **smoothed** value (what you draw); `player.stateRaw` is the **latest raw** snapshot — use it for hit-tests / discrete logic.
187
236
  - **Your own player is never delayed** — you always render yourself live.
188
237
  - **Numbers** are interpolated; a 4-element `[x, y, z, w]` **quaternion** is **slerped** (shortest-arc); anything else snaps.
@@ -197,12 +246,12 @@ Remote players are **smoothed automatically** — nothing to configure. `room.pl
197
246
 
198
247
  ```ts
199
248
  import { connect } from '@genex-ai/multiplayer';
200
- import { waitForPlayer, getColyseusAuth } from '@genex-ai/embed-sdk';
249
+ import { waitForPlayer, getColyseusAuth, getColyseusUrls } from '@genex-ai/embed-sdk';
201
250
  type S = { x: number; z: number };
202
251
 
203
252
  await waitForPlayer(); // identity gate (guest OR signed-in) — see Connect above
204
253
  const room = await connect<S>({
205
- url: 'wss://demo-colyseus.glotech.world',
254
+ urls: getColyseusUrls(),
206
255
  room: GAME_ID,
207
256
  auth: getColyseusAuth()!, // REQUIRED — tokenless joins are rejected
208
257
  });
@@ -240,7 +289,7 @@ import { matchmake } from "@genex-ai/multiplayer";
240
289
 
241
290
  // Pass `auth` as a FUNCTION — a handle re-joins many times (re-search/requeue) and tokens rotate
242
291
  // (~10 min); a function is read fresh each join, a static object goes stale mid-session.
243
- const mm = await matchmake<State>({ url, room: slug, auth: () => getColyseusAuth() });
292
+ const mm = await matchmake<State>({ urls, room: slug, auth: () => getColyseusAuth() });
244
293
 
245
294
  // `session` is null WHILE SEARCHING — render your own "finding a match…" HUD until it goes live.
246
295
  // each frame:
@@ -295,11 +344,11 @@ For `preset: 'private'` games, skip the queue: a host mints an invite **code**,
295
344
  import { createPrivate, joinPrivate } from "@genex-ai/multiplayer";
296
345
 
297
346
  // host
298
- const lobby = await createPrivate<State>({ url, room: slug, auth: () => getColyseusAuth() });
347
+ const lobby = await createPrivate<State>({ urls, room: slug, auth: () => getColyseusAuth() });
299
348
  share(lobby.code); // e.g. show it in the UI
300
349
 
301
350
  // friend (elsewhere)
302
- const joined = await joinPrivate<State>(code, { url, room: slug, auth: () => getColyseusAuth() });
351
+ const joined = await joinPrivate<State>(code, { urls, room: slug, auth: () => getColyseusAuth() });
303
352
 
304
353
  // then identical to matchmake(): .session, .matchmaking, eliminated()/score()/finish(), cancel()
305
354
  ```
package/dist/index.d.ts CHANGED
@@ -2,8 +2,9 @@ export { PlayerState, RoomState } from './schema.js';
2
2
  import '@colyseus/schema';
3
3
 
4
4
  type ConnectOptions = {
5
- /** Colyseus server url, e.g. "ws://localhost:2567". */
6
- url: string;
5
+ /** Regional relay urls (pass `getColyseusUrls()`) — the SDK pings each and connects to the
6
+ * lowest-latency one; a single entry just connects. */
7
+ urls: string[];
7
8
  /** Game id — everyone who passes the same id shares a room; different ids are
8
9
  * fully isolated. Use your project slug (or any stable per-game string). */
9
10
  room: string;
@@ -37,6 +38,8 @@ type PlayerView<TState> = {
37
38
  /** A shared object (a ball, a puck, an NPC) owned by exactly one client at a time. */
38
39
  type ObjectView<TState> = {
39
40
  id: string;
41
+ /** Relay-owned discontinuity epoch. Helpers direct-place physics when this changes. */
42
+ epoch: number;
40
43
  /** Session id of the current owner, or `undefined` if unowned. */
41
44
  owner: string | undefined;
42
45
  /** True if you own it (so you should simulate + `objects.set` it). */
@@ -47,6 +50,28 @@ type ObjectView<TState> = {
47
50
  /** The latest raw snapshot (never smoothed/delayed) — for hit-tests / logic. */
48
51
  stateRaw: TState;
49
52
  };
53
+ type ObjectControlOperation = 'claim' | 'release' | 'remove';
54
+ type ObjectControlReason = 'accepted' | 'already-owner' | 'held' | 'rate-limited' | 'not-owner' | 'capacity' | 'invalid' | 'missing' | 'host-authority' | 'not-host' | 'unsupported' | 'timeout' | 'aborted' | 'disconnected';
55
+ type ObjectControlResult = {
56
+ requestId: string;
57
+ operation: ObjectControlOperation;
58
+ id: string;
59
+ accepted: boolean;
60
+ owner?: string;
61
+ sequence: number;
62
+ reason: ObjectControlReason;
63
+ retryAfterMs?: number;
64
+ };
65
+ type ObjectControlOptions = {
66
+ /** Ordinary player claims are subject to the hold. Host authority is for seeding/reset only. */
67
+ authority?: 'player' | 'host';
68
+ /** Locally mark a claim as owned while awaiting the result. Default false. */
69
+ optimistic?: boolean;
70
+ /** Maximum wait including reconnect resend. Default 5000 ms. */
71
+ timeoutMs?: number;
72
+ /** Cancels the pending operation and resolves with reason `aborted`. */
73
+ signal?: AbortSignal;
74
+ };
50
75
  type Session<TState extends Record<string, unknown>> = {
51
76
  /** Your own session id. */
52
77
  readonly id: string;
@@ -62,6 +87,8 @@ type Session<TState extends Record<string, unknown>> = {
62
87
  * (~10–20Hz), not every render frame — each call sends one network message.
63
88
  */
64
89
  set(state: TState): void;
90
+ /** Publish a deliberate discontinuity (respawn/teleport/mode edge) and clear remote history. */
91
+ snap(state: TState): void;
65
92
  };
66
93
  readonly shared: {
67
94
  /** Read a shared value (any JSON), or `undefined`. */
@@ -79,14 +106,22 @@ type Session<TState extends Record<string, unknown>> = {
79
106
  * Ownership survives an owner leaving: the object is reassigned to the room host.
80
107
  */
81
108
  readonly objects: {
82
- /** Take ownership (last claim wins — call it when you kick/touch the object). */
109
+ /** Legacy optimistic ownership request. Prefer claimConfirmed for actions that need a result. */
83
110
  claim(id: string): void;
111
+ /** Request ownership and receive the relay's accepted/rejected result. */
112
+ claimConfirmed(id: string, options?: ObjectControlOptions): Promise<ObjectControlResult>;
84
113
  /** Give up ownership (leaves the object unowned until someone claims it). */
85
114
  release(id: string): void;
115
+ /** Give up ownership only after an explicit relay acknowledgement. */
116
+ releaseConfirmed(id: string, options?: ObjectControlOptions): Promise<ObjectControlResult>;
86
117
  /** Destroy the object everywhere (owner-only) — use for transient objects (bullets, pickups). */
87
118
  remove(id: string): void;
119
+ /** Destroy the object and receive an idempotent relay acknowledgement. */
120
+ removeConfirmed(id: string, options?: ObjectControlOptions): Promise<ObjectControlResult>;
88
121
  /** Publish the object's state — only takes effect while you own it. Full object each call. */
89
122
  set(id: string, state: Record<string, unknown>): void;
123
+ /** Owner-only deliberate discontinuity (reset/teleport), rendered as one hard reseed. */
124
+ snap(id: string, state: Record<string, unknown>): void;
90
125
  /** Read an object, or `undefined` if it doesn't exist yet. */
91
126
  get<T extends Record<string, unknown> = Record<string, unknown>>(id: string): ObjectView<T> | undefined;
92
127
  /** Ids of every object seen so far. */
@@ -230,8 +265,8 @@ type Matchmaking<TState extends Record<string, unknown>> = {
230
265
  cancel(): void;
231
266
  };
232
267
  type MatchmakeOptions = {
233
- /** Colyseus server url. */
234
- url: string;
268
+ /** Regional relay urls (pass `getColyseusUrls()`) — pings each, connects to the lowest-latency one. */
269
+ urls: string[];
235
270
  /** Game id (your project slug/id) — isolates this game's matchmaking. */
236
271
  room: string;
237
272
  name?: string;
@@ -276,4 +311,4 @@ declare function createPrivate<TState extends Record<string, unknown> = never>(o
276
311
  */
277
312
  declare function joinPrivate<TState extends Record<string, unknown> = never>(code: string, opts: MatchmakeOptions): Promise<Matchmaking<TState>>;
278
313
 
279
- export { type ConnectOptions, type MatchStatus, type MatchmakeOptions, type Matchmaking, type MatchmakingView, type ObjectView, type PlayerView, type PrivateMatch, type Session, connect, createPrivate, joinPrivate, matchmake };
314
+ export { 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, connect, createPrivate, joinPrivate, matchmake };
package/dist/index.js CHANGED
@@ -1,12 +1,12 @@
1
1
  // src/index.ts
2
- import { Client, getStateCallbacks } from "colyseus.js";
2
+ import { Client, getStateCallbacks } from "@colyseus/sdk";
3
3
  var RELAY_ROOM_TYPE = "play";
4
4
  var QUEUE_ROOM_TYPE = "queue";
5
5
  var MATCH_ROOM_TYPE = "match";
6
6
  var INTERP_RATIO = 1.5;
7
7
  var INTERP_MIN_MS = 90;
8
8
  var INTERP_MAX_MS = 300;
9
- var JITTER_SAFETY = 1.2;
9
+ var JITTER_SAFETY = 1.25;
10
10
  var POISON_GUARD_MS = 2e3;
11
11
  var INTERP_EASE = 0.1;
12
12
  var MAX_EXTRAP_MS = 250;
@@ -20,7 +20,19 @@ var OBJ_PREFIX = "__obj:";
20
20
  var HOST_KEY = "__host";
21
21
  var MATCH_KEY = "__match";
22
22
  var INPUT_MSG = "__input";
23
- var MP_PROTOCOL = 1;
23
+ var CAPABILITIES_MSG = "__capabilities";
24
+ var CAPABILITIES_REQUEST_MSG = "__capabilities:request";
25
+ var OBJECT_CONTROL_RESULT_MSG = "__object:result";
26
+ var OBJECT_CONTROL_VERSION = 2;
27
+ var STATE_SNAP_MSG = "__state:snap";
28
+ var OBJECT_SNAP_MSG = "__object:snap";
29
+ var MP_PROTOCOL = 2;
30
+ async function relayClient(opts) {
31
+ if (opts.urls.length > 1) return Client.selectByLatency(opts.urls);
32
+ const url = opts.urls[0];
33
+ if (!url) throw new Error("genex multiplayer: `urls` must contain at least one relay URL");
34
+ return new Client(url);
35
+ }
24
36
  function parseData(s) {
25
37
  if (!s) return { state: {} };
26
38
  try {
@@ -145,7 +157,7 @@ function quantile(xs, q) {
145
157
  return s[i];
146
158
  }
147
159
  async function connect(opts) {
148
- const client = new Client(opts.url);
160
+ const client = await relayClient(opts);
149
161
  const room = await client.joinOrCreate(RELAY_ROOM_TYPE, {
150
162
  gameId: opts.room,
151
163
  name: opts.name,
@@ -163,13 +175,18 @@ var RESERVED_SEND_TYPES = /* @__PURE__ */ new Set([
163
175
  "obj",
164
176
  "release",
165
177
  "destroy",
178
+ STATE_SNAP_MSG,
179
+ OBJECT_SNAP_MSG,
166
180
  // Server→client control verbs a game must never ORIGINATE — they'd broadcast to peers via the
167
181
  // relay's `*` handler: 'server:restart' would trip every peer's save-flush, 'rate:drop' a bogus
168
182
  // over-budget warning, 'queue'/'seat' spurious matchmaking control.
169
183
  "server:restart",
170
184
  "rate:drop",
171
185
  "queue",
172
- "seat"
186
+ "seat",
187
+ CAPABILITIES_MSG,
188
+ CAPABILITIES_REQUEST_MSG,
189
+ OBJECT_CONTROL_RESULT_MSG
173
190
  ]);
174
191
  var isReservedSendType = (type) => RESERVED_SEND_TYPES.has(type) || type.startsWith("match:") || type.startsWith(RESERVED_PREFIX);
175
192
  var RECONNECT_DELAYS_MS = [500, 1e3, 2e3, 4e3, 8e3];
@@ -187,10 +204,14 @@ function attachSession(client, initialRoom) {
187
204
  const autoDelayById = /* @__PURE__ */ new Map();
188
205
  let localState;
189
206
  const objOwners = /* @__PURE__ */ new Map();
207
+ const objEpochs = /* @__PURE__ */ new Map();
190
208
  const objRaw = /* @__PURE__ */ new Map();
191
209
  const localObjState = /* @__PURE__ */ new Map();
192
210
  const pendingBridge = /* @__PURE__ */ new Map();
193
211
  let hostId;
212
+ let objectControlVersion = 0;
213
+ let requestCounter = 0;
214
+ const pendingControls = /* @__PURE__ */ new Map();
194
215
  const testSkew = typeof globalThis !== "undefined" && globalThis.__GENEX_MP_TEST_SKEW__ || 0;
195
216
  const now = testSkew ? () => nowMs() + testSkew : nowMs;
196
217
  const pushSnap = (id, state, sendTime) => {
@@ -286,8 +307,13 @@ function attachSession(client, initialRoom) {
286
307
  "disconnect",
287
308
  INPUT_MSG,
288
309
  // inputs.on() has its own channel — never bridged as a custom event
289
- "rate:drop"
310
+ "rate:drop",
290
311
  // relay → us only (over-budget warning); handled internally, never bridged
312
+ CAPABILITIES_MSG,
313
+ CAPABILITIES_REQUEST_MSG,
314
+ OBJECT_CONTROL_RESULT_MSG,
315
+ STATE_SNAP_MSG,
316
+ OBJECT_SNAP_MSG
291
317
  ]);
292
318
  const emit = (event, ...args) => emitters.get(event)?.forEach((cb) => cb(...args));
293
319
  const lastShared = /* @__PURE__ */ new Map();
@@ -309,18 +335,26 @@ function attachSession(client, initialRoom) {
309
335
  if (!blob || typeof blob !== "object") return;
310
336
  const owner = typeof blob.o === "string" && blob.o ? blob.o : void 0;
311
337
  const ownerChanged = owner !== objOwners.get(id);
338
+ const epoch = typeof blob.e === "number" && Number.isFinite(blob.e) ? blob.e : 0;
339
+ const epochChanged = objEpochs.has(id) && epoch !== objEpochs.get(id);
340
+ objEpochs.set(id, epoch);
341
+ if (epochChanged) clearStream(key);
312
342
  if (ownerChanged) {
313
343
  if (owner) objOwners.set(id, owner);
314
344
  else objOwners.delete(id);
315
345
  if (owner !== room.sessionId) {
316
346
  localObjState.delete(id);
317
- clearStreamClock(key);
347
+ if (!epochChanged) clearStreamClock(key);
318
348
  }
319
349
  }
320
350
  if (blob.d && typeof blob.d === "object" && Object.keys(blob.d).length > 0) {
321
351
  objRaw.set(id, blob.d);
322
352
  if (owner !== room.sessionId) {
323
- if (ownerChanged) {
353
+ if (epochChanged) {
354
+ smoothed.set(key, blob.d);
355
+ smoothedAt.set(key, now());
356
+ pushSnap(key, blob.d, blob.t);
357
+ } else if (ownerChanged) {
324
358
  pendingBridge.set(key, smoothed.get(key) ?? blob.d);
325
359
  } else {
326
360
  pushSnap(key, blob.d, blob.t);
@@ -333,6 +367,7 @@ function attachSession(client, initialRoom) {
333
367
  emit("shared", key, parseValue(value));
334
368
  };
335
369
  const parseCache = /* @__PURE__ */ new Map();
370
+ const playerEpochs = /* @__PURE__ */ new Map();
336
371
  const cachedParse = (id, raw) => {
337
372
  if (!raw) return {};
338
373
  const hit = parseCache.get(id);
@@ -343,6 +378,145 @@ function attachSession(client, initialRoom) {
343
378
  };
344
379
  let intentionalLeave = false;
345
380
  let dead = false;
381
+ let connected = true;
382
+ let reconnectStartedAt = 0;
383
+ const hostTickSyncs = /* @__PURE__ */ new Set();
384
+ const syncHostTicks = () => hostTickSyncs.forEach((sync) => sync());
385
+ const settleControl = (pending, result) => {
386
+ pendingControls.delete(pending.requestId);
387
+ if (pending.timeout) clearTimeout(pending.timeout);
388
+ if (pending.capabilityTimer) clearTimeout(pending.capabilityTimer);
389
+ pending.abort?.();
390
+ if (pending.operation === "claim" && pending.optimistic && !result.accepted) {
391
+ if (result.owner) objOwners.set(pending.id, result.owner);
392
+ else objOwners.delete(pending.id);
393
+ if (result.owner !== room.sessionId) localObjState.delete(pending.id);
394
+ clearStreamClock(OBJ_PREFIX + pending.id);
395
+ }
396
+ if (pending.operation === "claim" && result.accepted) {
397
+ objOwners.set(pending.id, room.sessionId);
398
+ clearStreamClock(OBJ_PREFIX + pending.id);
399
+ }
400
+ if (pending.operation === "release" && result.accepted) {
401
+ localObjState.delete(pending.id);
402
+ if (objOwners.get(pending.id) === room.sessionId) objOwners.delete(pending.id);
403
+ clearStreamClock(OBJ_PREFIX + pending.id);
404
+ }
405
+ if (pending.operation === "remove" && result.accepted) {
406
+ localObjState.delete(pending.id);
407
+ objRaw.delete(pending.id);
408
+ objOwners.delete(pending.id);
409
+ clearStream(OBJ_PREFIX + pending.id);
410
+ }
411
+ pending.resolve(result);
412
+ };
413
+ const settleAllControls = (reason) => {
414
+ for (const pending of [...pendingControls.values()]) {
415
+ settleControl(pending, {
416
+ requestId: pending.requestId,
417
+ operation: pending.operation,
418
+ id: pending.id,
419
+ accepted: false,
420
+ owner: pending.optimistic ? pending.previousOwner : objOwners.get(pending.id),
421
+ sequence: 0,
422
+ reason
423
+ });
424
+ }
425
+ };
426
+ const sendPendingControl = (pending) => {
427
+ if (!connected || !room.connection?.isOpen) return;
428
+ send0(
429
+ pending.operation === "remove" ? "destroy" : pending.operation,
430
+ pending.payload
431
+ );
432
+ };
433
+ const flushCapabilityWaiters = () => {
434
+ if (objectControlVersion < OBJECT_CONTROL_VERSION) return;
435
+ for (const pending of pendingControls.values()) {
436
+ if (pending.capabilityTimer) {
437
+ clearTimeout(pending.capabilityTimer);
438
+ pending.capabilityTimer = null;
439
+ }
440
+ sendPendingControl(pending);
441
+ }
442
+ };
443
+ const confirmedControl = (operation, id, options = {}) => {
444
+ const key = String(id);
445
+ const requestId = `${room.sessionId.slice(0, 12)}:${Date.now().toString(36)}:${(++requestCounter).toString(36)}`;
446
+ const timeoutMs = Math.max(250, Math.min(options.timeoutMs ?? 5e3, 6e4));
447
+ return new Promise((resolve) => {
448
+ const pending = {
449
+ operation,
450
+ id: key,
451
+ requestId,
452
+ payload: {
453
+ id: key,
454
+ requestId,
455
+ ...options.authority === "host" ? { authority: "host" } : {}
456
+ },
457
+ optimistic: operation === "claim" && options.optimistic === true,
458
+ previousOwner: objOwners.get(key),
459
+ resolve,
460
+ timeout: null,
461
+ capabilityTimer: null,
462
+ abort: void 0
463
+ };
464
+ if (pending.optimistic) objOwners.set(key, room.sessionId);
465
+ pending.timeout = setTimeout(() => {
466
+ settleControl(pending, {
467
+ requestId,
468
+ operation,
469
+ id: key,
470
+ accepted: false,
471
+ owner: pending.optimistic ? pending.previousOwner : objOwners.get(key),
472
+ sequence: 0,
473
+ reason: "timeout"
474
+ });
475
+ }, timeoutMs);
476
+ if (options.signal) {
477
+ const onAbort = () => settleControl(pending, {
478
+ requestId,
479
+ operation,
480
+ id: key,
481
+ accepted: false,
482
+ owner: pending.optimistic ? pending.previousOwner : objOwners.get(key),
483
+ sequence: 0,
484
+ reason: "aborted"
485
+ });
486
+ options.signal.addEventListener("abort", onAbort, { once: true });
487
+ pending.abort = () => options.signal?.removeEventListener("abort", onAbort);
488
+ }
489
+ pendingControls.set(requestId, pending);
490
+ if (options.signal?.aborted) {
491
+ settleControl(pending, {
492
+ requestId,
493
+ operation,
494
+ id: key,
495
+ accepted: false,
496
+ owner: pending.optimistic ? pending.previousOwner : objOwners.get(key),
497
+ sequence: 0,
498
+ reason: "aborted"
499
+ });
500
+ return;
501
+ }
502
+ if (objectControlVersion >= OBJECT_CONTROL_VERSION) {
503
+ sendPendingControl(pending);
504
+ } else {
505
+ pending.capabilityTimer = setTimeout(() => {
506
+ if (!pendingControls.has(requestId) || objectControlVersion >= OBJECT_CONTROL_VERSION) return;
507
+ settleControl(pending, {
508
+ requestId,
509
+ operation,
510
+ id: key,
511
+ accepted: false,
512
+ owner: pending.optimistic ? pending.previousOwner : objOwners.get(key),
513
+ sequence: 0,
514
+ reason: "unsupported"
515
+ });
516
+ }, 500);
517
+ }
518
+ });
519
+ };
346
520
  let droppedWhileOffline = 0;
347
521
  const send0 = (type, payload, countIfDropped = false) => {
348
522
  if (room.connection?.isOpen) {
@@ -355,13 +529,22 @@ function attachSession(client, initialRoom) {
355
529
  if (countIfDropped) droppedWhileOffline++;
356
530
  };
357
531
  const wireRoom = (r) => {
532
+ let hydrated;
358
533
  const $ = getStateCallbacks(r);
359
534
  $(r.state).players.onAdd((player, id) => {
360
535
  const { state, sendTime } = parseData(player.data);
536
+ const addEpoch = Number.isFinite(player.epoch) ? player.epoch : 0;
537
+ const addEpochChanged = playerEpochs.has(id) && addEpoch !== playerEpochs.get(id);
538
+ playerEpochs.set(id, addEpoch);
539
+ if (id !== room.sessionId && addEpochChanged) clearStream(id);
361
540
  emit("join", id, state);
362
541
  if (id !== room.sessionId && sendTime != null) pushSnap(id, state, sendTime);
363
542
  $(player).onChange(() => {
364
543
  const { state: st, sendTime: ts } = parseData(player.data);
544
+ const epoch = Number.isFinite(player.epoch) ? player.epoch : 0;
545
+ const epochChanged = epoch !== (playerEpochs.get(id) ?? epoch);
546
+ playerEpochs.set(id, epoch);
547
+ if (id !== room.sessionId && epochChanged) clearStream(id);
365
548
  emit("change", id, st);
366
549
  if (id !== room.sessionId) pushSnap(id, st, ts);
367
550
  });
@@ -370,6 +553,7 @@ function attachSession(client, initialRoom) {
370
553
  emit("leave", id);
371
554
  clearStream(id);
372
555
  parseCache.delete(id);
556
+ playerEpochs.delete(id);
373
557
  });
374
558
  $(r.state).shared.onAdd(handleShared);
375
559
  $(r.state).shared.onChange(handleShared);
@@ -387,9 +571,57 @@ function attachSession(client, initialRoom) {
387
571
  clearStream(key);
388
572
  objRaw.delete(id);
389
573
  objOwners.delete(id);
574
+ objEpochs.delete(id);
575
+ localObjState.delete(id);
576
+ emit("object", id);
577
+ }
578
+ });
579
+ const hydrateShared = (state) => {
580
+ const shared = state?.shared;
581
+ shared?.forEach((value, key) => handleShared(value, key));
582
+ if (!shared) return;
583
+ for (const id of /* @__PURE__ */ new Set([...objOwners.keys(), ...objRaw.keys(), ...localObjState.keys()])) {
584
+ const key = OBJ_PREFIX + id;
585
+ if (shared.has(key)) continue;
586
+ lastShared.delete(key);
587
+ clearStream(key);
588
+ objRaw.delete(id);
589
+ objOwners.delete(id);
590
+ objEpochs.delete(id);
390
591
  localObjState.delete(id);
391
592
  emit("object", id);
392
593
  }
594
+ if (hostId !== void 0 && !shared.has(HOST_KEY)) {
595
+ lastShared.delete(HOST_KEY);
596
+ hostId = void 0;
597
+ emit("host", void 0);
598
+ }
599
+ };
600
+ if (r.state?.shared) {
601
+ hydrateShared(r.state);
602
+ hydrated = Promise.resolve();
603
+ } else {
604
+ hydrated = new Promise((resolve) => {
605
+ r.onStateChange.once((state) => {
606
+ hydrateShared(state);
607
+ resolve();
608
+ });
609
+ });
610
+ }
611
+ r.onMessage(CAPABILITIES_MSG, (msg) => {
612
+ if (typeof msg?.objectControl === "number") {
613
+ objectControlVersion = Math.max(objectControlVersion, msg.objectControl);
614
+ flushCapabilityWaiters();
615
+ }
616
+ });
617
+ r.onMessage(CAPABILITIES_REQUEST_MSG, () => {
618
+ });
619
+ send0(CAPABILITIES_REQUEST_MSG, { objectControl: OBJECT_CONTROL_VERSION });
620
+ r.onMessage(OBJECT_CONTROL_RESULT_MSG, (msg) => {
621
+ if (!msg || typeof msg.requestId !== "string") return;
622
+ const pending = pendingControls.get(msg.requestId);
623
+ if (!pending || msg.operation !== pending.operation || msg.id !== pending.id) return;
624
+ settleControl(pending, msg);
393
625
  });
394
626
  r.onMessage(INPUT_MSG, (msg) => {
395
627
  if (msg && typeof msg.from === "string") emit(INPUT_MSG, msg.from, msg.d);
@@ -403,12 +635,17 @@ function attachSession(client, initialRoom) {
403
635
  r.onLeave((code) => handleLeave(r, code ?? 1006));
404
636
  r.onError(() => {
405
637
  });
638
+ return hydrated;
406
639
  };
407
640
  const handleLeave = (left, code) => {
408
641
  if (left !== room || dead) return;
642
+ connected = false;
643
+ reconnectStartedAt ||= nowMs();
644
+ syncHostTicks();
409
645
  if (intentionalLeave) return;
410
646
  if (code === 1e3 || code >= 4e3) {
411
647
  dead = true;
648
+ settleAllControls("disconnected");
412
649
  emit("disconnect", code);
413
650
  return;
414
651
  }
@@ -421,7 +658,17 @@ function attachSession(client, initialRoom) {
421
658
  try {
422
659
  const next = await client.reconnect(token);
423
660
  room = next;
424
- wireRoom(next);
661
+ connected = true;
662
+ const reconnectGap = reconnectStartedAt ? nowMs() - reconnectStartedAt : 0;
663
+ reconnectStartedAt = 0;
664
+ if (reconnectGap > 1e3) {
665
+ for (const streamKey of [...buffers.keys()]) clearStream(streamKey);
666
+ }
667
+ const hydrated = wireRoom(next);
668
+ await Promise.race([hydrated, new Promise((r) => setTimeout(r, 2e3))]);
669
+ if (room !== next || dead || intentionalLeave) return;
670
+ syncHostTicks();
671
+ for (const pending of pendingControls.values()) sendPendingControl(pending);
425
672
  emit("reconnected");
426
673
  if (droppedWhileOffline > 0) {
427
674
  console.warn(
@@ -438,6 +685,7 @@ function attachSession(client, initialRoom) {
438
685
  }
439
686
  }
440
687
  dead = true;
688
+ settleAllControls("disconnected");
441
689
  emit("disconnect", code);
442
690
  };
443
691
  wireRoom(room);
@@ -482,6 +730,14 @@ function attachSession(client, initialRoom) {
482
730
  const clean = sanitizeFinite(state);
483
731
  localState = clean;
484
732
  send0("state", JSON.stringify({ ...clean, [SEND_TIME_KEY]: now() }));
733
+ },
734
+ snap(state) {
735
+ if (SEND_TIME_KEY in state) {
736
+ console.warn(`[multiplayer] '${SEND_TIME_KEY}' is a reserved field name and will be overwritten by the send timestamp \u2014 rename it.`);
737
+ }
738
+ const clean = sanitizeFinite(state);
739
+ localState = clean;
740
+ send0(STATE_SNAP_MSG, JSON.stringify({ ...clean, [SEND_TIME_KEY]: now() }));
485
741
  }
486
742
  },
487
743
  shared: {
@@ -503,6 +759,9 @@ function attachSession(client, initialRoom) {
503
759
  objOwners.set(key, room.sessionId);
504
760
  send0("claim", { id: key });
505
761
  },
762
+ claimConfirmed(id, options) {
763
+ return confirmedControl("claim", id, options);
764
+ },
506
765
  release(id) {
507
766
  const key = String(id);
508
767
  localObjState.delete(key);
@@ -510,6 +769,9 @@ function attachSession(client, initialRoom) {
510
769
  clearStreamClock(OBJ_PREFIX + key);
511
770
  send0("release", { id: key });
512
771
  },
772
+ releaseConfirmed(id, options) {
773
+ return confirmedControl("release", id, options);
774
+ },
513
775
  remove(id) {
514
776
  const key = String(id);
515
777
  if (objOwners.get(key) === room.sessionId) {
@@ -520,12 +782,21 @@ function attachSession(client, initialRoom) {
520
782
  }
521
783
  send0("destroy", { id: key });
522
784
  },
785
+ removeConfirmed(id, options) {
786
+ return confirmedControl("remove", id, options);
787
+ },
523
788
  set(id, state) {
524
789
  const key = String(id);
525
790
  const clean = sanitizeFinite(state);
526
791
  if (objOwners.get(key) === room.sessionId) localObjState.set(key, clean);
527
792
  send0("obj", { id: key, d: clean, t: now() });
528
793
  },
794
+ snap(id, state) {
795
+ const key = String(id);
796
+ const clean = sanitizeFinite(state);
797
+ if (objOwners.get(key) === room.sessionId) localObjState.set(key, clean);
798
+ send0(OBJECT_SNAP_MSG, { id: key, d: clean, t: now() });
799
+ },
529
800
  get(id) {
530
801
  const key = String(id);
531
802
  const owner = objOwners.get(key);
@@ -543,7 +814,14 @@ function attachSession(client, initialRoom) {
543
814
  raw = objRaw.get(key) ?? {};
544
815
  state = sampleSmoothed(OBJ_PREFIX + key, now()) ?? raw;
545
816
  }
546
- return { id: key, owner, isMine, state, stateRaw: raw };
817
+ return {
818
+ id: key,
819
+ epoch: objEpochs.get(key) ?? 0,
820
+ owner,
821
+ isMine,
822
+ state,
823
+ stateRaw: raw
824
+ };
547
825
  },
548
826
  ids() {
549
827
  return [.../* @__PURE__ */ new Set([...objOwners.keys(), ...objRaw.keys(), ...localObjState.keys()])];
@@ -570,24 +848,28 @@ function attachSession(client, initialRoom) {
570
848
  onHostTick(hz, cb) {
571
849
  let timer = null;
572
850
  let last = 0;
851
+ const safeHz = Number.isFinite(hz) && hz > 0 ? Math.min(hz, 100) : 20;
852
+ const intervalMs = Math.max(10, Math.round(1e3 / safeHz));
573
853
  const sync = () => {
574
- const shouldRun = hostId !== void 0 && hostId === room.sessionId;
854
+ const shouldRun = connected && !intentionalLeave && !dead && hostId !== void 0 && hostId === room.sessionId;
575
855
  if (shouldRun && !timer) {
576
856
  last = nowMs();
577
857
  timer = setInterval(() => {
578
858
  const t = nowMs();
579
859
  cb(Math.min(t - last, 250));
580
860
  last = t;
581
- }, Math.max(10, Math.round(1e3 / hz)));
861
+ }, intervalMs);
582
862
  } else if (!shouldRun && timer) {
583
863
  clearInterval(timer);
584
864
  timer = null;
585
865
  }
586
866
  };
587
867
  const off = on("host", sync);
868
+ hostTickSyncs.add(sync);
588
869
  sync();
589
870
  return () => {
590
871
  off();
872
+ hostTickSyncs.delete(sync);
591
873
  if (timer) clearInterval(timer);
592
874
  timer = null;
593
875
  };
@@ -602,6 +884,9 @@ function attachSession(client, initialRoom) {
602
884
  },
603
885
  leave() {
604
886
  intentionalLeave = true;
887
+ connected = false;
888
+ syncHostTicks();
889
+ settleAllControls("aborted");
605
890
  void room.leave();
606
891
  }
607
892
  };
@@ -668,7 +953,7 @@ function buildView(matchRoom, queueInfo) {
668
953
  };
669
954
  }
670
955
  async function matchmake(opts) {
671
- const client = new Client(opts.url);
956
+ const client = await relayClient(opts);
672
957
  const emitters = /* @__PURE__ */ new Map();
673
958
  const emit = (event, ...args) => emitters.get(event)?.forEach((cb) => cb(...args));
674
959
  const on = ((event, cb) => {
@@ -849,14 +1134,14 @@ function wirePrivate(client, room) {
849
1134
  };
850
1135
  }
851
1136
  async function createPrivate(opts) {
852
- const client = new Client(opts.url);
1137
+ const client = await relayClient(opts);
853
1138
  const room = await client.create(MATCH_ROOM_TYPE, matchJoinOptions(opts));
854
1139
  const handle = wirePrivate(client, room);
855
1140
  Object.defineProperty(handle, "code", { value: room.roomId, enumerable: true });
856
1141
  return handle;
857
1142
  }
858
1143
  async function joinPrivate(code, opts) {
859
- const client = new Client(opts.url);
1144
+ const client = await relayClient(opts);
860
1145
  const room = await client.joinById(code, matchJoinOptions(opts));
861
1146
  return wirePrivate(client, room);
862
1147
  }
package/dist/schema.d.ts CHANGED
@@ -4,9 +4,11 @@ import { SchemaType, MapSchema } from '@colyseus/schema';
4
4
  declare const PlayerState: _colyseus_schema.SchemaWithExtendsConstructor<{
5
5
  name: "string";
6
6
  data: "string";
7
+ epoch: "number";
7
8
  }, _colyseus_schema.AssignableProps<_colyseus_schema.InferSchemaInstanceType<{
8
9
  name: "string";
9
10
  data: "string";
11
+ epoch: "number";
10
12
  }>>, typeof _colyseus_schema.Schema>;
11
13
  type PlayerState = SchemaType<typeof PlayerState>;
12
14
  declare const RoomState: _colyseus_schema.SchemaWithExtendsConstructor<{
@@ -14,13 +16,16 @@ declare const RoomState: _colyseus_schema.SchemaWithExtendsConstructor<{
14
16
  map: _colyseus_schema.SchemaWithExtendsConstructor<{
15
17
  name: "string";
16
18
  data: "string";
19
+ epoch: "number";
17
20
  }, _colyseus_schema.AssignableProps<_colyseus_schema.InferSchemaInstanceType<{
18
21
  name: "string";
19
22
  data: "string";
23
+ epoch: "number";
20
24
  }>>, typeof _colyseus_schema.Schema>;
21
25
  default: MapSchema<_colyseus_schema.InferSchemaInstanceType<{
22
26
  name: "string";
23
27
  data: "string";
28
+ epoch: "number";
24
29
  }>, string>;
25
30
  };
26
31
  shared: {
@@ -32,13 +37,16 @@ declare const RoomState: _colyseus_schema.SchemaWithExtendsConstructor<{
32
37
  map: _colyseus_schema.SchemaWithExtendsConstructor<{
33
38
  name: "string";
34
39
  data: "string";
40
+ epoch: "number";
35
41
  }, _colyseus_schema.AssignableProps<_colyseus_schema.InferSchemaInstanceType<{
36
42
  name: "string";
37
43
  data: "string";
44
+ epoch: "number";
38
45
  }>>, typeof _colyseus_schema.Schema>;
39
46
  default: MapSchema<_colyseus_schema.InferSchemaInstanceType<{
40
47
  name: "string";
41
48
  data: "string";
49
+ epoch: "number";
42
50
  }>, string>;
43
51
  };
44
52
  shared: {
package/dist/schema.js CHANGED
@@ -3,7 +3,10 @@ import { schema, MapSchema } from "@colyseus/schema";
3
3
  var PlayerState = schema(
4
4
  {
5
5
  name: "string",
6
- data: "string"
6
+ data: "string",
7
+ // Relay-owned discontinuity epoch. Ordinary state updates keep it stable;
8
+ // state:snap increments it so new SDKs discard interpolation history.
9
+ epoch: "number"
7
10
  },
8
11
  "PlayerState"
9
12
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@genex-ai/multiplayer",
3
- "version": "0.8.8",
3
+ "version": "0.10.0",
4
4
  "description": "The multiplayer client wrapper + shared room schema for genex.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -30,8 +30,8 @@
30
30
  "access": "public"
31
31
  },
32
32
  "dependencies": {
33
- "@colyseus/schema": "^3.0.76",
34
- "colyseus.js": "^0.16.22"
33
+ "@colyseus/schema": "^4.0.27",
34
+ "@colyseus/sdk": "^0.17.43"
35
35
  },
36
36
  "peerDependencies": {
37
37
  "three": ">=0.160.0"