@genex-ai/multiplayer 0.7.0 → 0.7.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
@@ -141,9 +152,15 @@ Remote players are **smoothed automatically** — nothing to configure. `room.pl
141
152
 
142
153
  ```ts
143
154
  import { connect } from '@genex-ai/multiplayer';
155
+ import { waitForPlayer, getColyseusAuth } from '@genex-ai/embed-sdk';
144
156
  type S = { x: number; z: number };
145
157
 
146
- const room = await connect<S>({ url: 'wss://demo-colyseus.glotech.world', room: GAME_ID });
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
+ });
147
164
 
148
165
  const me = { x: 0, z: 0 };
149
166
 
@@ -189,18 +206,26 @@ mm.on("matched", () => {/* session is live */});
189
206
  mm.on("matchStart", ({ roundId }) => {/* round began */});
190
207
  mm.on("matchEnded", ({ winnerId, scores, draw }) => {/* result screen */});
191
208
  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() */});
192
211
 
193
212
  // Report ONLY your own outcome — the server adjudicates. Pick the call that matches the win condition:
194
213
  mm.eliminated(); // I'm out (lastStanding)
195
214
  mm.score(1); // I scored (firstToScore / highScoreInTime)
196
215
  mm.finish(); // I finished the race (firstToFinish)
197
216
 
217
+ mm.retry(); // re-enter the queue after a terminal 'error' (no-op in any other state)
198
218
  mm.cancel(); // stop searching / leave the match
199
219
  ```
200
220
 
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
+
201
225
  **`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.
202
226
 
203
- Configure once in the game's `package.json` (reported at publish; never sent from the client):
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):
204
229
 
205
230
  ```jsonc
206
231
  "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;
@@ -116,8 +120,12 @@ type Session<TState extends Record<string, unknown>> = {
116
120
  * Connect to a genex multiplayer room. You must supply your player-state type:
117
121
  *
118
122
  * ```ts
123
+ * import { waitForPlayer, getColyseusAuth } from '@genex-ai/embed-sdk';
124
+ *
125
+ * await waitForPlayer(); // identity gate (guest OR signed-in)
119
126
  * const room = await connect<{ x: number; y: number; z: number }>({
120
127
  * url: 'ws://localhost:2567', room: gameId, name: 'ada',
128
+ * auth: getColyseusAuth()!, // REQUIRED — the relay rejects tokenless joins
121
129
  * });
122
130
  * room.me.set({ x, y, z }); // typed + validated by the compiler
123
131
  * room.players.get(id)?.state.x; // number — auto-smoothed for remote players
@@ -167,6 +175,8 @@ type Matchmaking<TState extends Record<string, unknown>> = {
167
175
  * searching: (re)entered the queue. queue: your position changed ({position,size}).
168
176
  * matched: `session` is now live. matchStart: a round began ({roundId}).
169
177
  * 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()`.
170
180
  */
171
181
  on(event: 'searching' | 'queue' | 'matched' | 'matchStart' | 'matchEnded' | 'spectate' | 'error', cb: (payload?: unknown) => void): () => void;
172
182
  /** Report your OWN elimination (the `lastStanding` win condition). */
@@ -175,6 +185,9 @@ type Matchmaking<TState extends Record<string, unknown>> = {
175
185
  score(delta?: number): void;
176
186
  /** Report that you finished (the `firstToFinish` win condition — a race). */
177
187
  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;
178
191
  /** Stop searching / leave the current match. */
179
192
  cancel(): void;
180
193
  };
@@ -200,6 +213,10 @@ type MatchmakeOptions = {
200
213
  * Join a game's matchmaking. Returns immediately in the SEARCHING state (`session` is null); the
201
214
  * matchmaker seats you into a match room, at which point `session` becomes a live Session and a
202
215
  * `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.
203
220
  */
204
221
  declare function matchmake<TState extends Record<string, unknown> = never>(opts: MatchmakeOptions): Promise<Matchmaking<TState>>;
205
222
  /** A private lobby handle — a Matchmaking whose `session` is live immediately, plus the invite `code`. */
package/dist/index.js CHANGED
@@ -396,6 +396,7 @@ function attachSession(room) {
396
396
  }
397
397
  };
398
398
  }
399
+ var SEARCH_BACKOFF_MS = [1e3, 2e3, 4e3];
399
400
  var resolveAuth = (auth) => typeof auth === "function" ? auth() : auth;
400
401
  var matchJoinOptions = (opts) => ({
401
402
  gameId: opts.room,
@@ -469,36 +470,52 @@ async function matchmake(opts) {
469
470
  let queueRoom = null;
470
471
  let queueInfo = { position: 0, size: 0 };
471
472
  let cancelled = false;
473
+ let searching = false;
472
474
  const search = async () => {
473
- if (cancelled) return;
474
- session = null;
475
- matchRoom = null;
476
- if (queueRoom) {
477
- void queueRoom.leave();
478
- queueRoom = null;
479
- }
480
- queueInfo = { position: 0, size: 0 };
481
- let q;
475
+ if (cancelled || searching) return;
476
+ searching = true;
482
477
  try {
483
- q = await client.joinOrCreate(QUEUE_ROOM_TYPE, matchJoinOptions(opts));
484
- } catch (err) {
485
- if (!cancelled) emit("error", err);
486
- return;
487
- }
488
- if (cancelled) {
489
- void q.leave();
490
- return;
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;
491
518
  }
492
- queueRoom = q;
493
- q.onMessage("queue", (msg) => {
494
- queueInfo = { position: msg.position, size: msg.size };
495
- emit("queue", msg);
496
- });
497
- q.onMessage(
498
- "seat",
499
- (msg) => void consumeSeat(msg.reservation, msg.role)
500
- );
501
- emit("searching");
502
519
  };
503
520
  const consumeSeat = async (reservation, role) => {
504
521
  try {
@@ -530,7 +547,7 @@ async function matchmake(opts) {
530
547
  session = null;
531
548
  await search();
532
549
  };
533
- await search();
550
+ void search();
534
551
  return {
535
552
  get session() {
536
553
  return session;
@@ -548,6 +565,10 @@ async function matchmake(opts) {
548
565
  finish() {
549
566
  matchRoom?.send("match:finish", {});
550
567
  },
568
+ retry() {
569
+ if (cancelled || searching || queueRoom || matchRoom) return;
570
+ void search();
571
+ },
551
572
  cancel() {
552
573
  cancelled = true;
553
574
  if (matchRoom) void matchRoom.leave();
@@ -590,6 +611,8 @@ function wirePrivate(client, room) {
590
611
  finish() {
591
612
  matchRoom?.send("match:finish", {});
592
613
  },
614
+ retry() {
615
+ },
593
616
  cancel() {
594
617
  if (matchRoom) void matchRoom.leave();
595
618
  matchRoom = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@genex-ai/multiplayer",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "description": "The multiplayer client wrapper + shared room schema for genex.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",