@genex-ai/multiplayer 0.8.7 → 0.9.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
@@ -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.
@@ -250,7 +299,8 @@ else renderSearchingHud(mm.matchmaking);
250
299
  mm.on("matched", () => {/* session is live */});
251
300
  mm.on("matchStart", ({ roundId }) => {/* round began */});
252
301
  mm.on("matchEnded", ({ winnerId, scores, draw }) => {/* result screen */});
253
- mm.on("spectate", () => {/* seated as a spectator (overflow) — read-only */});
302
+ mm.on("spectate", () => {/* DEPRECATED — never fires: there are no server-side spectators anymore.
303
+ An overflow searcher now waits in the queue instead of watching. */});
254
304
  mm.on("error", (e) => {/* queue join failed even after the SDK's automatic retries — persistent
255
305
  (broken auth / server unreachable); fix the cause, then mm.retry() */});
256
306
 
@@ -267,7 +317,7 @@ A failed queue join is **retried automatically** with backoff (~10s total), so t
267
317
  (a token mid-rotation, identity still settling on page load) never surface. Only a persistent
268
318
  failure emits `error`; the handle then idles until you call `mm.retry()`.
269
319
 
270
- **`mm.matchmaking`** (read every frame for your HUD): `status` (`searching` | `waiting` | `countdown` | `playing` | `ended` | `spectating`), `queue` (`{ position, size }`), `players`, `opponents`, `teams`, `myTeam`, `scores`, `winCondition`, `config` (numeric knobs), `roundId`, `winnerId`, `message`. `championId`/`challengerId` are 1v1 aliases.
320
+ **`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`, `opponents`, `teams`, `myTeam`, `scores`, `winCondition`, `config` (numeric knobs), `roundId`, `winnerId`, `message`. `championId`/`challengerId` are 1v1 aliases.
271
321
 
272
322
  Configure once in the game's `package.json` (reported at `genex preview` AND `genex publish`;
273
323
  removing it clears the stored config on the next preview/publish — never sent from the client):
@@ -275,14 +325,16 @@ removing it clears the stored config on the next preview/publish — never sent
275
325
  ```jsonc
276
326
  "genex": {
277
327
  "matchmaking": {
278
- "preset": "arena", // duel | arena | teams | private
328
+ "preset": "arena", // open | duel | arena | teams | private
279
329
  "winCondition": "firstToScore", // lastStanding | firstToScore | highScoreInTime | firstToFinish
280
330
  "config": { "scoreTarget": 20, "maxPlayers": 8 } // numeric knobs, optional
281
331
  }
282
332
  }
283
333
  ```
284
334
 
