@genex-ai/multiplayer 0.8.0 → 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
@@ -13,15 +13,24 @@ import { connect } from '@genex-ai/multiplayer';
13
13
  ## Connect
14
14
 
15
15
  ```ts
16
+ import { waitForPlayer, getColyseusAuth } from '@genex-ai/embed-sdk';
17
+
16
18
  type State = { x: number; y: number; z: number }; // YOUR per-player state — any JSON shape
17
19
 
20
+ await waitForPlayer(); // identity gate — resolves for guests AND accounts
18
21
  const room = await connect<State>({
19
22
  url: 'wss://demo-colyseus.glotech.world', // the genex multiplayer server
20
23
  room: '<game-id>', // everyone with the same id shares a room (use the project slug)
21
24
  name: 'ada', // optional display name
25
+ auth: getColyseusAuth()!, // REQUIRED — the relay rejects tokenless joins
22
26
  });
23
27
  ```
24
28
 
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
+
25
34
  Different `room` ids are **fully isolated** — players in game A never see game B.
26
35
 
27
36
  ---
@@ -82,8 +91,10 @@ player. `state` is smoothed; `stateRaw` is the raw latest (hit-tests). `objects.
82
91
 
83
92
  ## `isHost` / `host` — the elected room authority
84
93
 
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.
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.
87
98
 
88
99
  ```ts
89
100
  if (room.isHost) room.shared.set('round', nextRound); // only the host advances the round
@@ -183,9 +194,15 @@ Remote players are **smoothed automatically** — nothing to configure. `room.pl
183
194
 
184
195
  ```ts
185
196
  import { connect } from '@genex-ai/multiplayer';
197
+ import { waitForPlayer, getColyseusAuth } from '@genex-ai/embed-sdk';
186
198
  type S = { x: number; z: number };
187
199
 
188
- const room = await connect<S>({ url: 'wss://demo-colyseus.glotech.world', room: GAME_ID });
200
+ await waitForPlayer(); // identity gate (guest OR signed-in) — see Connect above
201
+ const room = await connect<S>({
202
+ url: 'wss://demo-colyseus.glotech.world',
203
+ room: GAME_ID,
204
+ auth: getColyseusAuth()!, // REQUIRED — tokenless joins are rejected
205
+ });
189
206
 
190
207
  const me = { x: 0, z: 0 };
191
208
 
@@ -231,18 +248,26 @@ mm.on("matched", () => {/* session is live */});
231
248
  mm.on("matchStart", ({ roundId }) => {/* round began */});
232
249
  mm.on("matchEnded", ({ winnerId, scores, draw }) => {/* result screen */});
233
250
  mm.on("spectate", () => {/* seated as a spectator (overflow) — read-only */});
251
+ mm.on("error", (e) => {/* queue join failed even after the SDK's automatic retries — persistent
252
+ (broken auth / server unreachable); fix the cause, then mm.retry() */});
234
253
 
235
254
  // Report ONLY your own outcome — the server adjudicates. Pick the call that matches the win condition:
236
255
  mm.eliminated(); // I'm out (lastStanding)
237
256
  mm.score(1); // I scored (firstToScore / highScoreInTime)
238
257
  mm.finish(); // I finished the race (firstToFinish)
239
258
 
259
+ mm.retry(); // re-enter the queue after a terminal 'error' (no-op in any other state)
240
260
  mm.cancel(); // stop searching / leave the match
241
261
  ```
242
262
 
263
+ A failed queue join is **retried automatically** with backoff (~10s total), so transient failures
264
+ (a token mid-rotation, identity still settling on page load) never surface. Only a persistent
265
+ failure emits `error`; the handle then idles until you call `mm.retry()`.
266
+
243
267
  **`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.
244
268
 
245
- Configure once in the game's `package.json` (reported at publish; never sent from the client):
269
+ Configure once in the game's `package.json` (reported at `genex preview` AND `genex publish`;
270
+ removing it clears the stored config on the next preview/publish — never sent from the client):
246
271
 
