@genex-ai/multiplayer 0.7.1 → 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
@@ -13,24 +13,15 @@ import { connect } from '@genex-ai/multiplayer';
13
13
  ## Connect
14
14
 
15
15
  ```ts
16
- import { waitForPlayer, getColyseusAuth } from '@genex-ai/embed-sdk';
17
-
18
16
  type State = { x: number; y: number; z: number }; // YOUR per-player state — any JSON shape
19
17
 
20
- await waitForPlayer(); // identity gate — resolves for guests AND accounts
21
18
  const room = await connect<State>({
22
19
  url: 'wss://demo-colyseus.glotech.world', // the genex multiplayer server
23
20
  room: '<game-id>', // everyone with the same id shares a room (use the project slug)
24
21
  name: 'ada', // optional display name
25
- auth: getColyseusAuth()!, // REQUIRED — the relay rejects tokenless joins
26
22
  });
27
23
  ```
28
24
 
29
- `auth` is **required** — the relay rejects tokenless joins. Get it from `@genex-ai/embed-sdk`:
30
- `await waitForPlayer()` first (never `waitForAuth()` — that resolves only for signed-in accounts
31
- and keeps guests out forever), then read `getColyseusAuth()` **fresh at every `connect()`** —
32
- tokens rotate every ~10 min, so never cache the object across reconnects. Never log it.
33
-
34
25
  Different `room` ids are **fully isolated** — players in game A never see game B.
35
26
 
36
27
  ---
@@ -91,10 +82,8 @@ player. `state` is smoothed; `stateRaw` is the raw latest (hit-tests). `objects.
91
82
 
92
83
  ## `isHost` / `host` — the elected room authority
93
84
 
94
- One client is the **host** — the earliest **signed-in** player still present (guests host only
95
- while no account is in the room), re-elected automatically. The host can change on **join** as
96
- well as leave: the first account entering a guest-hosted room takes over. Use it to pick a single
97
- writer for scores/rounds or a single simulator for NPCs.
85
+ One client is the **host** (the first joiner still present, re-elected automatically when they
86
+ leave). Use it to pick a single writer for scores/rounds or a single simulator for NPCs.
98
87
 
99
88
  ```ts
100
89
  if (room.isHost) room.shared.set('round', nextRound); // only the host advances the round
@@ -114,10 +103,52 @@ room.on('shared', (key, value) => {}); // a shared key changed
114
103
  room.on('object', (id) => {}); // an object's owner changed (handoff)
115
104
  room.on('host', (id) => {}); // the room host changed
116
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
117
110
  ```
118
111
 
119
112
  `on(...)` returns an unsubscribe function.
120
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
+
121
152
  ## `send` — custom one-off events
122
153
 
123
154
  Fire-and-forget to all **other** clients (not stored in state). For shots, emotes, chat, pings.
@@ -152,15 +183,9 @@ Remote players are **smoothed automatically** — nothing to configure. `room.pl
152
183
 
153
184
  ```ts
154
185
  import { connect } from '@genex-ai/multiplayer';
155
- import { waitForPlayer, getColyseusAuth } from '@genex-ai/embed-sdk';
156
186
  type S = { x: number; z: number };
157
187
 
158
- await waitForPlayer(); // identity gate (guest OR signed-in) — see Connect above
159
- const room = await connect<S>({
160
- url: 'wss://demo-colyseus.glotech.world',
161
- room: GAME_ID,
162
- auth: getColyseusAuth()!, // REQUIRED — tokenless joins are rejected
163
- });
188
+ const room = await connect<S>({ url: 'wss://demo-colyseus.glotech.world', room: GAME_ID });
164
189
 
165
190
  const me = { x: 0, z: 0 };
166
191
 
@@ -206,26 +231,18 @@ mm.on("matched", () => {/* session is live */});
206
231
  mm.on("matchStart", ({ roundId }) => {/* round began */});
207
232
  mm.on("matchEnded", ({ winnerId, scores, draw }) => {/* result screen */});
208
233
  mm.on("spectate", () => {/* seated as a spectator (overflow) — read-only */});
209
- mm.on("error", (e) => {/* queue join failed even after the SDK's automatic retries — persistent
210
- (broken auth / server unreachable); fix the cause, then mm.retry() */});
211
234
 
212
235
  // Report ONLY your own outcome — the server adjudicates. Pick the call that matches the win condition:
213
236
  mm.eliminated(); // I'm out (lastStanding)
214
237
  mm.score(1); // I scored (firstToScore / highScoreInTime)
215
238
  mm.finish(); // I finished the race (firstToFinish)
216
239
 
217
- mm.retry(); // re-enter the queue after a terminal 'error' (no-op in any other state)
218
240
  mm.cancel(); // stop searching / leave the match
219
241
  ```
