@genex-ai/multiplayer 0.7.1 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -114,10 +114,52 @@ room.on('shared', (key, value) => {}); // a shared key changed
114
114
  room.on('object', (id) => {}); // an object's owner changed (handoff)
115
115
  room.on('host', (id) => {}); // the room host changed
116
116
  room.on('hit', (payload) => {}); // a custom event someone sent (see `send`)
117
+
118
+ room.on('reconnecting', ({ attempt }) => {}); // connection dropped — auto-reconnect underway
119
+ room.on('reconnected', () => {}); // back in the SAME seat: id, objects, host intact
120
+ room.on('disconnect', (code) => {}); // terminal — connect() fresh to play again
117
121
  ```
118
122
 
119
123
  `on(...)` returns an unsubscribe function.
120
124
 
125
+ ## Reconnection (automatic)
126
+
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.
135
+
136
+ **One seat per player:** the relay enforces a single session per identity per room — a
137
+ second tab / device / reloaded page supersedes the previous session (old one gets
138
+ `disconnect`, code 4409), so the same player never appears twice and reloads never leave
139
+ a ghost. Reload/close sockets ("going away") get only a ~5s seat-hold; genuine network
140
+ drops keep the full reconnection grace.
141
+
142
+ ## `inputs` + `onHostTick` — host-authoritative physics (contested objects)
143
+
144
+ Claim-on-touch is right for one-touch objects (kick a ball). For **sustained contact**
145
+ (two players pushing one crate) let ONE simulation own the contest: everyone sends inputs —
146
+ the relay routes them to the **current host only** — and the host applies them on a fixed
147
+ tick and publishes results via `objects` (auto-smoothed for everyone):
148
+
149
+ ```ts
150
+ if (pushing) room.inputs.send({ obj: 'crate', push: dir }); // any client → host only
151
+
152
+ room.inputs.on((fromId, payload) => queue.push(payload)); // fires only while YOU host
153
+ room.onHostTick(30, (dtMs) => { // runs only on the host,
154
+ // adopt objects + seed from stateRaw on first tick, apply // auto-migrates with the
155
+ // queued inputs to YOUR one physics world, then: // host election
156
+ room.objects.set('crate', { x, y, z, q });
157
+ });
158
+ ```
159
+
160
+ `onHostTick` auto-starts on election and auto-stops on demotion; a new host seeds its world
161
+ from each object's `stateRaw`, so the simulation survives host migration without a snap.
162
+
121
163
  ## `send` — custom one-off events
122
164
 
123
165
  Fire-and-forget to all **other** clients (not stored in state). For shots, emotes, chat, pings.
package/dist/index.d.ts CHANGED
@@ -99,21 +99,59 @@ type Session<TState extends Record<string, unknown>> = {
99
99
  readonly isHost: boolean;
100
100
  /** Session id of the current room host, or `undefined` until the first host patch lands. */
101
101
  readonly host: string | undefined;
102
+ /**
103
+ * Input channel for host-authoritative patterns (contested physics, host-simulated
104
+ * NPCs): anyone `send`s an input; the server delivers it ONLY to the current host
105
+ * (never broadcast). The host applies inputs in its one authoritative simulation and
106
+ * publishes results via `objects` — everyone renders them auto-smoothed.
107
+ */
108
+ readonly inputs: {
109
+ /** Send an input/impulse to the current host (single receiver, cheap). */
110
+ send(payload: unknown): void;
111
+ /** Receive inputs — only ever fires while YOU are the host. Returns unsubscribe. */
112
+ on(cb: (fromId: string, payload: unknown) => void): () => void;
113
+ };
114
+ /**
115
+ * Run `cb(dtMs)` on a fixed tick ONLY while this client is the host — auto-starts on
116
+ * election and auto-stops on demotion/leave, so host-simulation code needs no "am I
117
+ * host this frame?" bookkeeping. Returns a disposer.
118
+ */
119
+ onHostTick(hz: number, cb: (dtMs: number) => void): () => void;
102
120
  /** A player appeared/updated. **Also fires for you** on connect — filter `id === room.id`. */
103
121
  on(event: 'join' | 'change', cb: (id: string, state: TState) => void): () => void;
104
122
  /** A player left. */
105
123
  on(event: 'leave', cb: (id: string) => void): () => void;
106
124
  /** A shared key changed (reserved `__*` keys are hidden). For live values just read `shared.get`. */
107
125
  on(event: 'shared', cb: (key: string, value: unknown) => void): () => void;
108
- /** An object's owner or state changed (fires with its id). */
126
+ /** An object's OWNERSHIP changed (claim/handoff), or it appeared/was destroyed — fires
127
+ * with its id. NOT per-tick state: poll `objects.get(id)` in your loop for state. */
109
128
  on(event: 'object', cb: (id: string) => void): () => void;
110
129
  /** The room host changed (fires with the new host's id, or `undefined`). */
111
130
  on(event: 'host', cb: (host: string | undefined) => void): () => void;
131
+ /**
132
+ * The connection dropped unexpectedly and auto-reconnect is underway (attempts run
133
+ * ~0.5s→8s backoff inside the server's grace window). Show a "reconnecting…" overlay;
134
+ * keep your loop running — the world resumes seamlessly on `reconnected`.
135
+ */
136
+ on(event: 'reconnecting', cb: (info: {
137
+ attempt: number;
138
+ code: number;
139
+ }) => void): () => void;
140
+ /** Auto-reconnect succeeded: same session id, same seat — nothing was torn down. */
141
+ on(event: 'reconnected', cb: () => void): () => void;
142
+ /**
143
+ * The connection is gone for good: the server closed it on purpose (restart, revoked
144
+ * session, match eviction) or every reconnect attempt failed. The session is dead —
145
+ * to play again, read a FRESH `getColyseusAuth()` and `connect()` anew.
146
+ */
147
+ on(event: 'disconnect', cb: (code: number) => void): () => void;
112
148
  /** A custom event another client sent via `send(type, ...)`. */
113
149
  on(event: string & {}, cb: (payload: unknown) => void): () => void;
114
- /** Fire-and-forget event to all OTHER clients (not stored in state). */
150
+ /** Fire-and-forget event to all OTHER clients (not stored in state). Event names used
151
+ * by the relay itself (`state`, `shared`, `claim`, `obj`, `release`, `destroy`, and the
152
+ * `match:`/`__` prefixes) are refused with a console warning — pick another name. */
115
153
  send(type: string, payload?: unknown): void;
116
- /** Leave the room. */
154
+ /** Leave the room (deliberate — no auto-reconnect, no `disconnect` event). */
117
155
  leave(): void;
118
156
  };
119
157
  /**
package/dist/index.js CHANGED
@@ -16,6 +16,7 @@ var RESERVED_PREFIX = "__";
16
16
  var OBJ_PREFIX = "__obj:";
17
17
  var HOST_KEY = "__host";
18
18
  var MATCH_KEY = "__match";
19
+ var INPUT_MSG = "__input";
19
20
  var MP_PROTOCOL = 1;
20
21
  function parseData(s) {
21
22
  if (!s) return { state: {} };
@@ -135,9 +136,15 @@ async function connect(opts) {
135
136
  __v: MP_PROTOCOL
136
137
  // tells the relay this client hides reserved `__` keys (see MP_PROTOCOL)
137
138
  });
138
- return attachSession(room);
139
+ return attachSession(client, room);
139
140
  }
140
- function attachSession(room) {
141
+ var RESERVED_SEND_TYPES = /* @__PURE__ */ new Set(["state", "shared", "claim", "obj", "release", "destroy"]);
142
+ var isReservedSendType = (type) => RESERVED_SEND_TYPES.has(type) || type.startsWith("match:") || type.startsWith(RESERVED_PREFIX);
143
+ var RECONNECT_DELAYS_MS = [500, 1e3, 2e3, 4e3, 8e3];
144
+ var ROOM_REF = /* @__PURE__ */ Symbol("genex.roomRef");
145
+ var roomOf = (s) => s[ROOM_REF]();
146
+ function attachSession(client, initialRoom) {
147
+ let room = initialRoom;
141
148
  const buffers = /* @__PURE__ */ new Map();
142
149
  const lastStamp = /* @__PURE__ */ new Map();
143
150
  const clockOffsets = /* @__PURE__ */ new Map();
@@ -211,23 +218,20 @@ function attachSession(room) {
211
218
  };
212
219
  const emitters = /* @__PURE__ */ new Map();
213
220
  const bridged = /* @__PURE__ */ new Set();
214
- const reserved = /* @__PURE__ */ new Set(["join", "leave", "change", "shared", "object", "host"]);
221
+ const reserved = /* @__PURE__ */ new Set([
222
+ "join",
223
+ "leave",
224
+ "change",
225
+ "shared",
226
+ "object",
227
+ "host",
228
+ "reconnecting",
229
+ "reconnected",
230
+ "disconnect",
231
+ INPUT_MSG
232
+ // inputs.on() has its own channel — never bridged as a custom event
233
+ ]);
215
234
  const emit = (event, ...args) => emitters.get(event)?.forEach((cb) => cb(...args));
216
- const $ = getStateCallbacks(room);
217
- $(room.state).players.onAdd((player, id) => {
218
- const { state, sendTime } = parseData(player.data);
219
- emit("join", id, state);
220
- if (id !== room.sessionId) pushSnap(id, state, sendTime);
221
- $(player).onChange(() => {
222
- const { state: st, sendTime: ts } = parseData(player.data);
223
- emit("change", id, st);
224
- if (id !== room.sessionId) pushSnap(id, st, ts);
225
- });
226
- });
227
- $(room.state).players.onRemove((_player, id) => {
228
- emit("leave", id);
229
- clearStream(id);
230
- });
231
235
  const lastShared = /* @__PURE__ */ new Map();
232
236
  const handleShared = (value, key) => {
233
237
  if (lastShared.get(key) === value) return;
@@ -261,26 +265,90 @@ function attachSession(room) {
261
265
  }
262
266
  emit("shared", key, parseValue(value));
263
267
  };
264
- $(room.state).shared.onAdd(handleShared);
265
- $(room.state).shared.onChange(handleShared);
266
- $(room.state).shared.onRemove((_value, key) => {
267
- lastShared.delete(key);
268
- if (key === HOST_KEY) {
269
- if (hostId !== void 0) {
270
- hostId = void 0;
271
- emit("host", void 0);
268
+ const parseCache = /* @__PURE__ */ new Map();
269
+ const cachedParse = (id, raw) => {
270
+ if (!raw) return {};
271
+ const hit = parseCache.get(id);
272
+ if (hit && hit.raw === raw) return hit.state;
273
+ const { state } = parseData(raw);
274
+ parseCache.set(id, { raw, state });
275
+ return state;
276
+ };
277
+ let intentionalLeave = false;
278
+ let dead = false;
279
+ const wireRoom = (r) => {
280
+ const $ = getStateCallbacks(r);
281
+ $(r.state).players.onAdd((player, id) => {
282
+ const { state, sendTime } = parseData(player.data);
283
+ emit("join", id, state);
284
+ if (id !== room.sessionId) pushSnap(id, state, sendTime);
285
+ $(player).onChange(() => {
286
+ const { state: st, sendTime: ts } = parseData(player.data);
287
+ emit("change", id, st);
288
+ if (id !== room.sessionId) pushSnap(id, st, ts);
289
+ });
290
+ });
291
+ $(r.state).players.onRemove((_player, id) => {
292
+ emit("leave", id);
293
+ clearStream(id);
294
+ parseCache.delete(id);
295
+ });
296
+ $(r.state).shared.onAdd(handleShared);
297
+ $(r.state).shared.onChange(handleShared);
298
+ $(r.state).shared.onRemove((_value, key) => {
299
+ lastShared.delete(key);
300
+ if (key === HOST_KEY) {
301
+ if (hostId !== void 0) {
302
+ hostId = void 0;
303
+ emit("host", void 0);
304
+ }
305
+ return;
306
+ }
307
+ if (key.startsWith(OBJ_PREFIX)) {
308
+ const id = key.slice(OBJ_PREFIX.length);
309
+ clearStream(key);
310
+ objRaw.delete(id);
311
+ objOwners.delete(id);
312
+ localObjState.delete(id);
313
+ emit("object", id);
272
314
  }
315
+ });
316
+ r.onMessage(INPUT_MSG, (msg) => {
317
+ if (msg && typeof msg.from === "string") emit(INPUT_MSG, msg.from, msg.d);
318
+ });
319
+ for (const event of bridged) r.onMessage(event, (msg) => emit(event, msg));
320
+ r.onLeave((code) => handleLeave(r, code ?? 1006));
321
+ r.onError(() => {
322
+ });
323
+ };
324
+ const handleLeave = (left, code) => {
325
+ if (left !== room || dead) return;
326
+ if (intentionalLeave) return;
327
+ if (code === 1e3 || code >= 4e3) {
328
+ dead = true;
329
+ emit("disconnect", code);
273
330
  return;
274
331
  }
275
- if (key.startsWith(OBJ_PREFIX)) {
276
- const id = key.slice(OBJ_PREFIX.length);
277
- clearStream(key);
278
- objRaw.delete(id);
279
- objOwners.delete(id);
280
- localObjState.delete(id);
281
- emit("object", id);
332
+ void attemptReconnect(left.reconnectionToken, code);
333
+ };
334
+ const attemptReconnect = async (token, code) => {
335
+ for (let attempt = 1; attempt <= RECONNECT_DELAYS_MS.length; attempt++) {
336
+ emit("reconnecting", { attempt, code });
337
+ await new Promise((r) => setTimeout(r, RECONNECT_DELAYS_MS[attempt - 1]));
338
+ if (intentionalLeave || dead) return;
339
+ try {
340
+ const next = await client.reconnect(token);
341
+ room = next;
342
+ wireRoom(next);
343
+ emit("reconnected");
344
+ return;
345
+ } catch {
346
+ }
282
347
  }
283
- });
348
+ dead = true;
349
+ emit("disconnect", code);
350
+ };
351
+ wireRoom(room);
284
352
  const on = ((event, cb) => {
285
353
  let set = emitters.get(event);
286
354
  if (!set) emitters.set(event, set = /* @__PURE__ */ new Set());
@@ -293,13 +361,13 @@ function attachSession(room) {
293
361
  set?.delete(cb);
294
362
  };
295
363
  });
296
- return {
364
+ const session = {
297
365
  id: room.sessionId,
298
366
  get players() {
299
367
  const out = /* @__PURE__ */ new Map();
300
368
  const localNow = nowMs();
301
369
  room.state?.players?.forEach((p, id) => {
302
- const { state: latest } = parseData(p.data);
370
+ const latest = cachedParse(id, p.data);
303
371
  let state = latest;
304
372
  let raw = latest;
305
373
  if (id === room.sessionId) {
@@ -387,14 +455,58 @@ function attachSession(room) {
387
455
  get host() {
388
456
  return hostId;
389
457
  },
458
+ inputs: {
459
+ send(payload) {
460
+ room.send(INPUT_MSG, payload);
461
+ },
462
+ on(cb) {
463
+ let set = emitters.get(INPUT_MSG);
464
+ if (!set) emitters.set(INPUT_MSG, set = /* @__PURE__ */ new Set());
465
+ const listener = (from, payload) => cb(from, payload);
466
+ set.add(listener);
467
+ return () => void set?.delete(listener);
468
+ }
469
+ },
470
+ onHostTick(hz, cb) {
471
+ let timer = null;
472
+ let last = 0;
473
+ const sync = () => {
474
+ const shouldRun = hostId !== void 0 && hostId === room.sessionId;
475
+ if (shouldRun && !timer) {
476
+ last = nowMs();
477
+ timer = setInterval(() => {
478
+ const t = nowMs();
479
+ cb(t - last);
480
+ last = t;
481
+ }, Math.max(10, Math.round(1e3 / hz)));
482
+ } else if (!shouldRun && timer) {
483
+ clearInterval(timer);
484
+ timer = null;
485
+ }
486
+ };
487
+ const off = on("host", sync);
488
+ sync();
489
+ return () => {
490
+ off();
491
+ if (timer) clearInterval(timer);
492
+ timer = null;
493
+ };
494
+ },
390
495
  on,
391
496
  send(type, payload) {
497
+ if (isReservedSendType(type)) {
498
+ console.warn(`[multiplayer] send('${type}') collides with a relay-internal channel \u2014 use a different event name`);
499
+ return;
500
+ }
392
501
  room.send(type, payload);
393
502
  },
394
503
  leave() {
504
+ intentionalLeave = true;
395
505
  void room.leave();
396
506
  }
397
507
  };
508
+ Object.defineProperty(session, ROOM_REF, { value: () => room, enumerable: false });
509
+ return session;
398
510
  }
399
511
  var SEARCH_BACKOFF_MS = [1e3, 2e3, 4e3];
400
512
  var resolveAuth = (auth) => typeof auth === "function" ? auth() : auth;
@@ -510,6 +622,18 @@ async function matchmake(opts) {
510
622
  "seat",
511
623
  (msg) => void consumeSeat(msg.reservation, msg.role)
512
624
  );
625
+ q.onLeave(() => {
626
+ if (cancelled || queueRoom !== q) return;
627
+ queueRoom = null;
628
+ void (async () => {
629
+ for (const delayMs of RECONNECT_DELAYS_MS) {
630
+ await new Promise((r) => setTimeout(r, delayMs));
631
+ if (cancelled || queueRoom || session) return;
632
+ await search();
633
+ if (queueRoom || session) return;
634
+ }
635
+ })();
636
+ });
513
637
  emit("searching");
514
638
  return;
515
639
  }
@@ -525,10 +649,14 @@ async function matchmake(opts) {
525
649
  return;
526
650
  }
527
651
  matchRoom = mr;
528
- session = attachSession(mr);
529
- mr.onMessage("match:start", (m) => emit("matchStart", m));
530
- mr.onMessage("match:ended", (m) => emit("matchEnded", m));
531
- mr.onMessage("match:requeue", () => void requeue());
652
+ const s = attachSession(client, mr);
653
+ session = s;
654
+ s.on("match:start", (m) => emit("matchStart", m));
655
+ s.on("match:ended", (m) => emit("matchEnded", m));
656
+ s.on("match:requeue", () => void requeue());
657
+ s.on("disconnect", () => {
658
+ if (!cancelled && session === s) void requeue();
659
+ });
532
660
  if (queueRoom) {
533
661
  void queueRoom.leave();
534
662
  queueRoom = null;
@@ -540,30 +668,29 @@ async function matchmake(opts) {
540
668
  }
541
669
  };
542
670
  const requeue = async () => {
543
- if (matchRoom) {
544
- void matchRoom.leave();
545
- matchRoom = null;
546
- }
671
+ session?.leave();
672
+ matchRoom = null;
547
673
  session = null;
548
674
  await search();
549
675
  };
550
676
  void search();
677
+ const liveRoom = () => session ? roomOf(session) : null;
551
678
  return {
552
679
  get session() {
553
680
  return session;
554
681
  },
555
682
  get matchmaking() {
556
- return buildView(matchRoom, queueInfo);
683
+ return buildView(liveRoom(), queueInfo);
557
684
  },
558
685
  on,
559
686
  eliminated() {
560
- matchRoom?.send("match:eliminated", {});
687
+ liveRoom()?.send("match:eliminated", {});
561
688
  },
562
689
  score(delta = 1) {
563
- matchRoom?.send("match:score", { delta });
690
+ liveRoom()?.send("match:score", { delta });
564
691
  },
565
692
  finish() {
566
- matchRoom?.send("match:finish", {});
693
+ liveRoom()?.send("match:finish", {});
567
694
  },
568
695
  retry() {
569
696
  if (cancelled || searching || queueRoom || matchRoom) return;
@@ -571,7 +698,8 @@ async function matchmake(opts) {
571
698
  },
572
699
  cancel() {
573
700
  cancelled = true;
574
- if (matchRoom) void matchRoom.leave();
701
+ session?.leave();
702
+ if (!session && matchRoom) void matchRoom.leave();
575
703
  if (queueRoom) void queueRoom.leave();
576
704
  matchRoom = null;
577
705
  queueRoom = null;
@@ -588,35 +716,35 @@ function wirePrivate(client, room) {
588
716
  set.add(cb);
589
717
  return () => void set?.delete(cb);
590
718
  });
591
- let matchRoom = room;
592
- const session = attachSession(room);
593
- room.onMessage("match:start", (m) => emit("matchStart", m));
594
- room.onMessage("match:ended", (m) => emit("matchEnded", m));
595
- room.onMessage("match:requeue", () => void (matchRoom = null));
596
- room.onLeave(() => void (matchRoom = null));
719
+ let live = true;
720
+ const session = attachSession(client, room);
721
+ session.on("match:start", (m) => emit("matchStart", m));
722
+ session.on("match:ended", (m) => emit("matchEnded", m));
723
+ session.on("match:requeue", () => void (live = false));
724
+ session.on("disconnect", () => void (live = false));
725
+ const liveRoom = () => live ? roomOf(session) : null;
597
726
  return {
598
727
  get session() {
599
- return matchRoom ? session : null;
728
+ return live ? session : null;
600
729
  },
601
730
  get matchmaking() {
602
- return buildView(matchRoom, { position: 0, size: 0 });
731
+ return buildView(liveRoom(), { position: 0, size: 0 });
603
732
  },
604
733
  on,
605
734
  eliminated() {
606
- matchRoom?.send("match:eliminated", {});
735
+ liveRoom()?.send("match:eliminated", {});
607
736
  },
608
737
  score(delta = 1) {
609
- matchRoom?.send("match:score", { delta });
738
+ liveRoom()?.send("match:score", { delta });
610
739
  },
611
740
  finish() {
612
- matchRoom?.send("match:finish", {});
741
+ liveRoom()?.send("match:finish", {});
613
742
  },
614
743
  retry() {
615
744
  },
616
745
  cancel() {
617
- if (matchRoom) void matchRoom.leave();
618
- matchRoom = null;
619
- void client;
746
+ session.leave();
747
+ live = false;
620
748
  }
621
749
  };
622
750
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@genex-ai/multiplayer",
3
- "version": "0.7.1",
3
+ "version": "0.8.1",
4
4
  "description": "The multiplayer client wrapper + shared room schema for genex.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",