285
- Presets: **duel** (1v1 winner-stays), **arena** (N-player FFA, join-anytime), **teams** (balanced N-v-N), **private** (invite-code lobby). Win conditions: **lastStanding**, **firstToScore**, **highScoreInTime**, **firstToFinish**; a round that hits the time cap undecided is a draw.
335
+ 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.
336
+
337
+ **`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.
286
338
 
287
339
  ### Private lobbies — `createPrivate` / `joinPrivate`
288
340
 
package/dist/index.d.ts CHANGED
@@ -37,6 +37,8 @@ type PlayerView<TState> = {
37
37
  /** A shared object (a ball, a puck, an NPC) owned by exactly one client at a time. */
38
38
  type ObjectView<TState> = {
39
39
  id: string;
40
+ /** Relay-owned discontinuity epoch. Helpers direct-place physics when this changes. */
41
+ epoch: number;
40
42
  /** Session id of the current owner, or `undefined` if unowned. */
41
43
  owner: string | undefined;
42
44
  /** True if you own it (so you should simulate + `objects.set` it). */
@@ -47,6 +49,28 @@ type ObjectView<TState> = {
47
49
  /** The latest raw snapshot (never smoothed/delayed) — for hit-tests / logic. */
48
50
  stateRaw: TState;
49
51
  };
52
+ type ObjectControlOperation = 'claim' | 'release' | 'remove';
53
+ type ObjectControlReason = 'accepted' | 'already-owner' | 'held' | 'rate-limited' | 'not-owner' | 'capacity' | 'invalid' | 'missing' | 'host-authority' | 'not-host' | 'unsupported' | 'timeout' | 'aborted' | 'disconnected';
54
+ type ObjectControlResult = {
55
+ requestId: string;
56
+ operation: ObjectControlOperation;
57
+ id: string;
58
+ accepted: boolean;
59
+ owner?: string;
60
+ sequence: number;
61
+ reason: ObjectControlReason;
62
+ retryAfterMs?: number;
63
+ };
64
+ type ObjectControlOptions = {
65
+ /** Ordinary player claims are subject to the hold. Host authority is for seeding/reset only. */
66
+ authority?: 'player' | 'host';
67
+ /** Locally mark a claim as owned while awaiting the result. Default false. */
68
+ optimistic?: boolean;
69
+ /** Maximum wait including reconnect resend. Default 5000 ms. */
70
+ timeoutMs?: number;
71
+ /** Cancels the pending operation and resolves with reason `aborted`. */
72
+ signal?: AbortSignal;
73
+ };
50
74
  type Session<TState extends Record<string, unknown>> = {
51
75
  /** Your own session id. */
52
76
  readonly id: string;
@@ -62,6 +86,8 @@ type Session<TState extends Record<string, unknown>> = {
62
86
  * (~10–20Hz), not every render frame — each call sends one network message.
63
87
  */
64
88
  set(state: TState): void;
89
+ /** Publish a deliberate discontinuity (respawn/teleport/mode edge) and clear remote history. */
90
+ snap(state: TState): void;
65
91
  };
66
92
  readonly shared: {
67
93
  /** Read a shared value (any JSON), or `undefined`. */
@@ -79,14 +105,22 @@ type Session<TState extends Record<string, unknown>> = {
79
105
  * Ownership survives an owner leaving: the object is reassigned to the room host.
80
106
  */
81
107
  readonly objects: {
82
- /** Take ownership (last claim wins — call it when you kick/touch the object). */
108
+ /** Legacy optimistic ownership request. Prefer claimConfirmed for actions that need a result. */
83
109
  claim(id: string): void;
110
+ /** Request ownership and receive the relay's accepted/rejected result. */
111
+ claimConfirmed(id: string, options?: ObjectControlOptions): Promise<ObjectControlResult>;
84
112
  /** Give up ownership (leaves the object unowned until someone claims it). */
85
113
  release(id: string): void;
114
+ /** Give up ownership only after an explicit relay acknowledgement. */
115
+ releaseConfirmed(id: string, options?: ObjectControlOptions): Promise<ObjectControlResult>;
86
116
  /** Destroy the object everywhere (owner-only) — use for transient objects (bullets, pickups). */
87
117
  remove(id: string): void;
118
+ /** Destroy the object and receive an idempotent relay acknowledgement. */
119
+ removeConfirmed(id: string, options?: ObjectControlOptions): Promise<ObjectControlResult>;
88
120
  /** Publish the object's state — only takes effect while you own it. Full object each call. */
89
121
  set(id: string, state: Record<string, unknown>): void;
122
+ /** Owner-only deliberate discontinuity (reset/teleport), rendered as one hard reseed. */
123
+ snap(id: string, state: Record<string, unknown>): void;
90
124
  /** Read an object, or `undefined` if it doesn't exist yet. */
91
125
  get<T extends Record<string, unknown> = Record<string, unknown>>(id: string): ObjectView<T> | undefined;
92
126
  /** Ids of every object seen so far. */
@@ -276,4 +310,4 @@ declare function createPrivate<TState extends Record<string, unknown> = never>(o
276
310
  */
277
311
  declare function joinPrivate<TState extends Record<string, unknown> = never>(code: string, opts: MatchmakeOptions): Promise<Matchmaking<TState>>;
278
312
 
279
- export { type ConnectOptions, type MatchStatus, type MatchmakeOptions, type Matchmaking, type MatchmakingView, type ObjectView, type PlayerView, type PrivateMatch, type Session, connect, createPrivate, joinPrivate, matchmake };
313
+ 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
@@ -6,7 +6,7 @@ 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,13 @@ 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;
24
30
  function parseData(s) {
25
31
  if (!s) return { state: {} };
26
32
  try {
@@ -163,13 +169,18 @@ var RESERVED_SEND_TYPES = /* @__PURE__ */ new Set([
163
169
  "obj",
164
170
  "release",
165
171
  "destroy",
172
+ STATE_SNAP_MSG,
173
+ OBJECT_SNAP_MSG,
166
174
  // Server→client control verbs a game must never ORIGINATE — they'd broadcast to peers via the
167
175
  // relay's `*` handler: 'server:restart' would trip every peer's save-flush, 'rate:drop' a bogus
168
176
  // over-budget warning, 'queue'/'seat' spurious matchmaking control.
169
177
  "server:restart",
170
178
  "rate:drop",
171
179
  "queue",
172
- "seat"
180
+ "seat",
181
+ CAPABILITIES_MSG,
182
+ CAPABILITIES_REQUEST_MSG,
183
+ OBJECT_CONTROL_RESULT_MSG
173
184
  ]);
174
185
  var isReservedSendType = (type) => RESERVED_SEND_TYPES.has(type) || type.startsWith("match:") || type.startsWith(RESERVED_PREFIX);
175
186
  var RECONNECT_DELAYS_MS = [500, 1e3, 2e3, 4e3, 8e3];
@@ -187,10 +198,14 @@ function attachSession(client, initialRoom) {
187
198
  const autoDelayById = /* @__PURE__ */ new Map();
188
199
  let localState;
189
200
  const objOwners = /* @__PURE__ */ new Map();
201
+ const objEpochs = /* @__PURE__ */ new Map();
190
202
  const objRaw = /* @__PURE__ */ new Map();
191
203
  const localObjState = /* @__PURE__ */ new Map();
192
204
  const pendingBridge = /* @__PURE__ */ new Map();
193
205
  let hostId;
206
+ let objectControlVersion = 0;
207
+ let requestCounter = 0;
208
+ const pendingControls = /* @__PURE__ */ new Map();
194
209
  const testSkew = typeof globalThis !== "undefined" && globalThis.__GENEX_MP_TEST_SKEW__ || 0;
195
210
  const now = testSkew ? () => nowMs() + testSkew : nowMs;
196
211
  const pushSnap = (id, state, sendTime) => {
@@ -286,8 +301,13 @@ function attachSession(client, initialRoom) {
286
301
  "disconnect",
287
302
  INPUT_MSG,
288
303
  // inputs.on() has its own channel — never bridged as a custom event
289
- "rate:drop"
304
+ "rate:drop",
290
305
  // relay → us only (over-budget warning); handled internally, never bridged
306
+ CAPABILITIES_MSG,
307
+ CAPABILITIES_REQUEST_MSG,
308
+ OBJECT_CONTROL_RESULT_MSG,
309
+ STATE_SNAP_MSG,
310
+ OBJECT_SNAP_MSG
291
311
  ]);
292
312
  const emit = (event, ...args) => emitters.get(event)?.forEach((cb) => cb(...args));
293
313
  const lastShared = /* @__PURE__ */ new Map();
@@ -309,18 +329,26 @@ function attachSession(client, initialRoom) {
309
329
  if (!blob || typeof blob !== "object") return;
310
330
  const owner = typeof blob.o === "string" && blob.o ? blob.o : void 0;
311
331
  const ownerChanged = owner !== objOwners.get(id);
332
+ const epoch = typeof blob.e === "number" && Number.isFinite(blob.e) ? blob.e : 0;
333
+ const epochChanged = objEpochs.has(id) && epoch !== objEpochs.get(id);
334
+ objEpochs.set(id, epoch);
335
+ if (epochChanged) clearStream(key);
312
336
  if (ownerChanged) {
313
337
  if (owner) objOwners.set(id, owner);
314
338
  else objOwners.delete(id);
315
339
  if (owner !== room.sessionId) {
316
340
  localObjState.delete(id);
317
- clearStreamClock(key);
341
+ if (!epochChanged) clearStreamClock(key);
318
342
  }
319
343
  }
320
344
  if (blob.d && typeof blob.d === "object" && Object.keys(blob.d).length > 0) {
321
345
  objRaw.set(id, blob.d);
322
346
  if (owner !== room.sessionId) {
323
- if (ownerChanged) {
347
+ if (epochChanged) {
348
+ smoothed.set(key, blob.d);
349
+ smoothedAt.set(key, now());
350
+ pushSnap(key, blob.d, blob.t);
351
+ } else if (ownerChanged) {
324
352
  pendingBridge.set(key, smoothed.get(key) ?? blob.d);
325
353
  } else {
326
354
  pushSnap(key, blob.d, blob.t);
@@ -333,6 +361,7 @@ function attachSession(client, initialRoom) {
333
361
  emit("shared", key, parseValue(value));
334
362
  };
335
363
  const parseCache = /* @__PURE__ */ new Map();
364
+ const playerEpochs = /* @__PURE__ */ new Map();
336
365
  const cachedParse = (id, raw) => {
337
366
  if (!raw) return {};
338
367
  const hit = parseCache.get(id);
@@ -343,6 +372,145 @@ function attachSession(client, initialRoom) {
343
372
  };
344
373
  let intentionalLeave = false;
345
374
  let dead = false;
375
+ let connected = true;
376
+ let reconnectStartedAt = 0;
377
+ const hostTickSyncs = /* @__PURE__ */ new Set();
378
+ const syncHostTicks = () => hostTickSyncs.forEach((sync) => sync());
379
+ const settleControl = (pending, result) => {
380
+ pendingControls.delete(pending.requestId);
381
+ if (pending.timeout) clearTimeout(pending.timeout);
382
+ if (pending.capabilityTimer) clearTimeout(pending.capabilityTimer);
383
+ pending.abort?.();
384
+ if (pending.operation === "claim" && pending.optimistic && !result.accepted) {
385
+ if (result.owner) objOwners.set(pending.id, result.owner);
386
+ else objOwners.delete(pending.id);
387
+ if (result.owner !== room.sessionId) localObjState.delete(pending.id);
388
+ clearStreamClock(OBJ_PREFIX + pending.id);
389
+ }
390
+ if (pending.operation === "claim" && result.accepted) {
391
+ objOwners.set(pending.id, room.sessionId);
392
+ clearStreamClock(OBJ_PREFIX + pending.id);
393
+ }
394
+ if (pending.operation === "release" && result.accepted) {
395
+ localObjState.delete(pending.id);
396
+ if (objOwners.get(pending.id) === room.sessionId) objOwners.delete(pending.id);
397
+ clearStreamClock(OBJ_PREFIX + pending.id);
398
+ }
399
+ if (pending.operation === "remove" && result.accepted) {
400
+ localObjState.delete(pending.id);
401
+ objRaw.delete(pending.id);
402
+ objOwners.delete(pending.id);
403
+ clearStream(OBJ_PREFIX + pending.id);
404
+ }
405
+ pending.resolve(result);
406
+ };
407
+ const settleAllControls = (reason) => {
408
+ for (const pending of [...pendingControls.values()]) {
409
+ settleControl(pending, {
410
+ requestId: pending.requestId,
411
+ operation: pending.operation,
412
+ id: pending.id,
413
+ accepted: false,
414
+ owner: pending.optimistic ? pending.previousOwner : objOwners.get(pending.id),
415
+ sequence: 0,
416
+ reason
417
+ });
418
+ }
419
+ };
420
+ const sendPendingControl = (pending) => {
421
+ if (!connected || !room.connection?.isOpen) return;
422
+ send0(
423
+ pending.operation === "remove" ? "destroy" : pending.operation,
424
+ pending.payload
425
+ );
426
+ };
427
+ const flushCapabilityWaiters = () => {
428
+ if (objectControlVersion < OBJECT_CONTROL_VERSION) return;
429
+ for (const pending of pendingControls.values()) {
430
+ if (pending.capabilityTimer) {
431
+ clearTimeout(pending.capabilityTimer);
432
+ pending.capabilityTimer = null;
433
+ }
434
+ sendPendingControl(pending);
435
+ }
436
+ };
437
+ const confirmedControl = (operation, id, options = {}) => {
438
+ const key = String(id);
439
+ const requestId = `${room.sessionId.slice(0, 12)}:${Date.now().toString(36)}:${(++requestCounter).toString(36)}`;
440
+ const timeoutMs = Math.max(250, Math.min(options.timeoutMs ?? 5e3, 6e4));
441
+ return new Promise((resolve) => {
442
+ const pending = {
443
+ operation,
444
+ id: key,
445
+ requestId,
446
+ payload: {
447
+ id: key,
448
+ requestId,
449
+ ...options.authority === "host" ? { authority: "host" } : {}
450
+ },
451
+ optimistic: operation === "claim" && options.optimistic === true,
452
+ previousOwner: objOwners.get(key),
453
+ resolve,
454
+ timeout: null,
455
+ capabilityTimer: null,
456
+ abort: void 0
457
+ };
458
+ if (pending.optimistic) objOwners.set(key, room.sessionId);
459
+ pending.timeout = setTimeout(() => {
460
+ settleControl(pending, {
461
+ requestId,
462
+ operation,
463
+ id: key,
464
+ accepted: false,
465
+ owner: pending.optimistic ? pending.previousOwner : objOwners.get(key),
466
+ sequence: 0,
467
+ reason: "timeout"
468
+ });
469
+ }, timeoutMs);
470
+ if (options.signal) {
471
+ const onAbort = () => settleControl(pending, {
472
+ requestId,
473
+ operation,
474
+ id: key,
475
+ accepted: false,
476
+ owner: pending.optimistic ? pending.previousOwner : objOwners.get(key),
477
+ sequence: 0,
478
+ reason: "aborted"
479
+ });
480
+ options.signal.addEventListener("abort", onAbort, { once: true });
481
+ pending.abort = () => options.signal?.removeEventListener("abort", onAbort);
482
+ }
483
+ pendingControls.set(requestId, pending);
484
+ if (options.signal?.aborted) {
485
+ settleControl(pending, {
486
+ requestId,
487
+ operation,
488
+ id: key,
489
+ accepted: false,
490
+ owner: pending.optimistic ? pending.previousOwner : objOwners.get(key),
491
+ sequence: 0,
492
+ reason: "aborted"
493
+ });
494
+ return;
495
+ }
496
+ if (objectControlVersion >= OBJECT_CONTROL_VERSION) {
497
+ sendPendingControl(pending);
498
+ } else {
499
+ pending.capabilityTimer = setTimeout(() => {
500
+ if (!pendingControls.has(requestId) || objectControlVersion >= OBJECT_CONTROL_VERSION) return;
501
+ settleControl(pending, {
502
+ requestId,
503
+ operation,
504
+ id: key,
505
+ accepted: false,
506
+ owner: pending.optimistic ? pending.previousOwner : objOwners.get(key),
507
+ sequence: 0,
508
+ reason: "unsupported"
509
+ });
510
+ }, 500);
511
+ }
512
+ });
513
+ };
346
514
  let droppedWhileOffline = 0;
347
515
  const send0 = (type, payload, countIfDropped = false) => {
348
516
  if (room.connection?.isOpen) {
@@ -355,13 +523,22 @@ function attachSession(client, initialRoom) {
355
523
  if (countIfDropped) droppedWhileOffline++;
356
524
  };
357
525
  const wireRoom = (r) => {
526
+ let hydrated;
358
527
  const $ = getStateCallbacks(r);
359
528
  $(r.state).players.onAdd((player, id) => {
360
529
  const { state, sendTime } = parseData(player.data);
530
+ const addEpoch = Number.isFinite(player.epoch) ? player.epoch : 0;
531
+ const addEpochChanged = playerEpochs.has(id) && addEpoch !== playerEpochs.get(id);
532
+ playerEpochs.set(id, addEpoch);
533
+ if (id !== room.sessionId && addEpochChanged) clearStream(id);
361
534
  emit("join", id, state);
362
535
  if (id !== room.sessionId && sendTime != null) pushSnap(id, state, sendTime);
363
536
  $(player).onChange(() => {
364
537
  const { state: st, sendTime: ts } = parseData(player.data);
538
+ const epoch = Number.isFinite(player.epoch) ? player.epoch : 0;
539
+ const epochChanged = epoch !== (playerEpochs.get(id) ?? epoch);
540
+ playerEpochs.set(id, epoch);
541
+ if (id !== room.sessionId && epochChanged) clearStream(id);
365
542
  emit("change", id, st);
366
543
  if (id !== room.sessionId) pushSnap(id, st, ts);
367
544
  });
@@ -370,6 +547,7 @@ function attachSession(client, initialRoom) {
370
547
  emit("leave", id);
371
548
  clearStream(id);
372
549
  parseCache.delete(id);
550
+ playerEpochs.delete(id);
373
551
  });
374
552
  $(r.state).shared.onAdd(handleShared);
375
553
  $(r.state).shared.onChange(handleShared);
@@ -387,10 +565,58 @@ function attachSession(client, initialRoom) {
387
565
  clearStream(key);
388
566
  objRaw.delete(id);
389
567
  objOwners.delete(id);
568
+ objEpochs.delete(id);
390
569
  localObjState.delete(id);
391
570
  emit("object", id);
392
571
  }
393
572
  });
573
+ const hydrateShared = (state) => {
574
+ const shared = state?.shared;
575
+ shared?.forEach((value, key) => handleShared(value, key));
576
+ if (!shared) return;
577
+ for (const id of /* @__PURE__ */ new Set([...objOwners.keys(), ...objRaw.keys(), ...localObjState.keys()])) {
578
+ const key = OBJ_PREFIX + id;
579
+ if (shared.has(key)) continue;
580
+ lastShared.delete(key);
581
+ clearStream(key);
582
+ objRaw.delete(id);
583
+ objOwners.delete(id);
584
+ objEpochs.delete(id);
585
+ localObjState.delete(id);
586
+ emit("object", id);
587
+ }
588
+ if (hostId !== void 0 && !shared.has(HOST_KEY)) {
589
+ lastShared.delete(HOST_KEY);
590
+ hostId = void 0;
591
+ emit("host", void 0);
592
+ }
593
+ };
594
+ if (r.state?.shared) {
595
+ hydrateShared(r.state);
596
+ hydrated = Promise.resolve();
597
+ } else {
598
+ hydrated = new Promise((resolve) => {
599
+ r.onStateChange.once((state) => {
600
+ hydrateShared(state);
601
+ resolve();
602
+ });
603
+ });
604
+ }
605
+ r.onMessage(CAPABILITIES_MSG, (msg) => {
606
+ if (typeof msg?.objectControl === "number") {
607
+ objectControlVersion = Math.max(objectControlVersion, msg.objectControl);
608
+ flushCapabilityWaiters();
609
+ }
610
+ });
611
+ r.onMessage(CAPABILITIES_REQUEST_MSG, () => {
612
+ });
613
+ send0(CAPABILITIES_REQUEST_MSG, { objectControl: OBJECT_CONTROL_VERSION });
614
+ r.onMessage(OBJECT_CONTROL_RESULT_MSG, (msg) => {
615
+ if (!msg || typeof msg.requestId !== "string") return;
616
+ const pending = pendingControls.get(msg.requestId);
617
+ if (!pending || msg.operation !== pending.operation || msg.id !== pending.id) return;
618
+ settleControl(pending, msg);
619
+ });
394
620
  r.onMessage(INPUT_MSG, (msg) => {
395
621
  if (msg && typeof msg.from === "string") emit(INPUT_MSG, msg.from, msg.d);
396
622
  });
@@ -403,12 +629,17 @@ function attachSession(client, initialRoom) {
403
629
  r.onLeave((code) => handleLeave(r, code ?? 1006));
404
630
  r.onError(() => {
405
631
  });
632
+ return hydrated;
406
633
  };
407
634
  const handleLeave = (left, code) => {
408
635
  if (left !== room || dead) return;
636
+ connected = false;
637
+ reconnectStartedAt ||= nowMs();
638
+ syncHostTicks();
409
639
  if (intentionalLeave) return;
410
640
  if (code === 1e3 || code >= 4e3) {
411
641
  dead = true;
642
+ settleAllControls("disconnected");
412
643
  emit("disconnect", code);
413
644
  return;
414
645
  }
@@ -421,7 +652,17 @@ function attachSession(client, initialRoom) {
421
652
  try {
422
653
  const next = await client.reconnect(token);
423
654
  room = next;
424
- wireRoom(next);
655
+ connected = true;
656
+ const reconnectGap = reconnectStartedAt ? nowMs() - reconnectStartedAt : 0;
657
+ reconnectStartedAt = 0;
658
+ if (reconnectGap > 1e3) {
659
+ for (const streamKey of [...buffers.keys()]) clearStream(streamKey);
660
+ }
661
+ const hydrated = wireRoom(next);
662
+ await Promise.race([hydrated, new Promise((r) => setTimeout(r, 2e3))]);
663
+ if (room !== next || dead || intentionalLeave) return;
664
+ syncHostTicks();
665
+ for (const pending of pendingControls.values()) sendPendingControl(pending);
425
666
  emit("reconnected");
426
667
  if (droppedWhileOffline > 0) {
427
668
  console.warn(
@@ -438,6 +679,7 @@ function attachSession(client, initialRoom) {
438
679
  }
439
680
  }
440
681
  dead = true;
682
+ settleAllControls("disconnected");
441
683
  emit("disconnect", code);
442
684
  };
443
685
  wireRoom(room);
@@ -482,6 +724,14 @@ function attachSession(client, initialRoom) {
482
724
  const clean = sanitizeFinite(state);
483
725
  localState = clean;
484
726
  send0("state", JSON.stringify({ ...clean, [SEND_TIME_KEY]: now() }));
727
+ },
728
+ snap(state) {
729
+ if (SEND_TIME_KEY in state) {
730
+ console.warn(`[multiplayer] '${SEND_TIME_KEY}' is a reserved field name and will be overwritten by the send timestamp \u2014 rename it.`);
731
+ }
732
+ const clean = sanitizeFinite(state);
733
+ localState = clean;
734
+ send0(STATE_SNAP_MSG, JSON.stringify({ ...clean, [SEND_TIME_KEY]: now() }));
485
735
  }
486
736
  },
487
737
  shared: {
@@ -503,6 +753,9 @@ function attachSession(client, initialRoom) {
503
753
  objOwners.set(key, room.sessionId);
504
754
  send0("claim", { id: key });
505
755
  },
756
+ claimConfirmed(id, options) {
757
+ return confirmedControl("claim", id, options);
758
+ },
506
759
  release(id) {
507
760
  const key = String(id);
508
761
  localObjState.delete(key);
@@ -510,6 +763,9 @@ function attachSession(client, initialRoom) {
510
763
  clearStreamClock(OBJ_PREFIX + key);
511
764
  send0("release", { id: key });
512
765
  },
766
+ releaseConfirmed(id, options) {
767
+ return confirmedControl("release", id, options);
768
+ },
513
769
  remove(id) {
514
770
  const key = String(id);
515
771
  if (objOwners.get(key) === room.sessionId) {
@@ -520,12 +776,21 @@ function attachSession(client, initialRoom) {
520
776
  }
521
777
  send0("destroy", { id: key });
522
778
  },
779
+ removeConfirmed(id, options) {
780
+ return confirmedControl("remove", id, options);
781
+ },
523
782
  set(id, state) {
524
783
  const key = String(id);
525
784
  const clean = sanitizeFinite(state);
526
785
  if (objOwners.get(key) === room.sessionId) localObjState.set(key, clean);
527
786
  send0("obj", { id: key, d: clean, t: now() });
528
787
  },
788
+ snap(id, state) {
789
+ const key = String(id);
790
+ const clean = sanitizeFinite(state);
791
+ if (objOwners.get(key) === room.sessionId) localObjState.set(key, clean);
792
+ send0(OBJECT_SNAP_MSG, { id: key, d: clean, t: now() });
793
+ },
529
794
  get(id) {
530
795
  const key = String(id);
531
796
  const owner = objOwners.get(key);
@@ -543,7 +808,14 @@ function attachSession(client, initialRoom) {
543
808
  raw = objRaw.get(key) ?? {};
544
809
  state = sampleSmoothed(OBJ_PREFIX + key, now()) ?? raw;
545
810
  }
546
- return { id: key, owner, isMine, state, stateRaw: raw };
811
+ return {
812
+ id: key,
813
+ epoch: objEpochs.get(key) ?? 0,
814
+ owner,
815
+ isMine,
816
+ state,
817
+ stateRaw: raw
818
+ };
547
819
  },
548
820
  ids() {
549
821
  return [.../* @__PURE__ */ new Set([...objOwners.keys(), ...objRaw.keys(), ...localObjState.keys()])];
@@ -570,24 +842,28 @@ function attachSession(client, initialRoom) {
570
842
  onHostTick(hz, cb) {
571
843
  let timer = null;
572
844
  let last = 0;
845
+ const safeHz = Number.isFinite(hz) && hz > 0 ? Math.min(hz, 100) : 20;
846
+ const intervalMs = Math.max(10, Math.round(1e3 / safeHz));
573
847
  const sync = () => {
574
- const shouldRun = hostId !== void 0 && hostId === room.sessionId;
848
+ const shouldRun = connected && !intentionalLeave && !dead && hostId !== void 0 && hostId === room.sessionId;
575
849
  if (shouldRun && !timer) {
576
850
  last = nowMs();
577
851
  timer = setInterval(() => {
578
852
  const t = nowMs();
579
853
  cb(Math.min(t - last, 250));
580
854
  last = t;
581
- }, Math.max(10, Math.round(1e3 / hz)));
855
+ }, intervalMs);
582
856
  } else if (!shouldRun && timer) {
583
857
  clearInterval(timer);
584
858
  timer = null;
585
859
  }
586
860
  };
587
861
  const off = on("host", sync);
862
+ hostTickSyncs.add(sync);
588
863
  sync();
589
864
  return () => {
590
865
  off();
866
+ hostTickSyncs.delete(sync);
591
867
  if (timer) clearInterval(timer);
592
868
  timer = null;
593
869
  };
@@ -602,6 +878,9 @@ function attachSession(client, initialRoom) {
602
878
  },
603
879
  leave() {
604
880
  intentionalLeave = true;
881
+ connected = false;
882
+ syncHostTicks();
883
+ settleAllControls("aborted");
605
884
  void room.leave();
606
885
  }
607
886
  };
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.7",
3
+ "version": "0.9.0",
4
4
  "description": "The multiplayer client wrapper + shared room schema for genex.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",