220
242
 
221
- A failed queue join is **retried automatically** with backoff (~10s total), so transient failures
222
- (a token mid-rotation, identity still settling on page load) never surface. Only a persistent
223
- failure emits `error`; the handle then idles until you call `mm.retry()`.
224
-
225
243
  **`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.
226
244
 
227
- Configure once in the game's `package.json` (reported at `genex preview` AND `genex publish`;
228
- removing it clears the stored config on the next preview/publish — never sent from the client):
245
+ Configure once in the game's `package.json` (reported at publish; never sent from the client):
229
246
 
230
247
  ```jsonc
231
248
  "genex": {
package/dist/index.d.ts CHANGED
@@ -13,11 +13,10 @@ type ConnectOptions = {
13
13
  /**
14
14
  * Verified-identity credential — REQUIRED to actually join: the relay
15
15
  * rejects tokenless joins (there is no anonymous play). Pass
16
- * `genexEmbed.getColyseusAuth()` AFTER `await genexEmbed.waitForPlayer()`
17
- * (from `@genex-ai/embed-sdk`). NOT `waitForAuth()` — that resolves only for
18
- * signed-in accounts and would keep guests out of multiplayer forever.
19
- * Read it fresh at every connect() call — never cache the object across
20
- * reconnects (tokens rotate every ~10 min). NEVER log this value.
16
+ * `genexEmbed.getColyseusAuth()` AFTER `await genexEmbed.waitForAuth()`
17
+ * (from `@genex-ai/embed-sdk`), and read it fresh at every connect() call —
18
+ * never cache the object across reconnects (tokens rotate every ~10 min).
19
+ * NEVER log this value.
21
20
  */
22
21
  auth?: {
23
22
  embedToken: string;
@@ -66,8 +65,7 @@ type Session<TState extends Record<string, unknown>> = {
66
65
  readonly shared: {
67
66
  /** Read a shared value (any JSON), or `undefined`. */
68
67
  get(key: string): unknown;
69
- /** Set a shared value (any JSON). Any client may write game keys — by convention only
70
- * the host writes agreed facts (see `isHost`); `__*` keys are reserved and rejected. */
68
+ /** Set a shared value (any JSON). Any client may set any key (no auth — demo). */
71
69
  set(key: string, value: unknown): void;
72
70
  /** All shared keys currently set. */
73
71
  keys(): string[];
@@ -92,40 +90,72 @@ type Session<TState extends Record<string, unknown>> = {
92
90
  /** Ids of every object seen so far. */
93
91
  ids(): string[];
94
92
  };
95
- /** True if you are the elected room host — the earliest signed-in player still present
96
- * (guests host only while no account is in the room); can migrate on join as well as leave.
97
- * Settles within the first state patch after connect — read it in your loop or react to
98
- * `on('host')`, not once. */
93
+ /** True if you are the elected room host (first joiner still present). Settles within the
94
+ * first state patch after connect — read it in your loop or react to `on('host')`, not once. */
99
95
  readonly isHost: boolean;
100
96
  /** Session id of the current room host, or `undefined` until the first host patch lands. */
101
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;
102
116
  /** A player appeared/updated. **Also fires for you** on connect — filter `id === room.id`. */
103
117
  on(event: 'join' | 'change', cb: (id: string, state: TState) => void): () => void;
104
118
  /** A player left. */
105
119
  on(event: 'leave', cb: (id: string) => void): () => void;
106
120
  /** A shared key changed (reserved `__*` keys are hidden). For live values just read `shared.get`. */
107
121
  on(event: 'shared', cb: (key: string, value: unknown) => void): () => void;
108
- /** 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. */
109
124
  on(event: 'object', cb: (id: string) => void): () => void;
110
125
  /** The room host changed (fires with the new host's id, or `undefined`). */
111
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;
112
144
  /** A custom event another client sent via `send(type, ...)`. */
113
145
  on(event: string & {}, cb: (payload: unknown) => void): () => void;
114
- /** 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. */
115
149
  send(type: string, payload?: unknown): void;
116
- /** Leave the room. */
150
+ /** Leave the room (deliberate — no auto-reconnect, no `disconnect` event). */
117
151
  leave(): void;
118
152
  };
119
153
  /**
120
154
  * Connect to a genex multiplayer room. You must supply your player-state type:
121
155
  *
122
156
  * ```ts
123
- * import { waitForPlayer, getColyseusAuth } from '@genex-ai/embed-sdk';
124
- *
125
- * await waitForPlayer(); // identity gate (guest OR signed-in)
126
157
  * const room = await connect<{ x: number; y: number; z: number }>({
127
158
  * url: 'ws://localhost:2567', room: gameId, name: 'ada',
128
- * auth: getColyseusAuth()!, // REQUIRED — the relay rejects tokenless joins
129
159
  * });
130
160
  * room.me.set({ x, y, z }); // typed + validated by the compiler
131
161
  * room.players.get(id)?.state.x; // number — auto-smoothed for remote players
@@ -175,8 +205,6 @@ type Matchmaking<TState extends Record<string, unknown>> = {
175
205
  * searching: (re)entered the queue. queue: your position changed ({position,size}).
176
206
  * matched: `session` is now live. matchStart: a round began ({roundId}).
177
207
  * matchEnded: {winnerId,loserId,draw,scores}. spectate: you're watching, not playing.
178
- * error: joining the queue failed even after the SDK's automatic retries (persistent —
179
- * e.g. auth is broken or the server is unreachable); fix the cause, then call `retry()`.
180
208
  */
181
209
  on(event: 'searching' | 'queue' | 'matched' | 'matchStart' | 'matchEnded' | 'spectate' | 'error', cb: (payload?: unknown) => void): () => void;
182
210
  /** Report your OWN elimination (the `lastStanding` win condition). */
@@ -185,9 +213,6 @@ type Matchmaking<TState extends Record<string, unknown>> = {
185
213
  score(delta?: number): void;
186
214
  /** Report that you finished (the `firstToFinish` win condition — a race). */
187
215
  finish(): void;
188
- /** Re-enter the queue after a terminal `error`. No-op while searching/matched, after
189
- * `cancel()`, and for private lobbies (create/join a new lobby instead). */
190
- retry(): void;
191
216
  /** Stop searching / leave the current match. */
192
217
  cancel(): void;
193
218
  };
@@ -213,10 +238,6 @@ type MatchmakeOptions = {
213
238
  * Join a game's matchmaking. Returns immediately in the SEARCHING state (`session` is null); the
214
239
  * matchmaker seats you into a match room, at which point `session` becomes a live Session and a
215
240
  * `matched` event fires. The preset + config are owned by the project (server-side).
216
- *
217
- * A failed queue join is retried automatically with backoff (~10s total) — transient failures
218
- * (token rotation, identity settling) never surface. A persistent failure emits 'error' and the
219
- * handle idles until `retry()` is called.
220
241
  */
221
242
  declare function matchmake<TState extends Record<string, unknown> = never>(opts: MatchmakeOptions): Promise<Matchmaking<TState>>;
222
243
  /** A private lobby handle — a Matchmaking whose `session` is live immediately, plus the invite `code`. */
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,16 +455,59 @@ 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
- var SEARCH_BACKOFF_MS = [1e3, 2e3, 4e3];
400
511
  var resolveAuth = (auth) => typeof auth === "function" ? auth() : auth;
401
512
  var matchJoinOptions = (opts) => ({
402
513
  gameId: opts.room,
@@ -470,52 +581,48 @@ async function matchmake(opts) {
470
581
  let queueRoom = null;
471
582
  let queueInfo = { position: 0, size: 0 };
472
583
  let cancelled = false;
473
- let searching = false;
474
584
  const search = async () => {
475
- if (cancelled || searching) return;
476
- searching = true;
585
+ if (cancelled) return;
586
+ session = null;
587
+ matchRoom = null;
588
+ if (queueRoom) {
589
+ void queueRoom.leave();
590
+ queueRoom = null;
591
+ }
592
+ queueInfo = { position: 0, size: 0 };
593
+ let q;
477
594
  try {
478
- session = null;
479
- matchRoom = null;
480
- for (let attempt = 0; ; attempt++) {
481
- if (queueRoom) {
482
- void queueRoom.leave();
483
- queueRoom = null;
484
- }
485
- queueInfo = { position: 0, size: 0 };
486
- let q;
487
- try {
488
- q = await client.joinOrCreate(QUEUE_ROOM_TYPE, matchJoinOptions(opts));
489
- } catch (err) {
490
- if (cancelled) return;
491
- const delay = SEARCH_BACKOFF_MS[attempt];
492
- if (delay == null) {
493
- emit("error", err);
494
- return;
495
- }
496
- await new Promise((r) => setTimeout(r, delay));
497
- if (cancelled) return;
498
- continue;
499
- }
500
- if (cancelled) {
501
- void q.leave();
502
- return;
503
- }
504
- queueRoom = q;
505
- q.onMessage("queue", (msg) => {
506
- queueInfo = { position: msg.position, size: msg.size };
507
- emit("queue", msg);
508
- });
509
- q.onMessage(
510
- "seat",
511
- (msg) => void consumeSeat(msg.reservation, msg.role)
512
- );
513
- emit("searching");
514
- return;
515
- }
516
- } finally {
517
- searching = false;
595
+ q = await client.joinOrCreate(QUEUE_ROOM_TYPE, matchJoinOptions(opts));
596
+ } catch (err) {
597
+ if (!cancelled) emit("error", err);
598
+ return;
518
599
  }
600
+ if (cancelled) {
601
+ void q.leave();
602
+ return;
603
+ }
604
+ queueRoom = q;
605
+ q.onMessage("queue", (msg) => {
606
+ queueInfo = { position: msg.position, size: msg.size };
607
+ emit("queue", msg);
608
+ });
609
+ q.onMessage(
610
+ "seat",
611
+ (msg) => void consumeSeat(msg.reservation, msg.role)
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
+ });
625
+ emit("searching");
519
626
  };
520
627
  const consumeSeat = async (reservation, role) => {
521
628
  try {
@@ -525,10 +632,14 @@ async function matchmake(opts) {
525
632
  return;
526
633
  }
527
634
  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());
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
+ });
532
643
  if (queueRoom) {
533
644
  void queueRoom.leave();
534
645
  queueRoom = null;
@@ -540,38 +651,34 @@ async function matchmake(opts) {
540
651
  }
541
652
  };
542
653
  const requeue = async () => {
543
- if (matchRoom) {
544
- void matchRoom.leave();
545
- matchRoom = null;
546
- }
654
+ session?.leave();
655
+ matchRoom = null;
547
656
  session = null;
548
657
  await search();
549
658
  };
550
- void search();
659
+ await search();
660
+ const liveRoom = () => session ? roomOf(session) : null;
551
661
  return {
552
662
  get session() {
553
663
  return session;
554
664
  },
555
665
  get matchmaking() {
556
- return buildView(matchRoom, queueInfo);
666
+ return buildView(liveRoom(), queueInfo);
557
667
  },
558
668
  on,
559
669
  eliminated() {
560
- matchRoom?.send("match:eliminated", {});
670
+ liveRoom()?.send("match:eliminated", {});
561
671
  },
562
672
  score(delta = 1) {
563
- matchRoom?.send("match:score", { delta });
673
+ liveRoom()?.send("match:score", { delta });
564
674
  },
565
675
  finish() {
566
- matchRoom?.send("match:finish", {});
567
- },
568
- retry() {
569
- if (cancelled || searching || queueRoom || matchRoom) return;
570
- void search();
676
+ liveRoom()?.send("match:finish", {});
571
677
  },
572
678
  cancel() {
573
679
  cancelled = true;
574
- if (matchRoom) void matchRoom.leave();
680
+ session?.leave();
681
+ if (!session && matchRoom) void matchRoom.leave();
575
682
  if (queueRoom) void queueRoom.leave();
576
683
  matchRoom = null;
577
684
  queueRoom = null;
@@ -588,35 +695,33 @@ function wirePrivate(client, room) {
588
695
  set.add(cb);
589
696
  return () => void set?.delete(cb);
590
697
  });
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));
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;
597
705
  return {
598
706
  get session() {
599
- return matchRoom ? session : null;
707
+ return live ? session : null;
600
708
  },
601
709
  get matchmaking() {
602
- return buildView(matchRoom, { position: 0, size: 0 });
710
+ return buildView(liveRoom(), { position: 0, size: 0 });
603
711
  },
604
712
  on,
605
713
  eliminated() {
606
- matchRoom?.send("match:eliminated", {});
714
+ liveRoom()?.send("match:eliminated", {});
607
715
  },
608
716
  score(delta = 1) {
609
- matchRoom?.send("match:score", { delta });
717
+ liveRoom()?.send("match:score", { delta });
610
718
  },
611
719
  finish() {
612
- matchRoom?.send("match:finish", {});
613
- },
614
- retry() {
720
+ liveRoom()?.send("match:finish", {});
615
721
  },
616
722
  cancel() {
617
- if (matchRoom) void matchRoom.leave();
618
- matchRoom = null;
619
- void client;
723
+ session.leave();
724
+ live = false;
620
725
  }
621
726
  };
622
727
  }
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.0",
4
4
  "description": "The multiplayer client wrapper + shared room schema for genex.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",