@genex-ai/multiplayer 0.8.1 → 0.8.3

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.
Files changed (3) hide show
  1. package/README.md +2 -2
  2. package/dist/index.js +102 -27
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -178,13 +178,13 @@ room.leave();
178
178
 
179
179
  ## Smoothing remote players
180
180
 
181
- 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 the live snapshot rate**, so it works whether you send at 5Hz or 30Hz.
181
+ 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.
182
182
 
183
183
  - `player.state` is the **smoothed** value (what you draw); `player.stateRaw` is the **latest raw** snapshot — use it for hit-tests / discrete logic.
184
184
  - **Your own player is never delayed** — you always render yourself live.
185
185
  - **Numbers** are interpolated; a 4-element `[x, y, z, w]` **quaternion** is **slerped** (shortest-arc); anything else snaps.
186
186
  - **Send rotation as a quaternion:** `state.q = mesh.quaternion.toArray()`, then on the remote `mesh.quaternion.fromArray(p.state.q)`. The package slerps it and **output-smooths** it so it glides through turns.
187
- - **Late/dropped packets keep moving:** **position** dead-reckons forward from its last velocity (capped ~0.25s, Source-style) until fresh snapshots arrive; **rotation** holds its last orientation rather than extrapolating (extrapolating a turn overshoots and snaps — Mirror/Unity interpolate rotation only). Both are output-smoothed, so the catch-up glides. This is what holds up on high-latency links.
187
+ - **Late/dropped packets keep moving:** **position** dead-reckons forward from its last velocity (capped ~0.25s, Source-style) until fresh snapshots arrive; **rotation** also continues the turn but on a **tighter cap** (~0.15s), so a remote's heading keeps moving through a brief gap instead of freezing and then snapping when the next packet lands — the short cap plus output-smoothing bound any overshoot. This is what holds up on high-latency links.
188
188
 
189
189
  > No client-side prediction/reconciliation: in this relay you're authoritative over **yourself** (you render instantly, the server never corrects you), so there's nothing to predict — smoothing *remote* players is the piece that matters.
190
190
 
package/dist/index.js CHANGED
@@ -4,10 +4,13 @@ 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
- var INTERP_MIN_MS = 50;
7
+ var INTERP_MIN_MS = 90;
8
8
  var INTERP_MAX_MS = 300;
9
+ var JITTER_SAFETY = 1.2;
10
+ var POISON_GUARD_MS = 2e3;
9
11
  var INTERP_EASE = 0.1;
10
12
  var MAX_EXTRAP_MS = 250;
13
+ var ROT_EXTRAP_MS = 150;
11
14
  var CLOCK_WIN = 80;
12
15
  var SMOOTH_TAU_POS = 30;
13
16
  var SMOOTH_TAU_ROT = 70;
@@ -43,6 +46,14 @@ function parseValue(s) {
43
46
  return void 0;
44
47
  }
45
48
  }
49
+ var finiteReplacer = (_k, v) => {
50
+ if (typeof v === "number") return Number.isFinite(v) ? v : 0;
51
+ if (typeof v === "bigint") return Number(v);
52
+ return v;
53
+ };
54
+ function sanitizeFinite(obj) {
55
+ return JSON.parse(JSON.stringify(obj, finiteReplacer));
56
+ }
46
57
  var nowMs = () => typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
