@genex-ai/multiplayer 0.10.0 → 0.10.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
@@ -22,14 +22,15 @@ const room = await connect<State>({
22
22
  urls: getColyseusUrls(), // regional relays for this session — SDK joins the fastest
23
23
  room: '<game-id>', // everyone with the same id shares a room (use the project slug)
24
24
  name: 'ada', // optional display name
25
- auth: getColyseusAuth()!, // REQUIRED — the relay rejects tokenless joins
25
+ auth: () => getColyseusAuth(), // REQUIRED — resolved fresh for this connect
26
26
  });
27
27
  ```
28
28
 
29
29
  `auth` is **required** — the relay rejects tokenless joins. Get it from `@genex-ai/embed-sdk`:
30
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.
31
+ and keeps guests out forever), then pass `() => getColyseusAuth()` so each explicit
32
+ `connect()` reads it fresh — tokens rotate every ~10 min, so never cache the object
33
+ across reconnects. Never log it.
33
34
 
34
35
  Different `room` ids are **fully isolated** — players in game A never see game B.
35
36
 
@@ -46,7 +47,7 @@ room.me.snap({ x: spawnX, y: spawnY, z: spawnZ }); // respawn/teleport: hard-res
46
47
 
47
48
  ## `players` — everyone in the room
48
49
 
49
- A fresh `Map` each read; **includes you** (skip with `id === room.id`). For remote players `p.state` is **auto-smoothed** (interpolated); for yourself it's live. `p.stateRaw` is always the latest raw snapshot (use it for hit-tests / logic).
50
+ A fresh `Map` each read; **includes you** (skip with `id === room.id`). For remote players `p.state` is **auto-smoothed** (interpolated); for yourself it's live. `p.stateRaw` is always the latest raw snapshot (use it for hit-tests / logic). A reserved reconnecting seat stays in `players` with `p.connected === false`; use `room.activePlayers` when gameplay needs the live transport quorum.
50
51
 
51
52
  ```ts
52
53
  for (const [id, p] of room.players) {
@@ -253,7 +254,7 @@ await waitForPlayer(); // identity gate (guest OR signed-in) — see Connect a
253
254
  const room = await connect<S>({
254
255
  urls: getColyseusUrls(),
255
256
  room: GAME_ID,
256
- auth: getColyseusAuth()!, // REQUIRED — tokenless joins are rejected
257
+ auth: () => getColyseusAuth(), // REQUIRED — resolved fresh for this connect
257
258
  });
258
259
 
259
260
  const me = { x: 0, z: 0 };
@@ -317,7 +318,7 @@ A failed queue join is **retried automatically** with backoff (~10s total), so t
317
318
  (a token mid-rotation, identity still settling on page load) never surface. Only a persistent
318
319
  failure emits `error`; the handle then idles until you call `mm.retry()`.
319
320
 
320
- **`mm.matchmaking`** (read every frame for your HUD): `status` (`searching` | `waiting` | `countdown` | `playing` | `ended`; the legacy `spectating` is never emitted — server-side spectators were removed), `queue` (`{ position, size }`), `players`, `opponents`, `teams`, `myTeam`, `scores`, `winCondition`, `config` (numeric knobs), `roundId`, `winnerId`, `message`. `championId`/`challengerId` are 1v1 aliases.
321
+ **`mm.matchmaking`** (read every frame for your HUD): `status` (`searching` | `waiting` | `countdown` | `playing` | `ended`; the legacy `spectating` is never emitted — server-side spectators were removed), `queue` (`{ position, size }`), `players` (reserved seats), `connectedPlayers` (live transports), `opponents`, `teams`, `myTeam`, `scores`, `winCondition`, `config` (numeric knobs), `roundId`, `winnerId`, `message`. `championId`/`challengerId` are 1v1 aliases. During an automatic reconnect, the SDK retains the last coherent roster and match view until the replacement room hydrates, so status does not transiently regress to `waiting`.
321
322
 
322
323
  Configure once in the game's `package.json` (reported at `genex preview` AND `genex publish`;
323
324
  removing it clears the stored config on the next preview/publish — never sent from the client):
package/dist/index.d.ts CHANGED
@@ -1,6 +1,10 @@
1
1
  export { PlayerState, RoomState } from './schema.js';
2
2
  import '@colyseus/schema';
3
3
 
