@genex-ai/multiplayer 0.4.0 → 0.7.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
@@ -169,6 +169,68 @@ onKey('right', () => { me.x += 1; });
169
169
 
170
170
  ---
171
171
 
172
+ ## `matchmake` — competitive matches (server-owned)
173
+
174
+ When players should be **matched into separate capped rooms** instead of sharing one big room (1v1, FFA, teams, invite lobby), use `matchmake()` instead of `connect()`. The queue, roles, winner-stays, forfeit, timeout, and win condition are all **server-owned** — the client declares nothing and runs no matchmaking logic.
175
+
176
+ ```ts
177
+ import { matchmake } from "@genex-ai/multiplayer";
178
+
179
+ // Pass `auth` as a FUNCTION — a handle re-joins many times (re-search/requeue) and tokens rotate
180
+ // (~10 min); a function is read fresh each join, a static object goes stale mid-session.
181
+ const mm = await matchmake<State>({ url, room: slug, auth: () => getColyseusAuth() });
182
+
183
+ // `session` is null WHILE SEARCHING — render your own "finding a match…" HUD until it goes live.
184
+ // each frame:
185
+ if (mm.session) renderGame(mm.session); // a live Session (same API as connect())
186
+ else renderSearchingHud(mm.matchmaking);
187
+
188
+ mm.on("matched", () => {/* session is live */});
189
+ mm.on("matchStart", ({ roundId }) => {/* round began */});
190
+ mm.on("matchEnded", ({ winnerId, scores, draw }) => {/* result screen */});
191
+ mm.on("spectate", () => {/* seated as a spectator (overflow) — read-only */});
192
+
193
+ // Report ONLY your own outcome — the server adjudicates. Pick the call that matches the win condition:
194
+ mm.eliminated(); // I'm out (lastStanding)
195
+ mm.score(1); // I scored (firstToScore / highScoreInTime)
196
+ mm.finish(); // I finished the race (firstToFinish)
197
+
198
+ mm.cancel(); // stop searching / leave the match
199
+ ```
200
+
201
+ **`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
+
203
+ Configure once in the game's `package.json` (reported at publish; never sent from the client):
204
+
205
+ ```jsonc
206
+ "genex": {
207
+ "matchmaking": {
208
+ "preset": "arena", // duel | arena | teams | private
209
+ "winCondition": "firstToScore", // lastStanding | firstToScore | highScoreInTime | firstToFinish
210
+ "config": { "scoreTarget": 20, "maxPlayers": 8 } // numeric knobs, optional
211
+ }
212
+ }
213
+ ```
214
+
215
+ Presets: **duel** (1v1 winner-stays), **arena** (N-player FFA, join-anytime), **teams** (balanced N-v-N), **private** (invite-code lobby). Win conditions: **lastStanding**, **firstToScore**, **highScoreInTime**, **firstToFinish**; a round that hits the time cap undecided is a draw.
216
+
217
+ ### Private lobbies — `createPrivate` / `joinPrivate`
218
+
219
+ For `preset: 'private'` games, skip the queue: a host mints an invite **code**, friends join it, and everyone lands in the SAME persistent lobby (rounds replay, nobody is evicted). Both handles have the same shape as `matchmake()`, but `session` is live on return (no searching phase).
220
+
221
+ ```ts
222
+ import { createPrivate, joinPrivate } from "@genex-ai/multiplayer";
223
+
224
+ // host
225
+ const lobby = await createPrivate<State>({ url, room: slug, auth: () => getColyseusAuth() });
226
+ share(lobby.code); // e.g. show it in the UI
227
+
228
+ // friend (elsewhere)
229
+ const joined = await joinPrivate<State>(code, { url, room: slug, auth: () => getColyseusAuth() });
230
+
231
+ // then identical to matchmake(): .session, .matchmaking, eliminated()/score()/finish(), cancel()
232
+ ```
233
+
172
234
  ## Rules of thumb
173
235
 
174
236
  - **`me` / `shared` are synced; everything else is local.** Movement input → mutate a local object → `me.set` it on the tick.
@@ -181,13 +243,18 @@ onKey('right', () => { me.x += 1; });
181
243
 
182
244
  ## Persistent worlds (optional)
183
245
 
184
- The relay is in-memory — room state is gone when everyone leaves or the server restarts. To persist world state across restarts, save/load a JSON blob via the API (keyed by the project slug):
246
+ The relay is in-memory — room state is gone when everyone leaves or the server restarts. To persist world state across restarts, use `@genex-ai/embed-sdk`'s state helpers (they own auth tokens, guest handling, and write-conflict decoding):
185
247
 
186
248
  ```ts