247
272
  ```jsonc
248
273
  "genex": {
package/dist/index.d.ts CHANGED
@@ -13,10 +13,11 @@ 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.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.
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.
20
21
  */
21
22
  auth?: {
22
23
  embedToken: string;
@@ -65,7 +66,8 @@ type Session<TState extends Record<string, unknown>> = {
65
66
  readonly shared: {
66
67
  /** Read a shared value (any JSON), or `undefined`. */
67
68
  get(key: string): unknown;
68
- /** Set a shared value (any JSON). Any client may set any key (no auth — demo). */
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. */
69
71
  set(key: string, value: unknown): void;
70
72
  /** All shared keys currently set. */
71
73
  keys(): string[];
@@ -90,8 +92,10 @@ type Session<TState extends Record<string, unknown>> = {
90
92
  /** Ids of every object seen so far. */
91
93
  ids(): string[];
92
94
  };
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. */
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. */
95
99
  readonly isHost: boolean;
96
100
  /** Session id of the current room host, or `undefined` until the first host patch lands. */
97
101
  readonly host: string | undefined;
@@ -154,8 +158,12 @@ type Session<TState extends Record<string, unknown>> = {
154
158
  * Connect to a genex multiplayer room. You must supply your player-state type:
155
159
  *
156
160
  * ```ts
161
+ * import { waitForPlayer, getColyseusAuth } from '@genex-ai/embed-sdk';
162
+ *
163
+ * await waitForPlayer(); // identity gate (guest OR signed-in)
157
164
  * const room = await connect<{ x: number; y: number; z: number }>({
158
165
  * url: 'ws://localhost:2567', room: gameId, name: 'ada',
166
+ * auth: getColyseusAuth()!, // REQUIRED — the relay rejects tokenless joins
159
167
  * });
160
168
  * room.me.set({ x, y, z }); // typed + validated by the compiler
161
169
  * room.players.get(id)?.state.x; // number — auto-smoothed for remote players
@@ -205,6 +213,8 @@ type Matchmaking<TState extends Record<string, unknown>> = {
205
213
  * searching: (re)entered the queue. queue: your position changed ({position,size}).
206
214
  * matched: `session` is now live. matchStart: a round began ({roundId}).
207
215
  * matchEnded: {winnerId,loserId,draw,scores}. spectate: you're watching, not playing.
216
+ * error: joining the queue failed even after the SDK's automatic retries (persistent —
217
+ * e.g. auth is broken or the server is unreachable); fix the cause, then call `retry()`.
208
218
  */
209
219
  on(event: 'searching' | 'queue' | 'matched' | 'matchStart' | 'matchEnded' | 'spectate' | 'error', cb: (payload?: unknown) => void): () => void;
210
220
  /** Report your OWN elimination (the `lastStanding` win condition). */
@@ -213,6 +223,9 @@ type Matchmaking<TState extends Record<string, unknown>> = {
213
223
  score(delta?: number): void;
214
224
  /** Report that you finished (the `firstToFinish` win condition — a race). */
215
225
  finish(): void;
226
+ /** Re-enter the queue after a terminal `error`. No-op while searching/matched, after
227
+ * `cancel()`, and for private lobbies (create/join a new lobby instead). */
228
+ retry(): void;
216
229
  /** Stop searching / leave the current match. */
217
230
  cancel(): void;
218
231
  };