4
+ type ColyseusAuth = {
5
+ embedToken: string;
6
+ };
7
+ type AuthSource = ColyseusAuth | (() => ColyseusAuth | undefined);
4
8
  type ConnectOptions = {
5
9
  /** Regional relay urls (pass `getColyseusUrls()`) — the SDK pings each and connects to the
6
10
  * lowest-latency one; a single entry just connects. */
@@ -17,18 +21,19 @@ type ConnectOptions = {
17
21
  * `genexEmbed.getColyseusAuth()` AFTER `await genexEmbed.waitForPlayer()`
18
22
  * (from `@genex-ai/embed-sdk`). NOT `waitForAuth()` — that resolves only for
19
23
  * signed-in accounts and would keep guests out of multiplayer forever.
20
- * Read it fresh at every connect() call — never cache the object across
21
- * reconnects (tokens rotate every ~10 min). NEVER log this value.
24
+ * Prefer a supplier `() => getColyseusAuth()` so every explicit connect()
25
+ * call reads the current token (tokens rotate every ~10 min). A current
26
+ * value remains supported for backward compatibility. NEVER log it.
22
27
  */
23
- auth?: {
24
- embedToken: string;
25
- };
28
+ auth?: AuthSource;
26
29
  /** Extra join options passed to the room. */
27
30
  options?: Record<string, unknown>;
28
31
  };
29
32
  type PlayerView<TState> = {
30
33
  id: string;
31
34
  name: string;
35
+ /** Transport is live now. False while this reserved seat is inside reconnect grace. */
36
+ connected: boolean;
32
37
  /** State to render — for remote players this is **auto-smoothed** (interpolated);
33
38
  * for yourself it's live. Numeric fields are lerped; everything else snaps. */
34
39
  state: TState;
@@ -81,6 +86,8 @@ type Session<TState extends Record<string, unknown>> = {
81
86
  * `state` is auto-smoothed; read `stateRaw` for the raw latest value.
82
87
  */
83
88
  readonly players: Map<string, PlayerView<TState>>;
89
+ /** Connected subset of `players`; excludes seats currently held only by reconnect grace. */
90
+ readonly activePlayers: Map<string, PlayerView<TState>>;
84
91
  readonly me: {
85
92
  /**
86
93
  * Publish your synced state (replaces it wholesale). Call on a **fixed tick**
@@ -198,7 +205,7 @@ type Session<TState extends Record<string, unknown>> = {
198
205
  * await waitForPlayer(); // identity gate (guest OR signed-in)
199
206
  * const room = await connect<{ x: number; y: number; z: number }>({
200
207
  * url: 'ws://localhost:2567', room: gameId, name: 'ada',
201
- * auth: getColyseusAuth()!, // REQUIRED — the relay rejects tokenless joins
208
+ * auth: () => getColyseusAuth(), // REQUIRED — fresh on every explicit connect call
202
209
  * });
203
210
  * room.me.set({ x, y, z }); // typed + validated by the compiler
204
211
  * room.players.get(id)?.state.x; // number — auto-smoothed for remote players
@@ -220,6 +227,8 @@ type MatchmakingView = {
220
227
  };
221
228
  /** Every active participant id (N-player: 1v1, FFA, teams). */
222
229
  players: string[];
230
+ /** Participants whose transport is live now; graced seats remain only in `players`. */
231
+ connectedPlayers: string[];
223
232
  /** The other active player id(s) — everyone in `players` except you. */
224
233
  opponents: string[];
225
234
  /** teamId -> member ids for team presets; empty for FFA + 1v1. */
@@ -276,11 +285,7 @@ type MatchmakeOptions = {
276
285
  * requeue). Pass a FUNCTION — `() => genexEmbed.getColyseusAuth()` — so each (re)join reads a FRESH
277
286
  * token; a static object is read only once and will be rejected once it expires.
278
287
  */
279
- auth?: {
280
- embedToken: string;
281
- } | (() => {
282
- embedToken: string;
283
- } | undefined);
288
+ auth?: AuthSource;
284
289
  };
285
290
  /**
286
291
  * Join a game's matchmaking. Returns immediately in the SEARCHING state (`session` is null); the
@@ -311,4 +316,4 @@ declare function createPrivate<TState extends Record<string, unknown> = never>(o
311
316
  */
312
317
  declare function joinPrivate<TState extends Record<string, unknown> = never>(code: string, opts: MatchmakeOptions): Promise<Matchmaking<TState>>;
313
318
 
314
- export { type ConnectOptions, type MatchStatus, type MatchmakeOptions, type Matchmaking, type MatchmakingView, type ObjectControlOperation, type ObjectControlOptions, type ObjectControlReason, type ObjectControlResult, type ObjectView, type PlayerView, type PrivateMatch, type Session, connect, createPrivate, joinPrivate, matchmake };
319
+ export { type AuthSource, type ColyseusAuth, type ConnectOptions, type MatchStatus, type MatchmakeOptions, type Matchmaking, type MatchmakingView, type ObjectControlOperation, type ObjectControlOptions, type ObjectControlReason, type ObjectControlResult, type ObjectView, type PlayerView, type PrivateMatch, type Session, connect, createPrivate, joinPrivate, matchmake };
package/dist/index.js CHANGED
@@ -33,6 +33,7 @@ async function relayClient(opts) {
33
33
  if (!url) throw new Error("genex multiplayer: `urls` must contain at least one relay URL");
34
34
  return new Client(url);
35
35
  }
36
+ var resolveAuth = (auth) => typeof auth === "function" ? auth() : auth;
36
37
  function parseData(s) {
37
38
  if (!s) return { state: {} };
38
39
  try {
@@ -161,7 +162,7 @@ async function connect(opts) {
161
162
  const room = await client.joinOrCreate(RELAY_ROOM_TYPE, {
162
163
  gameId: opts.room,
163
164
  name: opts.name,
164
- embedToken: opts.auth?.embedToken,
165
+ embedToken: resolveAuth(opts.auth)?.embedToken,
165
166
  ...opts.options,
166
167
  __v: MP_PROTOCOL
167
168
  // tells the relay this client hides reserved `__` keys (see MP_PROTOCOL)
@@ -368,6 +369,7 @@ function attachSession(client, initialRoom) {
368
369
  };
369
370
  const parseCache = /* @__PURE__ */ new Map();
370
371
  const playerEpochs = /* @__PURE__ */ new Map();
372
+ let lastPlayersView = /* @__PURE__ */ new Map();
371
373
  const cachedParse = (id, raw) => {
372
374
  if (!raw) return {};
373
375
  const hit = parseCache.get(id);
@@ -658,7 +660,6 @@ function attachSession(client, initialRoom) {
658
660
  try {
659
661
  const next = await client.reconnect(token);
660
662
  room = next;
661
- connected = true;
662
663
  const reconnectGap = reconnectStartedAt ? nowMs() - reconnectStartedAt : 0;
663
664
  reconnectStartedAt = 0;
664
665
  if (reconnectGap > 1e3) {
@@ -667,6 +668,7 @@ function attachSession(client, initialRoom) {
667
668
  const hydrated = wireRoom(next);
668
669
  await Promise.race([hydrated, new Promise((r) => setTimeout(r, 2e3))]);
669
670
  if (room !== next || dead || intentionalLeave) return;
671
+ connected = true;
670
672
  syncHostTicks();
671
673
  for (const pending of pendingControls.values()) sendPendingControl(pending);
672
674
  emit("reconnected");
@@ -718,10 +720,22 @@ function attachSession(client, initialRoom) {
718
720
  } else {
719
721
  state = sampleSmoothed(id, localNow) ?? latest;
720
722
  }
721
- out.set(id, { id, name: p.name, state, stateRaw: raw });
723
+ out.set(id, {
724
+ id,
725
+ name: p.name,
726
+ connected: p.connected !== false,
727
+ // old relay schemas did not carry the field
728
+ state,
729
+ stateRaw: raw
730
+ });
722
731
  });
732
+ if (!connected && out.size === 0 && lastPlayersView.size > 0) return new Map(lastPlayersView);
733
+ lastPlayersView = out;
723
734
  return out;
724
735
  },
736
+ get activePlayers() {
737
+ return new Map([...session.players].filter(([, player]) => player.connected));
738
+ },
725
739
  me: {
726
740
  set(state) {
727
741
  if (SEND_TIME_KEY in state) {
@@ -894,7 +908,6 @@ function attachSession(client, initialRoom) {
894
908
  return session;
895
909
  }
896
910
  var SEARCH_BACKOFF_MS = [1e3, 2e3, 4e3];
897
- var resolveAuth = (auth) => typeof auth === "function" ? auth() : auth;
898
911
  var matchJoinOptions = (opts) => ({
899
912
  gameId: opts.room,
900
913
  name: opts.name,
@@ -911,6 +924,7 @@ function buildView(matchRoom, queueInfo) {
911
924
  role: "player",
912
925
  queue: queueInfo,
913
926
  players: [],
927
+ connectedPlayers: [],
914
928
  opponents: [],
915
929
  teams: {},
916
930
  myTeam: null,
@@ -929,6 +943,7 @@ function buildView(matchRoom, queueInfo) {
929
943
  const challengerId = m.challengerId ?? null;
930
944
  const teams = m.teams ?? {};
931
945
  const players = m.players ?? [championId, challengerId].filter((x) => !!x);
946
+ const connectedPlayers = m.connectedPlayers ?? players;
932
947
  const isPlayer = myId != null && players.includes(myId);
933
948
  const status = isPlayer ? m.phase : "spectating";
934
949
  const opponents = players.filter((id) => id !== myId);
@@ -939,6 +954,7 @@ function buildView(matchRoom, queueInfo) {
939
954
  role: isPlayer ? "player" : "spectator",
940
955
  queue: { position: 0, size: 0 },
941
956
  players,
957
+ connectedPlayers,
942
958
  opponents,
943
959
  teams,
944
960
  myTeam,
@@ -968,9 +984,13 @@ async function matchmake(opts) {
968
984
  let queueInfo = { position: 0, size: 0 };
969
985
  let cancelled = false;
970
986
  let searching = false;
987
+ let lifecycleEpoch = 0;
988
+ let seating = false;
989
+ let requeueing = false;
971
990
  const search = async () => {
972
991
  if (cancelled || searching) return;
973
992
  searching = true;
993
+ const epoch = ++lifecycleEpoch;
974
994
  try {
975
995
  session = null;
976
996
  matchRoom = null;
@@ -994,7 +1014,7 @@ async function matchmake(opts) {
994
1014
  if (cancelled) return;
995
1015
  continue;
996
1016
  }
997
- if (cancelled) {
1017
+ if (cancelled || epoch !== lifecycleEpoch) {
998
1018
  void q.leave();
999
1019
  return;
1000
1020
  }
@@ -1003,10 +1023,13 @@ async function matchmake(opts) {
1003
1023
  queueInfo = { position: msg.position, size: msg.size };
1004
1024
  emit("queue", msg);
1005
1025
  });
1006
- q.onMessage(
1007
- "seat",
1008
- (msg) => void consumeSeat(msg.reservation, msg.role)
1009
- );
1026
+ q.onMessage("seat", (msg) => {
1027
+ if (cancelled || epoch !== lifecycleEpoch || queueRoom !== q || seating) return;
1028
+ seating = true;
1029
+ queueRoom = null;
1030
+ void q.leave();
1031
+ void consumeSeat(msg.reservation, msg.role, epoch);
1032
+ });
1010
1033
  q.onLeave(() => {
1011
1034
  if (cancelled || queueRoom !== q) return;
1012
1035
  queueRoom = null;
@@ -1026,10 +1049,10 @@ async function matchmake(opts) {
1026
1049
  searching = false;
1027
1050
  }
1028
1051
  };
1029
- const consumeSeat = async (reservation, role) => {
1052
+ const consumeSeat = async (reservation, role, epoch) => {
1030
1053
  try {
1031
1054
  const mr = await client.consumeSeatReservation(reservation);
1032
- if (cancelled) {
1055
+ if (cancelled || epoch !== lifecycleEpoch) {
1033
1056
  void mr.leave();
1034
1057
  return;
1035
1058
  }
@@ -1038,7 +1061,9 @@ async function matchmake(opts) {
1038
1061
  session = s;
1039
1062
  s.on("match:start", (m) => emit("matchStart", m));
1040
1063
  s.on("match:ended", (m) => emit("matchEnded", m));
1041
- s.on("match:requeue", () => void requeue());
1064
+ s.on("match:requeue", () => {
1065
+ if (session === s) void requeue();
1066
+ });
1042
1067
  s.on("disconnect", () => {
1043
1068
  if (!cancelled && session === s) void requeue();
1044
1069
  });
@@ -1049,23 +1074,50 @@ async function matchmake(opts) {
1049
1074
  emit("matched");
1050
1075
  if (role === "spectator") emit("spectate");
1051
1076
  } catch {
1052
- void search();
1077
+ if (!cancelled && epoch === lifecycleEpoch) {
1078
+ seating = false;
1079
+ void search();
1080
+ }
1081
+ } finally {
1082
+ if (epoch === lifecycleEpoch) seating = false;
1053
1083
  }
1054
1084
  };
1055
1085
  const requeue = async () => {
1056
- session?.leave();
1057
- matchRoom = null;
1058
- session = null;
1059
- await search();
1086
+ if (cancelled || requeueing) return;
1087
+ requeueing = true;
1088
+ try {
1089
+ lifecycleEpoch++;
1090
+ seating = false;
1091
+ session?.leave();
1092
+ matchRoom = null;
1093
+ session = null;
1094
+ await search();
1095
+ } finally {
1096
+ requeueing = false;
1097
+ }
1060
1098
  };
1061
1099
  void search();
1062
1100
  const liveRoom = () => session ? roomOf(session) : null;
1101
+ let viewSession = null;
1102
+ let lastHydratedView = null;
1103
+ const stableView = () => {
1104
+ if (session !== viewSession) {
1105
+ viewSession = session;
1106
+ lastHydratedView = null;
1107
+ }
1108
+ const live = liveRoom();
1109
+ const hydrated = live?.state?.shared?.get(MATCH_KEY) != null;
1110
+ if (!hydrated && session && lastHydratedView) return lastHydratedView;
1111
+ const next = buildView(live, queueInfo);
1112
+ if (hydrated) lastHydratedView = next;
1113
+ return next;
1114
+ };
1063
1115
  return {
1064
1116
  get session() {
1065
1117
  return session;
1066
1118
  },
1067
1119
  get matchmaking() {
1068
- return buildView(liveRoom(), queueInfo);
1120
+ return stableView();
1069
1121
  },
1070
1122
  on,
1071
1123
  eliminated() {
@@ -1083,6 +1135,8 @@ async function matchmake(opts) {
1083
1135
  },
1084
1136
  cancel() {
1085
1137
  cancelled = true;
1138
+ lifecycleEpoch++;
1139
+ seating = false;
1086
1140
  session?.leave();
1087
1141
  if (!session && matchRoom) void matchRoom.leave();
1088
1142
  if (queueRoom) void queueRoom.leave();
package/dist/schema.d.ts CHANGED
@@ -5,10 +5,12 @@ declare const PlayerState: _colyseus_schema.SchemaWithExtendsConstructor<{
5
5
  name: "string";
6
6
  data: "string";
7
7
  epoch: "number";
8
+ connected: "boolean";
8
9
  }, _colyseus_schema.AssignableProps<_colyseus_schema.InferSchemaInstanceType<{
9
10
  name: "string";
10
11
  data: "string";
11
12
  epoch: "number";
13
+ connected: "boolean";
12
14
  }>>, typeof _colyseus_schema.Schema>;
13
15
  type PlayerState = SchemaType<typeof PlayerState>;
14
16
  declare const RoomState: _colyseus_schema.SchemaWithExtendsConstructor<{
@@ -17,15 +19,18 @@ declare const RoomState: _colyseus_schema.SchemaWithExtendsConstructor<{
17
19
  name: "string";
18
20
  data: "string";
19
21
  epoch: "number";
22
+ connected: "boolean";
20
23
  }, _colyseus_schema.AssignableProps<_colyseus_schema.InferSchemaInstanceType<{
21
24
  name: "string";
22
25
  data: "string";
23
26
  epoch: "number";
27
+ connected: "boolean";
24
28
  }>>, typeof _colyseus_schema.Schema>;
25
29
  default: MapSchema<_colyseus_schema.InferSchemaInstanceType<{
26
30
  name: "string";
27
31
  data: "string";
28
32
  epoch: "number";
33
+ connected: "boolean";
29
34
  }>, string>;
30
35
  };
31
36
  shared: {
@@ -38,15 +43,18 @@ declare const RoomState: _colyseus_schema.SchemaWithExtendsConstructor<{
38
43
  name: "string";
39
44
  data: "string";
40
45
  epoch: "number";
46
+ connected: "boolean";
41
47
  }, _colyseus_schema.AssignableProps<_colyseus_schema.InferSchemaInstanceType<{
42
48
  name: "string";
43
49
  data: "string";
44
50
  epoch: "number";
51
+ connected: "boolean";
45
52
  }>>, typeof _colyseus_schema.Schema>;
46
53
  default: MapSchema<_colyseus_schema.InferSchemaInstanceType<{
47
54
  name: "string";
48
55
  data: "string";
49
56
  epoch: "number";
57
+ connected: "boolean";
50
58
  }>, string>;
51
59
  };
52
60
  shared: {
package/dist/schema.js CHANGED
@@ -6,7 +6,10 @@ var PlayerState = schema(
6
6
  data: "string",
7
7
  // Relay-owned discontinuity epoch. Ordinary state updates keep it stable;
8
8
  // state:snap increments it so new SDKs discard interpolation history.
9
- epoch: "number"
9
+ epoch: "number",
10
+ // Seat presence is distinct from connectivity: a dropped player keeps the
11
+ // seat during reconnection grace but flips connected=false immediately.
12
+ connected: "boolean"
10
13
  },
11
14
  "PlayerState"
12
15
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@genex-ai/multiplayer",
3
- "version": "0.10.0",
3
+ "version": "0.10.1",
4
4
  "description": "The multiplayer client wrapper + shared room schema for genex.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",