249
+ import { loadWorldState, saveWorldState } from '@genex-ai/embed-sdk';
250
+
187
251
  // load on boot
188
- const { data } = await fetch(`${API}/api/projects/${slug}/state`).then(r => r.json());
252
+ const { data, version } = await loadWorldState();
189
253
  initWorld(data ?? defaultWorld());
190
254
 
191
- // save whenever (from ONE authority — e.g. a host client — to avoid races)
192
- fetch(`${API}/api/projects/${slug}/state`, { method: 'PUT', body: JSON.stringify(world) });
255
+ // save (from ONE authority — the elected host — debounced, with ifVersion so
256
+ // races surface as { conflict: true } instead of silently clobbering)
257
+ if (room.isHost) await saveWorldState(world, { ifVersion: version });
193
258
  ```
259
+
260
+ The relay prefers signed-in players when electing the host (guests cannot write saves), so host-driven persistence works whenever any account is in the room. Per-player progression belongs in `savePlayerState()` (each player's own slot), never in the shared world blob.
package/dist/index.d.ts CHANGED
@@ -128,5 +128,97 @@ type Session<TState extends Record<string, unknown>> = {
128
128
  * automatically (entity interpolation) — `state` is smooth, `stateRaw` is raw.
129
129
  */
130
130
  declare function connect<TState extends Record<string, unknown> = never>(opts: ConnectOptions): Promise<Session<TState>>;
131
+ type MatchStatus = 'searching' | 'waiting' | 'countdown' | 'playing' | 'ended' | 'spectating';
132
+ type MatchmakingView = {
133
+ status: MatchStatus;
134
+ role: 'player' | 'spectator';
135
+ /** Your place in the search line while `status === 'searching'`. */
136
+ queue: {
137
+ position: number;
138
+ size: number;
139
+ };
140
+ /** Every active participant id (N-player: 1v1, FFA, teams). */
141
+ players: string[];
142
+ /** The other active player id(s) — everyone in `players` except you. */
143
+ opponents: string[];
144
+ /** teamId -> member ids for team presets; empty for FFA + 1v1. */
145
+ teams: Record<string, string[]>;
146
+ /** Your team id, if the preset uses teams and you're an active player; else null. */
147
+ myTeam: string | null;
148
+ scores: Record<string, number>;
149
+ /** The server-owned numeric preset knobs in effect (e.g. { countdownMs, maxRoundMs, scoreTarget }). */
150
+ config: Record<string, number>;
151
+ /** The win condition this game runs: 'lastStanding' | 'firstToScore' | 'highScoreInTime' | 'firstToFinish'. */
152
+ winCondition: string;
153
+ /** 1v1 (duel) convenience aliases; null for presets without a champion/challenger role. */
154
+ championId: string | null;
155
+ challengerId: string | null;
156
+ roundId: number;
157
+ /** Winner of the round shown while `status === 'ended'` (competitor id; null on a draw). */
158
+ winnerId: string | null;
159
+ message: string;
160
+ };
161
+ type Matchmaking<TState extends Record<string, unknown>> = {
162
+ /** The live game session, or null while searching for a match. */
163
+ readonly session: Session<TState> | null;
164
+ /** Read every frame to render your matchmaking HUD. */
165
+ readonly matchmaking: MatchmakingView;
166
+ /**
167
+ * searching: (re)entered the queue. queue: your position changed ({position,size}).
168
+ * matched: `session` is now live. matchStart: a round began ({roundId}).
169
+ * matchEnded: {winnerId,loserId,draw,scores}. spectate: you're watching, not playing.
170
+ */
171
+ on(event: 'searching' | 'queue' | 'matched' | 'matchStart' | 'matchEnded' | 'spectate' | 'error', cb: (payload?: unknown) => void): () => void;
172
+ /** Report your OWN elimination (the `lastStanding` win condition). */
173
+ eliminated(): void;
174
+ /** Report a score delta for yourself (the `firstToScore` / `highScoreInTime` conditions). */
175
+ score(delta?: number): void;
176
+ /** Report that you finished (the `firstToFinish` win condition — a race). */
177
+ finish(): void;
178
+ /** Stop searching / leave the current match. */
179
+ cancel(): void;
180
+ };
181
+ type MatchmakeOptions = {
182
+ /** Colyseus server url. */
183
+ url: string;
184
+ /** Game id (your project slug/id) — isolates this game's matchmaking. */
185
+ room: string;
186
+ name?: string;
187
+ /**
188
+ * Verified-identity credential (see ConnectOptions.auth). Required to join. Tokens rotate (~10 min),
189
+ * and a single matchmake() handle re-joins the queue/match many times over its life (re-search,
190
+ * requeue). Pass a FUNCTION — `() => genexEmbed.getColyseusAuth()` — so each (re)join reads a FRESH
191
+ * token; a static object is read only once and will be rejected once it expires.
192
+ */
193
+ auth?: {
194
+ embedToken: string;
195
+ } | (() => {
196
+ embedToken: string;
197
+ } | undefined);
198
+ };
199
+ /**
200
+ * Join a game's matchmaking. Returns immediately in the SEARCHING state (`session` is null); the
201
+ * matchmaker seats you into a match room, at which point `session` becomes a live Session and a
202
+ * `matched` event fires. The preset + config are owned by the project (server-side).
203
+ */
204
+ declare function matchmake<TState extends Record<string, unknown> = never>(opts: MatchmakeOptions): Promise<Matchmaking<TState>>;
205
+ /** A private lobby handle — a Matchmaking whose `session` is live immediately, plus the invite `code`. */
206
+ type PrivateMatch<TState extends Record<string, unknown>> = Matchmaking<TState> & {
207
+ /** Share this with friends; they pass it to `joinPrivate()` to land in the SAME lobby. */
208
+ readonly code: string;
209
+ };
210
+ /**
211
+ * Create a PRIVATE lobby for a game and return a shareable invite `code`. Unlike matchmake() there's
212
+ * no queue: you're the host, `session` is live immediately, and friends join the SAME lobby with
213
+ * `joinPrivate(code, …)`. The preset + rules are still server-owned (the game's config) — a game that
214
+ * wants invite lobbies publishes `preset: 'private'`, a persistent lobby that replays rounds without
215
+ * evicting anyone.
216
+ */
217
+ declare function createPrivate<TState extends Record<string, unknown> = never>(opts: MatchmakeOptions): Promise<PrivateMatch<TState>>;
218
+ /**
219
+ * Join an existing private lobby by its invite `code` (from createPrivate()). `session` is live on
220
+ * return — render the game immediately (there is no searching phase for a private lobby).
221
+ */
222
+ declare function joinPrivate<TState extends Record<string, unknown> = never>(code: string, opts: MatchmakeOptions): Promise<Matchmaking<TState>>;
131
223
 
132
- export { type ConnectOptions, type ObjectView, type PlayerView, type Session, connect };
224
+ export { type ConnectOptions, type MatchStatus, type MatchmakeOptions, type Matchmaking, type MatchmakingView, type ObjectView, type PlayerView, type PrivateMatch, type Session, connect, createPrivate, joinPrivate, matchmake };
package/dist/index.js CHANGED
@@ -1,6 +1,8 @@
1
1
  // src/index.ts
2
2
  import { Client, getStateCallbacks } from "colyseus.js";
3
3
  var RELAY_ROOM_TYPE = "play";
4
+ var QUEUE_ROOM_TYPE = "queue";
5
+ var MATCH_ROOM_TYPE = "match";
4
6
  var INTERP_RATIO = 1.5;
5
7
  var INTERP_MIN_MS = 50;
6
8
  var INTERP_MAX_MS = 300;
@@ -13,6 +15,7 @@ var SEND_TIME_KEY = "__t";
13
15
  var RESERVED_PREFIX = "__";
14
16
  var OBJ_PREFIX = "__obj:";
15
17
  var HOST_KEY = "__host";
18
+ var MATCH_KEY = "__match";
16
19
  var MP_PROTOCOL = 1;
17
20
  function parseData(s) {
18
21
  if (!s) return { state: {} };
@@ -132,6 +135,9 @@ async function connect(opts) {
132
135
  __v: MP_PROTOCOL
133
136
  // tells the relay this client hides reserved `__` keys (see MP_PROTOCOL)
134
137
  });
138
+ return attachSession(room);
139
+ }
140
+ function attachSession(room) {
135
141
  const buffers = /* @__PURE__ */ new Map();
136
142
  const lastStamp = /* @__PURE__ */ new Map();
137
143
  const clockOffsets = /* @__PURE__ */ new Map();
@@ -226,6 +232,7 @@ async function connect(opts) {
226
232
  const handleShared = (value, key) => {
227
233
  if (lastShared.get(key) === value) return;
228
234
  lastShared.set(key, value);
235
+ if (key === MATCH_KEY) return;
229
236
  if (key === HOST_KEY) {
230
237
  const next = parseValue(value) || void 0;
231
238
  if (next !== hostId) {
@@ -389,6 +396,222 @@ async function connect(opts) {
389
396
  }
390
397
  };
391
398
  }
399
+ var resolveAuth = (auth) => typeof auth === "function" ? auth() : auth;
400
+ var matchJoinOptions = (opts) => ({
401
+ gameId: opts.room,
402
+ name: opts.name,
403
+ embedToken: resolveAuth(opts.auth)?.embedToken,
404
+ __v: MP_PROTOCOL
405
+ });
406
+ function buildView(matchRoom, queueInfo) {
407
+ const raw = matchRoom?.state?.shared?.get(MATCH_KEY);
408
+ const parsed = raw != null ? parseValue(raw) : null;
409
+ const m = parsed && typeof parsed === "object" ? parsed : null;
410
+ if (!m) {
411
+ return {
412
+ status: matchRoom ? "waiting" : "searching",
413
+ role: "player",
414
+ queue: queueInfo,
415
+ players: [],
416
+ opponents: [],
417
+ teams: {},
418
+ myTeam: null,
419
+ scores: {},
420
+ config: {},
421
+ winCondition: "lastStanding",
422
+ championId: null,
423
+ challengerId: null,
424
+ roundId: 0,
425
+ winnerId: null,
426
+ message: matchRoom ? "Waiting for players\u2026" : "Finding a match\u2026"
427
+ };
428
+ }
429
+ const myId = matchRoom?.sessionId;
430
+ const championId = m.championId ?? null;
431
+ const challengerId = m.challengerId ?? null;
432
+ const teams = m.teams ?? {};
433
+ const players = m.players ?? [championId, challengerId].filter((x) => !!x);
434
+ const isPlayer = myId != null && players.includes(myId);
435
+ const status = isPlayer ? m.phase : "spectating";
436
+ const opponents = players.filter((id) => id !== myId);
437
+ const myTeam = myId ? Object.keys(teams).find((t) => teams[t]?.includes(myId)) ?? null : null;
438
+ const { winCondition: wc, ...numericCfg } = m.cfg ?? {};
439
+ return {
440
+ status,
441
+ role: isPlayer ? "player" : "spectator",
442
+ queue: { position: 0, size: 0 },
443
+ players,
444
+ opponents,
445
+ teams,
446
+ myTeam,
447
+ scores: m.scores ?? {},
448
+ config: numericCfg,
449
+ winCondition: wc?.type ?? "lastStanding",
450
+ championId,
451
+ challengerId,
452
+ roundId: m.roundId ?? 0,
453
+ winnerId: m.winnerId ?? null,
454
+ message: m.message ?? ""
455
+ };
456
+ }
457
+ async function matchmake(opts) {
458
+ const client = new Client(opts.url);
459
+ const emitters = /* @__PURE__ */ new Map();
460
+ const emit = (event, ...args) => emitters.get(event)?.forEach((cb) => cb(...args));
461
+ const on = ((event, cb) => {
462
+ let set = emitters.get(event);
463
+ if (!set) emitters.set(event, set = /* @__PURE__ */ new Set());
464
+ set.add(cb);
465
+ return () => void set?.delete(cb);
466
+ });
467
+ let session = null;
468
+ let matchRoom = null;
469
+ let queueRoom = null;
470
+ let queueInfo = { position: 0, size: 0 };
471
+ let cancelled = false;
472
+ 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;
482
+ 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;
491
+ }
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
+ };
503
+ const consumeSeat = async (reservation, role) => {
504
+ try {
505
+ const mr = await client.consumeSeatReservation(reservation);
506
+ if (cancelled) {
507
+ void mr.leave();
508
+ return;
509
+ }
510
+ 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());
515
+ if (queueRoom) {
516
+ void queueRoom.leave();
517
+ queueRoom = null;
518
+ }
519
+ emit("matched");
520
+ if (role === "spectator") emit("spectate");
521
+ } catch {
522
+ void search();
523
+ }
524
+ };
525
+ const requeue = async () => {
526
+ if (matchRoom) {
527
+ void matchRoom.leave();
528
+ matchRoom = null;
529
+ }
530
+ session = null;
531
+ await search();
532
+ };
533
+ await search();
534
+ return {
535
+ get session() {
536
+ return session;
537
+ },
538
+ get matchmaking() {
539
+ return buildView(matchRoom, queueInfo);
540
+ },
541
+ on,
542
+ eliminated() {
543
+ matchRoom?.send("match:eliminated", {});
544
+ },
545
+ score(delta = 1) {
546
+ matchRoom?.send("match:score", { delta });
547
+ },
548
+ finish() {
549
+ matchRoom?.send("match:finish", {});
550
+ },
551
+ cancel() {
552
+ cancelled = true;
553
+ if (matchRoom) void matchRoom.leave();
554
+ if (queueRoom) void queueRoom.leave();
555
+ matchRoom = null;
556
+ queueRoom = null;
557
+ session = null;
558
+ }
559
+ };
560
+ }
561
+ function wirePrivate(client, room) {
562
+ const emitters = /* @__PURE__ */ new Map();
563
+ const emit = (event, ...args) => emitters.get(event)?.forEach((cb) => cb(...args));
564
+ const on = ((event, cb) => {
565
+ let set = emitters.get(event);
566
+ if (!set) emitters.set(event, set = /* @__PURE__ */ new Set());
567
+ set.add(cb);
568
+ return () => void set?.delete(cb);
569
+ });
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));
576
+ return {
577
+ get session() {
578
+ return matchRoom ? session : null;
579
+ },
580
+ get matchmaking() {
581
+ return buildView(matchRoom, { position: 0, size: 0 });
582
+ },
583
+ on,
584
+ eliminated() {
585
+ matchRoom?.send("match:eliminated", {});
586
+ },
587
+ score(delta = 1) {
588
+ matchRoom?.send("match:score", { delta });
589
+ },
590
+ finish() {
591
+ matchRoom?.send("match:finish", {});
592
+ },
593
+ cancel() {
594
+ if (matchRoom) void matchRoom.leave();
595
+ matchRoom = null;
596
+ void client;
597
+ }
598
+ };
599
+ }
600
+ async function createPrivate(opts) {
601
+ const client = new Client(opts.url);
602
+ const room = await client.create(MATCH_ROOM_TYPE, matchJoinOptions(opts));
603
+ const handle = wirePrivate(client, room);
604
+ Object.defineProperty(handle, "code", { value: room.roomId, enumerable: true });
605
+ return handle;
606
+ }
607
+ async function joinPrivate(code, opts) {
608
+ const client = new Client(opts.url);
609
+ const room = await client.joinById(code, matchJoinOptions(opts));
610
+ return wirePrivate(client, room);
611
+ }
392
612
  export {
393
- connect
613
+ connect,
614
+ createPrivate,
615
+ joinPrivate,
616
+ matchmake
394
617
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@genex-ai/multiplayer",
3
- "version": "0.4.0",
3
+ "version": "0.7.0",
4
4
  "description": "The multiplayer client wrapper + shared room schema for genex.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -20,6 +20,12 @@
20
20
  "files": [
21
21
  "dist"
22
22
  ],
23
+ "scripts": {
24
+ "build": "tsup",
25
+ "typecheck": "tsc --noEmit",
26
+ "check": "pnpm build && publint && attw --pack . --profile esm-only",
27
+ "prepack": "pnpm build && publint"
28
+ },
23
29
  "publishConfig": {
24
30
  "access": "public"
25
31
  },
@@ -45,10 +51,5 @@
45
51
  "url": "git+https://github.com/me-ai-org/genex-demo.git",
46
52
  "directory": "packages/multiplayer"
47
53
  },
48
- "license": "MIT",
49
- "scripts": {
50
- "build": "tsup",
51
- "typecheck": "tsc --noEmit",
52
- "check": "pnpm build && publint && attw --pack . --profile esm-only"
53
- }
54
- }
54
+ "license": "MIT"
55
+ }