@genex-ai/cli-demo 0.52.0-dev.111 → 0.52.0-dev.113

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@genex-ai/cli-demo",
3
- "version": "0.52.0-dev.111",
3
+ "version": "0.52.0-dev.113",
4
4
  "description": "Set up your ~/.claude workspace, authorize, create a game project, generate AI assets, and publish (genex CLI).",
5
5
  "type": "module",
6
6
  "bin": {
@@ -110,10 +110,11 @@ boot-path gate; `waitForAuth()` guards saves only.
110
110
  advanced case of calling the Genex API by hand. The state/leaderboard
111
111
  helpers below attach it automatically — prefer them; never hand-roll fetch
112
112
  calls to `/state` endpoints.
113
- - `getColyseusAuth()` → `{ embedToken } | undefined` — pass as `connect()`'s
114
- `auth` option (REQUIRED the relay rejects tokenless joins; guest tokens
115
- are accepted). Read it fresh at every `connect()` call; tokens rotate
116
- automatically (~every 10 minutes).
113
+ - `getColyseusAuth()` → `{ embedToken } | undefined` — relay credential
114
+ (REQUIRED; guest tokens are accepted). Pass `auth: () => getColyseusAuth()`
115
+ to both `connect()` and `matchmake()` so each explicit connect and every
116
+ automatic re-seat reads a fresh token. Tokens rotate automatically (~every
117
+ 10 minutes). Never cache the value across joins and never log it.
117
118
  - `on(event, cb)` → unsubscribe fn. Events: `"authenticated"`, `"guest"`,
118
119
  `"blocked"`, `"error"`. A mid-game sign-in fires `"authenticated"` after
119
120
  `"guest"` — progress saving can start right then, no reload.
@@ -325,8 +326,9 @@ Rules:
325
326
  - [ ] `sentryCanvasSnapshot(renderer.domElement)` runs after `renderer.render()`
326
327
  in the main loop (WebGL and WebGPU alike).
327
328
  - [ ] `genex.config.ts` includes `dashboardOrigins` (from `.genex/project.json`).
328
- - [ ] Multiplayer `connect()` and player-name UI await `waitForPlayer()` —
329
- NEVER `waitForAuth()` (guests would hang forever).
329
+ - [ ] Multiplayer and player-name UI await `waitForPlayer()` — NEVER
330
+ `waitForAuth()` (guests would hang forever). Both `connect()` and
331
+ `matchmake()` receive `auth: () => getColyseusAuth()`.
330
332
  - [ ] Saves/loads use the SDK helpers (`savePlayerState`/`loadPlayerState` for
331
333
  per-player progress, `saveWorldState`/`loadWorldState` for the shared
332
334
  world, `submitScore`/`getLeaderboard` for scores) — no hand-rolled fetch
@@ -360,8 +362,8 @@ Rules:
360
362
  - **Multiplayer `connect()` fails in local test mode** — by design: no relay
361
363
  credential exists there. Validate multiplayer on the hosted draft (the
362
364
  owner's session) or the published game, and say plainly when it wasn't.
363
- - **Multiplayer join rejected with 401** — `connect()` ran before
364
- `waitForPlayer()` resolved, without `auth: getColyseusAuth()!`, or with a
365
+ - **Multiplayer join rejected with 401** — the join ran before
366
+ `waitForPlayer()` resolved, without `auth: () => getColyseusAuth()`, or with a
365
367
  stale cached token on reconnect (read it fresh each call).
366
368
  - **Multiplayer join rejected with 403 "guest capacity"** — the room is at its
367
369
  guest limit; only signing in gets the player a seat right now. Surface the
@@ -303,6 +303,23 @@ function setPhase(phase: "loading" | "playing" | "paused" | "over" | "won") {
303
303
  - **Buttons react**: a hover/focus state (scale, glow, or an indicator chevron)
304
304
  plus a pressed state. Menus are keyboard-first — ↑/↓ moves focus, Enter
305
305
  activates, and the hovered/focused item is visibly selected.
306
+ - **A menu action must release focus before gameplay begins.** A clicked
307
+ `<button>` keeps browser focus, so a later gameplay Space/Enter can natively
308
+ activate that same button again and repeat Play, Cancel, Leave, or Requeue.
309
+ Blur inside every action handler before running the action:
310
+
311
+ ```ts
312
+ function wireButton(button: HTMLButtonElement, act: () => void) {
313
+ button.addEventListener("click", () => {
314
+ button.blur();
315
+ act();
316
+ });
317
+ }
318
+ ```
319
+
320
+ Scope ↑/↓/Enter menu navigation to menu/lobby phases only. When a phase
321
+ transition leaves a menu, also blur any focused button as a backstop:
322
+ `if (document.activeElement instanceof HTMLButtonElement) document.activeElement.blur();`
306
323
  - **Values tween.** Score, coins, timers tick to their new value; health bars
307
324
  slide. A number that teleports reads as a bug even when it's correct.
308
325
 
@@ -378,6 +395,9 @@ architecture and consume the shared style brief.
378
395
  - Layout shifting as numbers grow.
379
396
  - A fail state with no visible restart key, or a restart that reloads the page.
380
397
  - Buttons that render but don't emit the game's real input intents.
398
+ - A clicked menu button remains focused after entering gameplay, so Space/Enter
399
+ natively activates it again; or menu keyboard navigation still runs outside
400
+ the menu/lobby phase.
381
401
  - UI logic duplicating game rules and drifting out of sync.
382
402
  - A waiting/lobby overlay that can miss its dismissal — see the multiplayer
383
403
  skill's status-driven rule.
@@ -34,14 +34,15 @@ example, the shared-object/ball code, rotation, and host usage. Read
34
34
  ## Install
35
35
 
36
36
  ```bash
37
- npm i @genex-ai/multiplayer@^0.10.0
37
+ npm i @genex-ai/multiplayer@^0.10.1
38
38
  ```
39
39
 
40
- > Pin `@^0.10.0` (not a bare `npm i`): regional relay selection (`getColyseusUrls()` + `urls`)
40
+ > Pin `@^0.10.1` (not a bare `npm i`): live connected-player presence, supplier-form `connect()`
41
+ > auth, regional relay selection (`getColyseusUrls()` + `urls`)
41
42
  > landed in 0.10; confirmed object controls, snaps, host-tick teardown, and reconnect rebasing
42
43
  > in 0.9. An older resolve does not have those.
43
44
 
44
- This skill targets `@genex-ai/multiplayer` **≥ 0.10.0** (`objects`/`host` since 0.4;
45
+ This skill targets `@genex-ai/multiplayer` **≥ 0.10.1** (`objects`/`host` since 0.4;
45
46
  `matchmake()` since 0.5; private lobbies since 0.7; auto-reconnect + `inputs`/`onHostTick`
46
47
  since 0.8; soft ownership handoff since 0.8.4; confirmed controls, snap epochs, and host-tick
47
48
  lifecycle guarantees since 0.9; regional relay selection via `getColyseusUrls()` since 0.10).
@@ -55,6 +56,19 @@ ownership, match seating/adjudication, and rate/size caps — but a modified cli
55
56
  lie about its own position or score. Great for friends and casual lobbies; don't promise
56
57
  ranked-grade fairness.
57
58
 
59
+ ## Choose one net model first
60
+
61
+ - **`connect()` — shared world:** everyone for this slug shares a room. Use for
62
+ persistent/co-op spaces where the game may remain valid with one player.
63
+ Pass auth as a FUNCTION so each explicit connect attempt reads fresh; after
64
+ a terminal disconnect the game starts a new connect flow.
65
+ - **`matchmake()` — capped matches:** the queue seats players into separate
66
+ rooms. Use for duels, races, teams, and finite arenas. It requires
67
+ `genex.matchmaking` in `package.json`; auth is a FUNCTION; the handle may
68
+ replace `mm.session`, so the game polls and rebinds it.
69
+
70
+ Do not combine the requirements or silently convert one model into the other.
71
+
58
72
  ## Matchmaking (competitive presets — server-owned)
59
73
 
60
74
  When players should be **matched into separate capped rooms** rather than share one big room (a 1v1
@@ -64,16 +78,40 @@ HUD from `mm.matchmaking` (its `status`, `queue.position`, `players`/`opponents`
64
78
  `winCondition`), and switch to the game once `session` goes live:
65
79
 
66
80
  ```ts
67
- // Pass auth as a FUNCTION one matchmake() handle re-joins the queue/match many times (re-search,
68
- // requeue) over a session, and embed tokens rotate (~10 min). A function is read fresh each (re)join;
69
- // a static object goes stale and gets rejected mid-session.
70
- await waitForPlayer(); // identity gate first (guest OR signed-in) — same rule as connect()
71
- const mm = await matchmake<MyState>({ urls, room: slug, auth: () => getColyseusAuth() });
72
- mm.on('matched', () => {/* session is live — start the game */});
73
- // each frame: if (mm.session) renderGame(mm.session); else renderSearchingHud(mm.matchmaking);
74
- mm.on('matchEnded', ({ winnerId, scores, draw }) => {/* result screen */});
75
- mm.on('error', (e) => {/* queue join failed even after the SDK's automatic retries (backoff, ~10s)
76
- persistent, e.g. broken auth. Fix the cause, then call mm.retry(). */});
81
+ import { matchmake, type Session } from "@genex-ai/multiplayer";
82
+ import {
83
+ waitForPlayer, getColyseusAuth, getColyseusUrls,
84
+ } from "@genex-ai/embed-sdk";
85
+ import { GENEX } from "./genex.config";
86
+
87
+ const { user } = await waitForPlayer();
88
+ const mm = await matchmake<MyState>({
89
+ urls: getColyseusUrls(),
90
+ room: GENEX.slug,
91
+ name: user.name,
92
+ auth: () => getColyseusAuth(), // fresh for every queue join and re-seat
93
+ });
94
+
95
+ let wired: Session<MyState> | null = null;
96
+ function syncSession() {
97
+ const live = mm.session;
98
+ if (live === wired) return;
99
+ wired = live;
100
+ if (live) wireRoom(live); // attach leave/reconnect/disconnect + game listeners
101
+ }
102
+
103
+ mm.on("queue", (payload) => {
104
+ const q = payload as { position?: number; size?: number } | undefined;
105
+ updateQueue(q?.position ?? 0, q?.size ?? 0);
106
+ });
107
+ mm.on("matched", () => syncSession());
108
+ mm.on("matchStart", () => syncSession()); // duel/arena/teams only
109
+ mm.on("matchEnded", (result) => showResult(result)); // duel/arena/teams only
110
+ mm.on("error", (e) => showQueueError(e)); // after SDK retries; fix, then mm.retry()
111
+
112
+ // Every frame AND a low-rate timer: the handle swaps in a new Session after
113
+ // seating, terminal drop/requeue, and the next match.
114
+ syncSession();
77
115
 
78
116
  // Report ONLY your own outcome — the server adjudicates. Which call fits depends on the win condition:
79
117
  mm.eliminated(); // I'm out (lastStanding)
@@ -81,11 +119,22 @@ mm.score(1); // I scored (firstToScore / highScoreInTime)
81
119
  mm.finish(); // I finished the race (firstToFinish)
82
120
  ```
83
121
 
122
+ For `open`, `matchStart` and `matchEnded` never fire: poll
123
+ `mm.matchmaking.status`, `mm.matchmaking.players`, and `mm.session` instead.
124
+ The event listeners above are for the batteries-included presets and do not
125
+ replace `syncSession()`.
126
+
84
127
  Everything is **server-owned** — set once in `package.json` under `genex.matchmaking`, reported at
85
128
  `genex preview` AND `genex publish` (removing it from package.json clears the stored config on the
86
129
  next preview/publish); the client declares nothing. You never run matchmaking logic: the server
87
130
  owns the queue, roles, winner-stays, forfeit, timeout, and the win condition.
88
131
 
132
+ If `genex.matchmaking` is absent, unavailable, or names an unknown preset, the
133
+ relay falls back to `duel`: rooms of exactly two with the duel round loop.
134
+ That is silently wrong for most 3–64 player `open` games. A `matchmake()` game
135
+ must declare and preview/publish the intended block; a `connect()` shared-world
136
+ game does not use this block.
137
+
89
138
  ### WHEN to call `matchmake()` — it IS the "Play Online" button, never a boot call (MANDATORY)
90
139
 
91
140
  `matchmake()` is the ONE action that puts a player on the server: calling it enters the queue and
@@ -279,6 +328,33 @@ Two ways to present the lobby:
279
328
  re-matchmaking, they're already together. The transition into the match still keys off
280
329
  `status === 'playing'` first; the pad only sequences what happens after quorum.
281
330
 
331
+ #### Quorum loss after start is game-owned — enforce it independently
332
+
333
+ For `open`, `status` reaches `playing` once and NEVER regresses to `waiting`.
334
+ If live connectivity later falls below `minPlayers`, the relay preserves the
335
+ dropped player's seat during reconnection grace but deliberately leaves the
336
+ gameplay decision to you. `players` is the seated roster;
337
+ `connectedPlayers` / `session.activePlayers` excludes grace-window ghosts:
338
+
339
+ ```ts
340
+ const MIN_PLAYERS = 2;
341
+ function enforceQuorum() {
342
+ syncSession();
343
+ const connected = mm.session?.activePlayers.size ?? 0;
344
+ if (phase === "playing" && connected < MIN_PLAYERS) {
345
+ setPhase("lobby");
346
+ showNotice("Opponent disconnected — waiting for them or a replacement…");
347
+ }
348
+ }
349
+ setInterval(enforceQuorum, 250); // independent of a throttled/failed render loop
350
+ ```
351
+
352
+ Run the same check in the render/network pump for immediate response. The
353
+ 250 ms watchdog is the backstop: no code path may leave a quorum-required game
354
+ in `playing` below its connected minimum. A shared-world `connect()` game may
355
+ intentionally continue solo; choose that explicitly rather than inheriting this
356
+ match rule.
357
+
282
358
  **Private lobbies** (for `preset: 'private'`) don't use `matchmake()` — a host makes an invite code
283
359
  and friends join it; the lobby is persistent (rounds replay, nobody is evicted):
284
360
 
@@ -303,17 +379,17 @@ then gate `connect()` on `waitForPlayer()` — NOT `waitForAuth()`, which stays
303
379
  pending for guests and would keep them out of multiplayer forever:
304
380
 
305
381
  ```ts
306
- import { connect } from "@genex-ai/multiplayer";
382
+ import { connect, type Session } from "@genex-ai/multiplayer";
307
383
  import { waitForPlayer, getColyseusAuth, getColyseusUrls } from "@genex-ai/embed-sdk";
308
384
 
309
385
  type State = { x: number; z: number; q: number[] }; // YOUR per-player state (rotation as quaternion)
310
386
 
311
387
  const { user } = await waitForPlayer(); // player gate (guest OR signed-in) — rejects only if blocked
312
- const room = await connect<State>({
388
+ let room = await connect<State>({
313
389
  urls: getColyseusUrls(), // regional relays for this session (server-owned); SDK joins the fastest
314
390
  room: GENEX.slug, // the project slug — everyone with this id shares a room
315
391
  name: user.name, // display name — the server prefers the verified identity's name
316
- auth: getColyseusAuth()!, // REQUIRED — tokenless joins are rejected. Read fresh each connect; NEVER log it.
392
+ auth: () => getColyseusAuth(), // REQUIRED — fresh on every explicit connect attempt; NEVER log it.
317
393
  });
318
394
  ```
319
395
 
@@ -322,7 +398,7 @@ envelope. Object-heavy rooms amplify fanout; measure the exact game at 8/16/32/6
322
398
  supported count. Above the cap the relay opens another room for the same game. If the game needs one
323
399
  seated competitive world, use matchmaking rather than one large `connect()` room.
324
400
 
325
- ## Disconnects & reconnection (built in render it, don't rebuild it)
401
+ ## Disconnects: transient reconnect is built in; terminal rejoin is yours
326
402
 
327
403
  The SDK auto-reconnects after a network blip or brief signal loss: the relay holds your seat for a
328
404
  grace window (~30 s). A short blip keeps the same session id, ownership, and host. If a disconnected
@@ -331,15 +407,58 @@ return to its seat but is demoted. Long reconnects rebase remote smoothing rathe
331
407
  whole-map catch-up streak. Your UI still reflects connection state:
332
408
 
333
409
  ```ts
334
- room.on("reconnecting", ({ attempt }) => showOverlay(`Reconnecting… (${attempt})`));
335
- room.on("reconnected", () => hideOverlay());
336
- room.on("disconnect", (code) => {
337
- // Terminal: server restart, revoked session, or the link never came back.
338
- if (code === 4409) flushSaves(); // replaced by this player's OTHER tab/device — flush now
339
- // To play again, read a FRESH token and connect() anew — never reuse the old auth object.
340
- showMenu("Connection lost");
341
- });
342
- room.on("server:restart", () => flushSaves()); // the relay warns before a deploy save now
410
+ let intentionalLeave = false;
411
+ let rejoining = false;
412
+
413
+ function wireRoom(live: Session<State>) {
414
+ live.on("reconnecting", ({ attempt }) => showOverlay(`Reconnecting… (${attempt})`));
415
+ live.on("reconnected", () => hideOverlay());
416
+ live.on("disconnect", (code) => {
417
+ // 4409 means this player deliberately opened the game elsewhere. Rejoining
418
+ // here would evict the new tab, which would rejoin and evict this one forever.
419
+ if (code === 4409) {
420
+ flushSaves();
421
+ showMenu("This game is open in another tab or device.");
422
+ return;
423
+ }
424
+ if (!intentionalLeave) void rejoinShared();
425
+ });
426
+ live.on("server:restart", () => flushSaves());
427
+ }
428
+ wireRoom(room);
429
+
430
+ async function rejoinShared() {
431
+ if (rejoining) return;
432
+ rejoining = true;
433
+ showOverlay("Connection lost — rejoining…");
434
+ try {
435
+ let delay = 1_000;
436
+ while (!intentionalLeave) {
437
+ try {
438
+ const { user } = await waitForPlayer();
439
+ room = await connect<State>({
440
+ urls: getColyseusUrls(),
441
+ room: GENEX.slug,
442
+ name: user.name,
443
+ auth: () => getColyseusAuth(), // fresh on EVERY attempt
444
+ });
445
+ wireRoom(room); // installs this same connection/disconnect wiring again
446
+ hideOverlay();
447
+ return;
448
+ } catch {
449
+ await new Promise((resolve) => setTimeout(resolve, delay));
450
+ delay = Math.min(delay * 2, 10_000);
451
+ }
452
+ }
453
+ } finally {
454
+ rejoining = false;
455
+ }
456
+ }
457
+
458
+ function leaveShared() {
459
+ intentionalLeave = true;
460
+ room.leave();
461
+ }
343
462
  ```
344
463
 
345
464
  Keep your render loop running during `reconnecting` — remote players freeze briefly and then
@@ -376,8 +495,10 @@ fights (many writers). A ball on `objects` glides and has one owner. That's the
376
495
  - `room.me.snap(state)` — respawn/teleport/mode edge. Publishes a discontinuity epoch so remotes
377
496
  hard-reseed instead of interpolating from the old pose. Never use for ordinary movement.
378
497
  - `room.players` — fresh `Map` each read, **includes you** (skip `id === room.id`). Each value is
379
- `{ id, name, state, stateRaw }`: `state` is auto-smoothed (remotes) / live (you); `stateRaw` is
380
- the raw latest (hit-tests, discrete values).
498
+ `{ id, name, connected, state, stateRaw }`: `state` is auto-smoothed (remotes) / live (you);
499
+ `stateRaw` is the raw latest (hit-tests, discrete values). A reconnect-grace seat remains in this
500
+ map with `connected: false`.
501
+ - `room.activePlayers` — the connected-only subset of `room.players`; use its size for live quorum.
381
502
  - `room.objects` — shared objects nobody owns until claimed (a ball, an NPC):
382
503
  - `claim(id)` — **legacy** optimistic request. It flips local ownership immediately and is corrected
383
504
  if the relay rejects it. Keep only for reversible old-game behavior.
@@ -399,6 +520,12 @@ fights (many writers). A ball on `objects` glides and has one owner. That's the
399
520
  - `room.isHost` / `room.host` — you are (or who is) the elected authority. Use to pick the single
400
521
  writer of `shared` scores/rounds and the single simulator of host-owned objects. Settles within
401
522
  the first patch after connect — read in your loop / react to `on('host')`, not once.
523
+ `room.host === undefined` before that patch means **authority is not ready**:
524
+ do not start host-owned simulation and do not synthesize an "acting host" from
525
+ a locally sorted roster. Clients can briefly observe different rosters, so
526
+ that fallback can create multiple writers. Gate host-dependent initialization
527
+ until `host !== undefined`; if it remains unset while connected, expose it in
528
+ diagnostics and re-seat/reconnect instead of inventing authority.
402
529
  - `room.shared.get/set/keys` — key/value store (any JSON) for **slow agreed facts only**.
403
530
  - `room.on(event, cb)` → unsubscribe fn. Events: `'join'`/`'change'` `(id, state)` (also fire for
404
531
  you), `'leave'` `(id)`, `'shared'` `(key, value)`, `'object'` `(id)` (ownership handoff),
@@ -546,6 +673,22 @@ one neutral simulation (the host) runs the physics; everyone else sends **inputs
546
673
  Both patterns — the claim-on-touch Rapier proxy and the host-authoritative contest, plus surviving host
547
674
  migration and wiring the vendored controllers — are in [references/host-physics.md](references/host-physics.md).
548
675
 
676
+ ## Production supportability floor
677
+
678
+ Every multiplayer build ships three small, token-free diagnostics:
679
+
680
+ 1. A `BUILD` string, bumped for every preview/publish, shown in a quiet
681
+ menu/lobby corner, printed once to the console, and exposed on a read-only
682
+ game debug object.
683
+ 2. A 250 ms status line containing only:
684
+ `build · seated/connected players · phase · host · matchmaking status · last transition/cause · last network error`.
685
+ Keep it unobtrusive and never include embed auth, URLs containing credentials,
686
+ player tokens, or arbitrary server payloads.
687
+ 3. The independent connected-quorum watchdog above for games that cannot play solo.
688
+
689
+ These are production supportability, not a hidden test mode: a screenshot must
690
+ identify the build and network state without changing game behavior.
691
+
549
692
  ## Smoothness is felt, not seen — hand the feel to a human
550
693
 
551
694
  Lag and stutter are *motion over time*. A screenshot is one frozen instant, so **you cannot tell
@@ -633,8 +776,11 @@ host-driven saving works as long as ANY account is in the room.
633
776
 
634
777
  ## Checklist
635
778
 
636
- - [ ] `npm i @genex-ai/multiplayer@^0.10.0` (confirmed controls, snap epochs, reconnect-safe host ticks); config wired into the build.
779
+ - [ ] `npm i @genex-ai/multiplayer@^0.10.1` (connected presence, supplier auth, confirmed controls, snap epochs, reconnect-safe host ticks); config wired into the build.
637
780
  - [ ] `reconnecting`/`reconnected`/`disconnect` render an overlay (don't tear the scene down).
781
+ - [ ] `connect()` terminal `disconnect` starts a guarded backoff rejoin that reruns
782
+ `waitForPlayer()` and reads fresh auth on every attempt; deliberate leave stops it;
783
+ replacement code `4409` NEVER auto-rejoins.
638
784
  - [ ] Numbers rounded (~2 decimals) before `me.set`/`objects.set`.
639
785
  - [ ] Pushable/ownable objects (ball, box, prop) use claim-on-touch + a Rapier proxy (the soft handoff glides the handoff); only a genuine simultaneous tug-of-war (sumo) uses the host-authoritative pattern. See host-physics.md.
640
786
  - [ ] Irreversible actions wait for `claimConfirmed`; held contact retries after `retryAfterMs` while still valid.
@@ -647,7 +793,7 @@ host-driven saving works as long as ANY account is in the room.
647
793
  stable embed `uid`, never the session id.
648
794
  - [ ] Host renders objects OWNED BY OTHERS from the stream (authority follows ownership).
649
795
  - [ ] `connect()` runs AFTER `await waitForPlayer()` (never `waitForAuth()` — guests would
650
- hang) and passes `auth: getColyseusAuth()!` (the relay rejects tokenless joins —
796
+ hang) and passes `auth: () => getColyseusAuth()` (the relay rejects tokenless joins —
651
797
  see `genex-threejs-embed-auth`).
652
798
  - [ ] `room` is the **project slug**.
653
799
  - [ ] `me.set` on a fixed **10–20 Hz** tick; full object each time.
@@ -658,13 +804,27 @@ host-driven saving works as long as ANY account is in the room.
658
804
  - [ ] Hit-tests and discrete values read from `stateRaw`, not `state`.
659
805
  - [ ] A ball / shared NPC is on `objects` (claim on contact), never on `shared`.
660
806
  - [ ] `shared` scores/rounds and host-simulated enemies are written only by `room.isHost`.
807
+ - [ ] Host-owned work waits for `room.host !== undefined`; no client-invented acting host.
661
808
  - [ ] `matchmake()` fires ONLY on the "Play Online" commit, never on page load / in boot code — the
662
809
  menu runs an offline world with NO relay contact; "Bots"/"Local" never call it; leaving online
663
810
  calls `mm.cancel()`.
811
+ - [ ] `matchmake()` polls `mm.session` and rebinds when it changes after seating or re-seat;
812
+ function-form auth is used.
664
813
  - [ ] Waiting room (if any): shown only AFTER "Play Online" (seated) and while under `minPlayers`;
665
814
  overlay driven by `mm.matchmaking.status` read every frame, gone the moment it flips to
666
815
  `'playing'` — and you WATCHED it close in two browser windows at `minPlayers` (never gated on
667
816
  `matchStart` or a host `shared` signal alone).
817
+ - [ ] Every menu/lobby action button blurs before acting, and menu Enter/arrow handling runs only
818
+ in menu/lobby phases.
819
+ - [ ] Multiplayer was exercised by two DISTINCT identities using real clicks and real key presses.
820
+ After clicking Play/Find Match, Space/Enter gameplay input does not repeat that menu action;
821
+ scripted `element.click()` is not evidence for this focus path.
822
+ - [ ] For a quorum-required matchmade game: close one client mid-match, watch connected quorum
823
+ (`activePlayers` / `connectedPlayers`) leave `playing`, then join a new distinct client and
824
+ watch it re-seat. For a shared-world `connect()` game, verify leave/host migration according
825
+ to that game's design instead of imposing a lobby.
826
+ - [ ] Build id is visible in menu/lobby, logged once, and exposed with token-free 250 ms network
827
+ telemetry; quorum-required games have the independent connected-quorum watchdog.
668
828
  - [ ] Team game: the HOST reconciles the balanced `id → team` map into `shared` (leavers dropped,
669
829
  newcomers to the smallest team); every client READS its team from `shared` — never computed
670
830
  per-client, never `mm.matchmaking.teams`, never a default for the unassigned — and you
@@ -674,8 +834,8 @@ host-driven saving works as long as ANY account is in the room.
674
834
  ## Troubleshooting auth
675
835
 
676
836
  - **`connect()` rejects with 401/403** — 401 "auth required"/"invalid token": you joined
677
- without `auth` or before `waitForPlayer()` resolved (or the token expired mid-reconnect
678
- read `getColyseusAuth()` fresh at every connect). 403 "wrong game": the `room` value
837
+ without `auth`, before `waitForPlayer()` resolved, or reused a stale token for a NEW terminal
838
+ rejoin — read `getColyseusAuth()` fresh at every connect. 403 "wrong game": the `room` value
679
839
  doesn't match this game's own slug. 403 "guest capacity": the room is at its guest
680
840
  limit — signing in gets the player a seat; surface the message as-is.
681
841
  - **`disconnect` fired and the player wants back in** — the old session is dead; run your
@@ -191,6 +191,9 @@ fourth who never comes.
191
191
  | Leaving online (back to menu / quit / switch to Bots after being seated) | Tear down the online view, return to the offline menu | `mm.cancel()` — frees the seat so you stop counting toward `minPlayers`. |
192
192
 
193
193
  **Decisions:**
194
+ - **Every menu action blurs its button before acting.** Otherwise the first gameplay Space/Enter
195
+ can natively activate the still-focused Play/Leave button again; scope menu keyboard handlers to
196
+ menu/lobby phases (see the game-ui skill).
194
197
  - **`matchmake()` is created lazily, on the click — not held from boot.** Keep the handle in a
195
198
  variable so you can `cancel()` it; create it inside the "Play Online" handler, not at module load.
196
199
  - **Bots/Local touch nothing networked.** They run the exact offline world the menu already booted.
@@ -208,15 +211,35 @@ fourth who never comes.
208
211
 
209
212
  ```ts
210
213
  let mm = null; // no relay contact yet — we're on the menu
214
+ let wired = null;
211
215
  bootOfflineWorld(); // local world under the menu overlay
212
- await waitForPlayer(); // identity token only; seats nobody
216
+ const { user } = await waitForPlayer(); // identity token only; seats nobody
217
+
218
+ function syncSession() {
219
+ const live = mm?.session ?? null;
220
+ if (live === wired) return;
221
+ wired = live;
222
+ if (live) wireRoom(live);
223
+ }
213
224
 
214
225
  onClick("play-online", async () => {
215
- mm = await matchmake({ urls, room: slug, auth: () => getColyseusAuth() });
226
+ mm = await matchmake({
227
+ urls: getColyseusUrls(),
228
+ room: GENEX.slug,
229
+ name: user.name,
230
+ auth: () => getColyseusAuth(),
231
+ });
232
+ mm.on("matched", syncSession);
233
+ mm.on("matchStart", syncSession); // preset-only; `open` never emits it
234
+ mm.on("queue", updateQueue);
235
+ mm.on("error", showQueueError);
216
236
  showWaitingOverlay(); // driven by mm.matchmaking.status, per SKILL.md
217
237
  });
218
238
  onClick("play-bots", () => startBots()); // offline; mm stays null
219
239
  onClick("leave-online", () => { mm?.cancel(); mm = null; returnToMenu(); });
240
+ // Poll every frame or on the independent 250ms network tick: a re-seat installs
241
+ // a NEW Session object; the old one is never mutated back to life.
242
+ syncSession();
220
243
  ```
221
244
 
222
245
  **Acceptance feel:** a player who picks Bots never appears in anyone's online room; the online
@@ -30,7 +30,7 @@ const room = await connect<S>({
30
30
  urls: getColyseusUrls(),
31
31
  room: GENEX.slug,
32
32
  name: user.name,
33
- auth: getColyseusAuth()!, // REQUIRED — tokenless joins are rejected. Read fresh each connect; NEVER log it.
33
+ auth: () => getColyseusAuth(), // REQUIRED — resolved fresh for this connect; NEVER log it.
34
34
  });
35
35
 
36
36
  // --- local player: input mutates this; we render yourself from it (zero latency) ---
@@ -90,8 +90,12 @@ execution order.
90
90
  **Multiplayer is mandatory routing:** if the game has 2+ players sharing a world, loading
91
91
  `$genex-threejs-multiplayer` is **required** before any networking code — the SDK auto-smooths
92
92
  remote players **and shared objects**, and gives you server-enforced object ownership (a ball) and
93
- a room `host` (scores, enemies). That skill covers the rules that keep it smooth and its per-genre
94
- recipes (sports/ball, shooter, co-op).
93
+ a room `host` (scores, enemies). Choose the net model before coding: `connect()` is one shared-world
94
+ room; `matchmake()` creates capped queued rooms. Only `matchmake()` requires a server-owned
95
+ `genex.matchmaking` block in `package.json`, reported by preview/publish. Both models accept a fresh
96
+ auth supplier, but their terminal-recovery duties differ, so follow the chosen model's section rather than
97
+ mixing the two. The skill also covers the rules that keep it smooth and its per-genre recipes
98
+ (sports/ball, shooter, co-op).
95
99
 
96
100
  ## Real (AI-generated) assets — `npx genex` commands
97
101