47
58
  function slerp(a, b, t) {
48
59
  let [bx, by, bz, bw] = b;
@@ -71,6 +82,7 @@ function isQuat(v) {
71
82
  }
72
83
  function blend(a, b, tNum, tQuat) {
73
84
  const out = {};
85
+ const interpolating = tNum < 1;
74
86
  for (const k in b) {
75
87
  const av = a[k];
76
88
  const bv = b[k];
@@ -79,7 +91,7 @@ function blend(a, b, tNum, tQuat) {
79
91
  } else if (isQuat(av) && isQuat(bv)) {
80
92
  out[k] = slerp(av, bv, tQuat);
81
93
  } else {
82
- out[k] = bv;
94
+ out[k] = interpolating && k in a ? av : bv;
83
95
  }
84
96
  }
85
97
  return out;
@@ -111,9 +123,9 @@ function sampleBuffer(buf, renderTime) {
111
123
  const b2 = buf[n - 1];
112
124
  const span2 = b2.t - a2.t;
113
125
  if (span2 <= 0) return b2.state;
114
- const capped = Math.min(renderTime, b2.t + MAX_EXTRAP_MS);
115
- const t2 = (capped - a2.t) / span2;
116
- return blend(a2.state, b2.state, t2, 1);
126
+ const tPos = (Math.min(renderTime, b2.t + MAX_EXTRAP_MS) - a2.t) / span2;
127
+ const tRot = (Math.min(renderTime, b2.t + ROT_EXTRAP_MS) - a2.t) / span2;
128
+ return blend(a2.state, b2.state, tPos, tRot);
117
129
  }
118
130
  const a = buf[j - 1];
119
131
  const b = buf[j];
@@ -126,6 +138,12 @@ function median(xs) {
126
138
  const s = [...xs].sort((a, b) => a - b);
127
139
  return s[s.length >> 1];
128
140
  }
141
+ function quantile(xs, q) {
142
+ if (xs.length === 0) return 0;
143
+ const s = [...xs].sort((a, b) => a - b);
144
+ const i = Math.min(s.length - 1, Math.max(0, Math.round(q * (s.length - 1))));
145
+ return s[i];
146
+ }
129
147
  async function connect(opts) {
130
148
  const client = new Client(opts.url);
131
149
  const room = await client.joinOrCreate(RELAY_ROOM_TYPE, {
@@ -138,7 +156,21 @@ async function connect(opts) {
138
156
  });
139
157
  return attachSession(client, room);
140
158
  }
141
- var RESERVED_SEND_TYPES = /* @__PURE__ */ new Set(["state", "shared", "claim", "obj", "release", "destroy"]);
159
+ var RESERVED_SEND_TYPES = /* @__PURE__ */ new Set([
160
+ "state",
161
+ "shared",
162
+ "claim",
163
+ "obj",
164
+ "release",
165
+ "destroy",
166
+ // Server→client control verbs a game must never ORIGINATE — they'd broadcast to peers via the
167
+ // relay's `*` handler: 'server:restart' would trip every peer's save-flush, 'rate:drop' a bogus
168
+ // over-budget warning, 'queue'/'seat' spurious matchmaking control.
169
+ "server:restart",
170
+ "rate:drop",
171
+ "queue",
172
+ "seat"
173
+ ]);
142
174
  var isReservedSendType = (type) => RESERVED_SEND_TYPES.has(type) || type.startsWith("match:") || type.startsWith(RESERVED_PREFIX);
143
175
  var RECONNECT_DELAYS_MS = [500, 1e3, 2e3, 4e3, 8e3];
144
176
  var ROOM_REF = /* @__PURE__ */ Symbol("genex.roomRef");
@@ -158,8 +190,10 @@ function attachSession(client, initialRoom) {
158
190
  const objRaw = /* @__PURE__ */ new Map();
159
191
  const localObjState = /* @__PURE__ */ new Map();
160
192
  let hostId;
193
+ const testSkew = typeof globalThis !== "undefined" && globalThis.__GENEX_MP_TEST_SKEW__ || 0;
194
+ const now = testSkew ? () => nowMs() + testSkew : nowMs;
161
195
  const pushSnap = (id, state, sendTime) => {
162
- const arrival = nowMs();
196
+ const arrival = now();
163
197
  const t = sendTime ?? arrival;
164
198
  if (sendTime != null) {
165
199
  let win = offsetWin.get(id);
@@ -172,12 +206,17 @@ function attachSession(client, initialRoom) {
172
206
  lastStamp.set(id, t);
173
207
  if (prev != null) {
174
208
  const gap = t - prev;
175
- if (gap > 5 && gap < 1e3) {
209
+ if (gap > 5 && gap < 2e3) {
176
210
  let iv = intervalsById.get(id);
177
211
  if (!iv) intervalsById.set(id, iv = []);
178
212
  iv.push(gap);
179
213
  if (iv.length > 30) iv.shift();
180
- const target = Math.min(INTERP_MAX_MS, Math.max(INTERP_MIN_MS, INTERP_RATIO * median(iv)));
214
+ const ow = offsetWin.get(id);
215
+ const jitter = ow && ow.length >= 8 ? quantile(ow, 0.9) - Math.min(...ow) : 0;
216
+ const target = Math.min(
217
+ INTERP_MAX_MS,
218
+ Math.max(INTERP_MIN_MS, INTERP_RATIO * median(iv), JITTER_SAFETY * jitter)
219
+ );
181
220
  const cur = autoDelayById.get(id) ?? 100;
182
221
  autoDelayById.set(id, cur + (target - cur) * INTERP_EASE);
183
222
  }
@@ -187,6 +226,7 @@ function attachSession(client, initialRoom) {
187
226
  buf.push({ t, state });
188
227
  const cutoff = t - 1500;
189
228
  while (buf.length > 2 && buf[0].t < cutoff) buf.shift();
229
+ while (buf.length > 1 && buf[0].t > buf[buf.length - 1].t + POISON_GUARD_MS) buf.shift();
190
230
  };
191
231
  const sampleSmoothed = (streamKey, localNow) => {
192
232
  const buf = buffers.get(streamKey);
@@ -228,8 +268,10 @@ function attachSession(client, initialRoom) {
228
268
  "reconnecting",
229
269
  "reconnected",
230
270
  "disconnect",
231
- INPUT_MSG
271
+ INPUT_MSG,
232
272
  // inputs.on() has its own channel — never bridged as a custom event
273
+ "rate:drop"
274
+ // relay → us only (over-budget warning); handled internally, never bridged
233
275
  ]);
234
276
  const emit = (event, ...args) => emitters.get(event)?.forEach((cb) => cb(...args));
235
277
  const lastShared = /* @__PURE__ */ new Map();
@@ -254,7 +296,10 @@ function attachSession(client, initialRoom) {
254
296
  if (ownerChanged) {
255
297
  if (owner) objOwners.set(id, owner);
256
298
  else objOwners.delete(id);
257
- if (owner !== room.sessionId) localObjState.delete(id);
299
+ if (owner !== room.sessionId) {
300
+ localObjState.delete(id);
301
+ clearStream(key);
302
+ }
258
303
  }
259
304
  if (blob.d && typeof blob.d === "object" && Object.keys(blob.d).length > 0) {
260
305
  objRaw.set(id, blob.d);
@@ -276,12 +321,23 @@ function attachSession(client, initialRoom) {
276
321
  };
277
322
  let intentionalLeave = false;
278
323
  let dead = false;
324
+ let droppedWhileOffline = 0;
325
+ const send0 = (type, payload, countIfDropped = false) => {
326
+ if (room.connection?.isOpen) {
327
+ try {
328
+ room.send(type, payload);
329
+ return;
330
+ } catch {
331
+ }
332
+ }
333
+ if (countIfDropped) droppedWhileOffline++;
334
+ };
279
335
  const wireRoom = (r) => {
280
336
  const $ = getStateCallbacks(r);
281
337
  $(r.state).players.onAdd((player, id) => {
282
338
  const { state, sendTime } = parseData(player.data);
283
339
  emit("join", id, state);
284
- if (id !== room.sessionId) pushSnap(id, state, sendTime);
340
+ if (id !== room.sessionId && sendTime != null) pushSnap(id, state, sendTime);
285
341
  $(player).onChange(() => {
286
342
  const { state: st, sendTime: ts } = parseData(player.data);
287
343
  emit("change", id, st);
@@ -316,6 +372,11 @@ function attachSession(client, initialRoom) {
316
372
  r.onMessage(INPUT_MSG, (msg) => {
317
373
  if (msg && typeof msg.from === "string") emit(INPUT_MSG, msg.from, msg.d);
318
374
  });
375
+ r.onMessage("rate:drop", (msg) => {
376
+ console.warn(
377
+ `[multiplayer] the relay dropped ${msg?.dropped ?? "some"} of your messages in the last second \u2014 you are publishing over budget. Publish idle/unchanged objects at \u22642Hz keepalive, not every tick (see the multiplayer skill's "message budget" section).`
378
+ );
379
+ });
319
380
  for (const event of bridged) r.onMessage(event, (msg) => emit(event, msg));
320
381
  r.onLeave((code) => handleLeave(r, code ?? 1006));
321
382
  r.onError(() => {
@@ -332,18 +393,27 @@ function attachSession(client, initialRoom) {
332
393
  void attemptReconnect(left.reconnectionToken, code);
333
394
  };
334
395
  const attemptReconnect = async (token, code) => {
335
- for (let attempt = 1; attempt <= RECONNECT_DELAYS_MS.length; attempt++) {
396
+ for (let attempt = 1; attempt <= RECONNECT_DELAYS_MS.length + 1; attempt++) {
336
397
  emit("reconnecting", { attempt, code });
337
- await new Promise((r) => setTimeout(r, RECONNECT_DELAYS_MS[attempt - 1]));
338
398
  if (intentionalLeave || dead) return;
339
399
  try {
340
400
  const next = await client.reconnect(token);
341
401
  room = next;
342
402
  wireRoom(next);
343
403
  emit("reconnected");
404
+ if (droppedWhileOffline > 0) {
405
+ console.warn(
406
+ `[multiplayer] ${droppedWhileOffline} fire-and-forget event(s)/input(s) were dropped while reconnecting \u2014 they are not buffered; player/object state re-synced automatically.`
407
+ );
408
+ droppedWhileOffline = 0;
409
+ }
344
410
  return;
345
411
  } catch {
346
412
  }
413
+ if (attempt <= RECONNECT_DELAYS_MS.length) {
414
+ await new Promise((r) => setTimeout(r, RECONNECT_DELAYS_MS[attempt - 1]));
415
+ if (intentionalLeave || dead) return;
416
+ }
347
417
  }
348
418
  dead = true;
349
419
  emit("disconnect", code);
@@ -365,7 +435,7 @@ function attachSession(client, initialRoom) {
365
435
  id: room.sessionId,
366
436
  get players() {
367
437
  const out = /* @__PURE__ */ new Map();
368
- const localNow = nowMs();
438
+ const localNow = now();
369
439
  room.state?.players?.forEach((p, id) => {
370
440
  const latest = cachedParse(id, p.data);
371
441
  let state = latest;
@@ -384,8 +454,12 @@ function attachSession(client, initialRoom) {
384
454
  },
385
455
  me: {
386
456
  set(state) {
387
- localState = { ...state };
388
- room.send("state", JSON.stringify({ ...state, [SEND_TIME_KEY]: nowMs() }));
457
+ if (SEND_TIME_KEY in state) {
458
+ console.warn(`[multiplayer] '${SEND_TIME_KEY}' is a reserved field name and will be overwritten by the send timestamp \u2014 rename it.`);
459
+ }
460
+ const clean = sanitizeFinite(state);
461
+ localState = clean;
462
+ send0("state", JSON.stringify({ ...clean, [SEND_TIME_KEY]: now() }));
389
463
  }
390
464
  },
391
465
  shared: {
@@ -395,7 +469,7 @@ function attachSession(client, initialRoom) {
395
469
  },
396
470
  set(key, value) {
397
471
  if (key.startsWith(RESERVED_PREFIX)) return;
398
- room.send("shared", { key, value: JSON.stringify(value) });
472
+ send0("shared", { key, value: JSON.stringify(sanitizeFinite(value)) });
399
473
  },
400
474
  keys() {
401
475
  return room.state?.shared ? Array.from(room.state.shared.keys()).filter((k) => !k.startsWith(RESERVED_PREFIX)) : [];
@@ -405,13 +479,13 @@ function attachSession(client, initialRoom) {
405
479
  claim(id) {
406
480
  const key = String(id);
407
481
  objOwners.set(key, room.sessionId);
408
- room.send("claim", { id: key });
482
+ send0("claim", { id: key });
409
483
  },
410
484
  release(id) {
411
485
  const key = String(id);
412
486
  localObjState.delete(key);
413
487
  if (objOwners.get(key) === room.sessionId) objOwners.delete(key);
414
- room.send("release", { id: key });
488
+ send0("release", { id: key });
415
489
  },
416
490
  remove(id) {
417
491
  const key = String(id);
@@ -421,12 +495,13 @@ function attachSession(client, initialRoom) {
421
495
  objOwners.delete(key);
422
496
  clearStream(OBJ_PREFIX + key);
423
497
  }
424
- room.send("destroy", { id: key });
498
+ send0("destroy", { id: key });
425
499
  },
426
500
  set(id, state) {
427
501
  const key = String(id);
428
- if (objOwners.get(key) === room.sessionId) localObjState.set(key, { ...state });
429
- room.send("obj", { id: key, d: { ...state } });
502
+ const clean = sanitizeFinite(state);
503
+ if (objOwners.get(key) === room.sessionId) localObjState.set(key, clean);
504
+ send0("obj", { id: key, d: clean, t: now() });
430
505
  },
431
506
  get(id) {
432
507
  const key = String(id);
@@ -441,7 +516,7 @@ function attachSession(client, initialRoom) {
441
516
  raw = mine;
442
517
  } else {
443
518
  raw = objRaw.get(key) ?? {};
444
- state = sampleSmoothed(OBJ_PREFIX + key, nowMs()) ?? raw;
519
+ state = sampleSmoothed(OBJ_PREFIX + key, now()) ?? raw;
445
520
  }
446
521
  return { id: key, owner, isMine, state, stateRaw: raw };
447
522
  },
@@ -457,7 +532,7 @@ function attachSession(client, initialRoom) {
457
532
  },
458
533
  inputs: {
459
534
  send(payload) {
460
- room.send(INPUT_MSG, payload);
535
+ send0(INPUT_MSG, sanitizeFinite(payload), true);
461
536
  },
462
537
  on(cb) {
463
538
  let set = emitters.get(INPUT_MSG);
@@ -476,7 +551,7 @@ function attachSession(client, initialRoom) {
476
551
  last = nowMs();
477
552
  timer = setInterval(() => {
478
553
  const t = nowMs();
479
- cb(t - last);
554
+ cb(Math.min(t - last, 250));
480
555
  last = t;
481
556
  }, Math.max(10, Math.round(1e3 / hz)));
482
557
  } else if (!shouldRun && timer) {
@@ -498,7 +573,7 @@ function attachSession(client, initialRoom) {
498
573
  console.warn(`[multiplayer] send('${type}') collides with a relay-internal channel \u2014 use a different event name`);
499
574
  return;
500
575
  }
501
- room.send(type, payload);
576
+ send0(type, payload, true);
502
577
  },
503
578
  leave() {
504
579
  intentionalLeave = true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@genex-ai/multiplayer",
3
- "version": "0.8.1",
3
+ "version": "0.8.3",
4
4
  "description": "The multiplayer client wrapper + shared room schema for genex.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",