@@ -238,6 +251,10 @@ type MatchmakeOptions = {
238
251
  * Join a game's matchmaking. Returns immediately in the SEARCHING state (`session` is null); the
239
252
  * matchmaker seats you into a match room, at which point `session` becomes a live Session and a
240
253
  * `matched` event fires. The preset + config are owned by the project (server-side).
254
+ *
255
+ * A failed queue join is retried automatically with backoff (~10s total) — transient failures
256
+ * (token rotation, identity settling) never surface. A persistent failure emits 'error' and the
257
+ * handle idles until `retry()` is called.
241
258
  */
242
259
  declare function matchmake<TState extends Record<string, unknown> = never>(opts: MatchmakeOptions): Promise<Matchmaking<TState>>;
243
260
  /** A private lobby handle — a Matchmaking whose `session` is live immediately, plus the invite `code`. */
package/dist/index.js CHANGED
@@ -508,6 +508,7 @@ function attachSession(client, initialRoom) {
508
508
  Object.defineProperty(session, ROOM_REF, { value: () => room, enumerable: false });
509
509
  return session;
510
510
  }
511
+ var SEARCH_BACKOFF_MS = [1e3, 2e3, 4e3];
511
512
  var resolveAuth = (auth) => typeof auth === "function" ? auth() : auth;
512
513
  var matchJoinOptions = (opts) => ({
513
514
  gameId: opts.room,
@@ -581,48 +582,64 @@ async function matchmake(opts) {
581
582
  let queueRoom = null;
582
583
  let queueInfo = { position: 0, size: 0 };
583
584
  let cancelled = false;
585
+ let searching = false;
584
586
  const search = async () => {
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;
587
+ if (cancelled || searching) return;
588
+ searching = true;
594
589
  try {
595
- q = await client.joinOrCreate(QUEUE_ROOM_TYPE, matchJoinOptions(opts));
596
- } catch (err) {
597
- if (!cancelled) emit("error", err);
598
- return;
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;
590
+ session = null;
591
+ matchRoom = null;
592
+ for (let attempt = 0; ; attempt++) {
593
+ if (queueRoom) {
594
+ void queueRoom.leave();
595
+ queueRoom = null;
622
596
  }
623
- })();
624
- });
625
- emit("searching");
597
+ queueInfo = { position: 0, size: 0 };
598
+ let q;
599
+ try {
600
+ q = await client.joinOrCreate(QUEUE_ROOM_TYPE, matchJoinOptions(opts));
601
+ } catch (err) {
602
+ if (cancelled) return;
603
+ const delay = SEARCH_BACKOFF_MS[attempt];
604
+ if (delay == null) {
605
+ emit("error", err);
606
+ return;
607
+ }
608
+ await new Promise((r) => setTimeout(r, delay));
609
+ if (cancelled) return;
610
+ continue;
611
+ }
612
+ if (cancelled) {
613
+ void q.leave();
614
+ return;
615
+ }
616
+ queueRoom = q;
617
+ q.onMessage("queue", (msg) => {
618
+ queueInfo = { position: msg.position, size: msg.size };
619
+ emit("queue", msg);
620
+ });
621
+ q.onMessage(
622
+ "seat",
623
+ (msg) => void consumeSeat(msg.reservation, msg.role)
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
+ });
637
+ emit("searching");
638
+ return;
639
+ }
640
+ } finally {
641
+ searching = false;
642
+ }
626
643
  };
627
644
  const consumeSeat = async (reservation, role) => {
628
645
  try {
@@ -656,7 +673,7 @@ async function matchmake(opts) {
656
673
  session = null;
657
674
  await search();
658
675
  };
659
- await search();
676
+ void search();
660
677
  const liveRoom = () => session ? roomOf(session) : null;
661
678
  return {
662
679
  get session() {
@@ -675,6 +692,10 @@ async function matchmake(opts) {
675
692
  finish() {
676
693
  liveRoom()?.send("match:finish", {});
677
694
  },
695
+ retry() {
696
+ if (cancelled || searching || queueRoom || matchRoom) return;
697
+ void search();
698
+ },
678
699
  cancel() {
679
700
  cancelled = true;
680
701
  session?.leave();
@@ -719,6 +740,8 @@ function wirePrivate(client, room) {
719
740
  finish() {
720
741
  liveRoom()?.send("match:finish", {});
721
742
  },
743
+ retry() {
744
+ },
722
745
  cancel() {
723
746
  session.leave();
724
747
  live = false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@genex-ai/multiplayer",
3
- "version": "0.8.0",
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",