@genex-ai/multiplayer 0.7.0 → 0.8.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
@@ -103,10 +103,52 @@ room.on('shared', (key, value) => {}); // a shared key changed
103
103
  room.on('object', (id) => {}); // an object's owner changed (handoff)
104
104
  room.on('host', (id) => {}); // the room host changed
105
105
  room.on('hit', (payload) => {}); // a custom event someone sent (see `send`)
106
+
107
+ room.on('reconnecting', ({ attempt }) => {}); // connection dropped — auto-reconnect underway
108
+ room.on('reconnected', () => {}); // back in the SAME seat: id, objects, host intact
109
+ room.on('disconnect', (code) => {}); // terminal — connect() fresh to play again
106
110
  ```
107
111
 
108
112
  `on(...)` returns an unsubscribe function.
109
113
 
114
+ ## Reconnection (automatic)
115
+
116
+ A dropped socket (Wi-Fi blip, brief signal loss) is **not** a leave: the relay holds your
117
+ seat for a grace window (~30s) and the SDK reconnects with backoff. On success nothing
118
+ changed — same session id, owned objects still yours, host status intact, and mid-match a
119
+ blip is **not a forfeit**. Render `reconnecting`/`reconnected` as an overlay and keep your
120
+ loop running. `disconnect` is terminal (server restart, revoked session, or the link never
121
+ recovered): read a fresh token and `connect()` again. A deliberate `leave()` never
122
+ reconnects. The relay also broadcasts `server:restart` right before a deploy — flush saves
123
+ when you see it.
124
+
125
+ **One seat per player:** the relay enforces a single session per identity per room — a
126
+ second tab / device / reloaded page supersedes the previous session (old one gets
127
+ `disconnect`, code 4409), so the same player never appears twice and reloads never leave
128
+ a ghost. Reload/close sockets ("going away") get only a ~5s seat-hold; genuine network
129
+ drops keep the full reconnection grace.
130
+
131
+ ## `inputs` + `onHostTick` — host-authoritative physics (contested objects)
132
+
133
+ Claim-on-touch is right for one-touch objects (kick a ball). For **sustained contact**
134
+ (two players pushing one crate) let ONE simulation own the contest: everyone sends inputs —
135
+ the relay routes them to the **current host only** — and the host applies them on a fixed
136
+ tick and publishes results via `objects` (auto-smoothed for everyone):
137
+
138
+ ```ts
139
+ if (pushing) room.inputs.send({ obj: 'crate', push: dir }); // any client → host only
140
+
141
+ room.inputs.on((fromId, payload) => queue.push(payload)); // fires only while YOU host
142
+ room.onHostTick(30, (dtMs) => { // runs only on the host,
143
+ // adopt objects + seed from stateRaw on first tick, apply // auto-migrates with the
144
+ // queued inputs to YOUR one physics world, then: // host election
145
+ room.objects.set('crate', { x, y, z, q });
146
+ });
147
+ ```
148
+
149
+ `onHostTick` auto-starts on election and auto-stops on demotion; a new host seeds its world
150
+ from each object's `stateRaw`, so the simulation survives host migration without a snap.
151
+
110
152
  ## `send` — custom one-off events
111
153
 
112
154
  Fire-and-forget to all **other** clients (not stored in state). For shots, emotes, chat, pings.
package/dist/index.d.ts CHANGED
@@ -95,21 +95,59 @@ type Session<TState extends Record<string, unknown>> = {
95
95
  readonly isHost: boolean;
96
96
  /** Session id of the current room host, or `undefined` until the first host patch lands. */
97
97
  readonly host: string | undefined;
98
+ /**
99
+ * Input channel for host-authoritative patterns (contested physics, host-simulated
100
+ * NPCs): anyone `send`s an input; the server delivers it ONLY to the current host
101
+ * (never broadcast). The host applies inputs in its one authoritative simulation and
102
+ * publishes results via `objects` — everyone renders them auto-smoothed.
103
+ */
104
+ readonly inputs: {
105
+ /** Send an input/impulse to the current host (single receiver, cheap). */
106
+ send(payload: unknown): void;
107
+ /** Receive inputs — only ever fires while YOU are the host. Returns unsubscribe. */
108
+ on(cb: (fromId: string, payload: unknown) => void): () => void;
109
+ };
110
+ /**
111
+ * Run `cb(dtMs)` on a fixed tick ONLY while this client is the host — auto-starts on
112
+ * election and auto-stops on demotion/leave, so host-simulation code needs no "am I
113
+ * host this frame?" bookkeeping. Returns a disposer.
114
+ */
115
+ onHostTick(hz: number, cb: (dtMs: number) => void): () => void;
98
116
  /** A player appeared/updated. **Also fires for you** on connect — filter `id === room.id`. */
99
117
  on(event: 'join' | 'change', cb: (id: string, state: TState) => void): () => void;
100
118
  /** A player left. */
101
119
  on(event: 'leave', cb: (id: string) => void): () => void;
102
120
  /** A shared key changed (reserved `__*` keys are hidden). For live values just read `shared.get`. */
103
121
  on(event: 'shared', cb: (key: string, value: unknown) => void): () => void;
104
- /** An object's owner or state changed (fires with its id). */
122
+ /** An object's OWNERSHIP changed (claim/handoff), or it appeared/was destroyed — fires
123
+ * with its id. NOT per-tick state: poll `objects.get(id)` in your loop for state. */
105
124
  on(event: 'object', cb: (id: string) => void): () => void;
106
125
  /** The room host changed (fires with the new host's id, or `undefined`). */
107
126
  on(event: 'host', cb: (host: string | undefined) => void): () => void;
127
+ /**
128
+ * The connection dropped unexpectedly and auto-reconnect is underway (attempts run
129
+ * ~0.5s→8s backoff inside the server's grace window). Show a "reconnecting…" overlay;
130
+ * keep your loop running — the world resumes seamlessly on `reconnected`.
131
+ */
132
+ on(event: 'reconnecting', cb: (info: {
133
+ attempt: number;
134
+ code: number;
135
+ }) => void): () => void;
136
+ /** Auto-reconnect succeeded: same session id, same seat — nothing was torn down. */
137
+ on(event: 'reconnected', cb: () => void): () => void;
138
+ /**
139
+ * The connection is gone for good: the server closed it on purpose (restart, revoked
140
+ * session, match eviction) or every reconnect attempt failed. The session is dead —
141
+ * to play again, read a FRESH `getColyseusAuth()` and `connect()` anew.
142
+ */
143
+ on(event: 'disconnect', cb: (code: number) => void): () => void;
108
144
  /** A custom event another client sent via `send(type, ...)`. */
109
145
  on(event: string & {}, cb: (payload: unknown) => void): () => void;
110
- /** Fire-and-forget event to all OTHER clients (not stored in state). */
146
+ /** Fire-and-forget event to all OTHER clients (not stored in state). Event names used
147
+ * by the relay itself (`state`, `shared`, `claim`, `obj`, `release`, `destroy`, and the
148
+ * `match:`/`__` prefixes) are refused with a console warning — pick another name. */
111
149
  send(type: string, payload?: unknown): void;
112
- /** Leave the room. */
150
+ /** Leave the room (deliberate — no auto-reconnect, no `disconnect` event). */
113
151
  leave(): void;
114
152
  };
115
153
  /**
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;
272
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);
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 resolveAuth = (auth) => typeof auth === "function" ? auth() : auth;
400
512
  var matchJoinOptions = (opts) => ({
@@ -498,6 +610,18 @@ async function matchmake(opts) {
498
610
  "seat",
499
611
  (msg) => void consumeSeat(msg.reservation, msg.role)
500
612
  );
613
+ q.onLeave(() => {
614
+ if (cancelled || queueRoom !== q) return;
615
+ queueRoom = null;
616
+ void (async () => {
617
+ for (const delayMs of RECONNECT_DELAYS_MS) {
618
+ await new Promise((r) => setTimeout(r, delayMs));
619
+ if (cancelled || queueRoom || session) return;
620
+ await search();
621
+ if (queueRoom || session) return;
622
+ }
623
+ })();
624
+ });
501
625
  emit("searching");
502
626
  };
503
627
  const consumeSeat = async (reservation, role) => {
@@ -508,10 +632,14 @@ async function matchmake(opts) {
508
632
  return;
509
633
  }
510
634
  matchRoom = mr;
511
- session = attachSession(mr);
512
- mr.onMessage("match:start", (m) => emit("matchStart", m));
513
- mr.onMessage("match:ended", (m) => emit("matchEnded", m));
514
- mr.onMessage("match:requeue", () => void requeue());
635
+ const s = attachSession(client, mr);
636
+ session = s;
637
+ s.on("match:start", (m) => emit("matchStart", m));
638
+ s.on("match:ended", (m) => emit("matchEnded", m));
639
+ s.on("match:requeue", () => void requeue());
640
+ s.on("disconnect", () => {
641
+ if (!cancelled && session === s) void requeue();
642
+ });
515
643
  if (queueRoom) {
516
644
  void queueRoom.leave();
517
645
  queueRoom = null;
@@ -523,34 +651,34 @@ async function matchmake(opts) {
523
651
  }
524
652
  };
525
653
  const requeue = async () => {
526
- if (matchRoom) {
527
- void matchRoom.leave();
528
- matchRoom = null;
529
- }
654
+ session?.leave();
655
+ matchRoom = null;
530
656
  session = null;
531
657
  await search();
532
658
  };
533
659
  await search();
660
+ const liveRoom = () => session ? roomOf(session) : null;
534
661
  return {
535
662
  get session() {
536
663
  return session;
537
664
  },
538
665
  get matchmaking() {
539
- return buildView(matchRoom, queueInfo);
666
+ return buildView(liveRoom(), queueInfo);
540
667
  },
541
668
  on,
542
669
  eliminated() {
543
- matchRoom?.send("match:eliminated", {});
670
+ liveRoom()?.send("match:eliminated", {});
544
671
  },
545
672
  score(delta = 1) {
546
- matchRoom?.send("match:score", { delta });
673
+ liveRoom()?.send("match:score", { delta });
547
674
  },
548
675
  finish() {
549
- matchRoom?.send("match:finish", {});
676
+ liveRoom()?.send("match:finish", {});
550
677
  },
551
678
  cancel() {
552
679
  cancelled = true;
553
- if (matchRoom) void matchRoom.leave();
680
+ session?.leave();
681
+ if (!session && matchRoom) void matchRoom.leave();
554
682
  if (queueRoom) void queueRoom.leave();
555
683
  matchRoom = null;
556
684
  queueRoom = null;
@@ -567,33 +695,33 @@ function wirePrivate(client, room) {
567
695
  set.add(cb);
568
696
  return () => void set?.delete(cb);
569
697
  });
570
- let matchRoom = room;
571
- const session = attachSession(room);
572
- room.onMessage("match:start", (m) => emit("matchStart", m));
573
- room.onMessage("match:ended", (m) => emit("matchEnded", m));
574
- room.onMessage("match:requeue", () => void (matchRoom = null));
575
- room.onLeave(() => void (matchRoom = null));
698
+ let live = true;
699
+ const session = attachSession(client, room);
700
+ session.on("match:start", (m) => emit("matchStart", m));
701
+ session.on("match:ended", (m) => emit("matchEnded", m));
702
+ session.on("match:requeue", () => void (live = false));
703
+ session.on("disconnect", () => void (live = false));
704
+ const liveRoom = () => live ? roomOf(session) : null;
576
705
  return {
577
706
  get session() {
578
- return matchRoom ? session : null;
707
+ return live ? session : null;
579
708
  },
580
709
  get matchmaking() {
581
- return buildView(matchRoom, { position: 0, size: 0 });
710
+ return buildView(liveRoom(), { position: 0, size: 0 });
582
711
  },
583
712
  on,
584
713
  eliminated() {
585
- matchRoom?.send("match:eliminated", {});
714
+ liveRoom()?.send("match:eliminated", {});
586
715
  },
587
716
  score(delta = 1) {
588
- matchRoom?.send("match:score", { delta });
717
+ liveRoom()?.send("match:score", { delta });
589
718
  },
590
719
  finish() {
591
- matchRoom?.send("match:finish", {});
720
+ liveRoom()?.send("match:finish", {});
592
721
  },
593
722
  cancel() {
594
- if (matchRoom) void matchRoom.leave();
595
- matchRoom = null;
596
- void client;
723
+ session.leave();
724
+ live = false;
597
725
  }
598
726
  };
599
727
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@genex-ai/multiplayer",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "The multiplayer client wrapper + shared room schema for genex.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",