@genex-ai/cli-demo 0.14.2 → 0.16.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/dist/index.js CHANGED
@@ -1110,6 +1110,24 @@ async function detectMultiplayer(cwd = process.cwd()) {
1110
1110
  return false;
1111
1111
  }
1112
1112
  }
1113
+ async function detectMatchmaking(log, cwd = process.cwd()) {
1114
+ let pkg;
1115
+ try {
1116
+ pkg = JSON.parse(await fs8.readFile(path9.join(cwd, "package.json"), "utf8"));
1117
+ } catch (err) {
1118
+ log.dim(` (skipping matchmaking \u2014 couldn't read package.json: ${String(err)})`);
1119
+ return null;
1120
+ }
1121
+ const mm = pkg.genex?.matchmaking;
1122
+ if (mm && typeof mm.preset === "string" && mm.preset) {
1123
+ return {
1124
+ preset: mm.preset,
1125
+ ...mm.winCondition ? { winCondition: mm.winCondition } : {},
1126
+ ...mm.config ? { config: mm.config } : {}
1127
+ };
1128
+ }
1129
+ return null;
1130
+ }
1113
1131
  async function runPublish(opts) {
1114
1132
  const log = createLogger({ quiet: opts.quiet });
1115
1133
  log.plain(c.bold("genex publish"));
@@ -1151,6 +1169,8 @@ async function runPublish(opts) {
1151
1169
  const embedSdkVersion = await detectEmbedSdkVersion();
1152
1170
  if (embedSdkVersion) body.embedSdkVersion = embedSdkVersion;
1153
1171
  body.multiplayer = await detectMultiplayer();
1172
+ const matchmaking = await detectMatchmaking(log);
1173
+ if (matchmaking) body.matchmaking = matchmaking;
1154
1174
  res = await fetch(`${apiUrl}/api/projects/${meta.id}/publish`, {
1155
1175
  method: "POST",
1156
1176
  headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@genex-ai/cli-demo",
3
- "version": "0.14.2",
3
+ "version": "0.16.0",
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": {
@@ -1,15 +1,16 @@
1
1
  ---
2
2
  name: genex-threejs-embed-auth
3
- description: Wire up player identity for a Genex game via @genex-ai/embed-sdk. Load this UNCONDITIONALLY for every game, multiplayer or not, BEFORE writing any boot code — every player gets an identity (signed-in account or guest); saving progress requires sign-in, and multiplayer requires the SDK's token either way.
3
+ description: Wire up player identity AND durable game state for a Genex game via @genex-ai/embed-sdk. Load this UNCONDITIONALLY for every game, multiplayer or not, BEFORE writing any boot code — every player gets an identity (signed-in account or guest); per-player saves, shared world state, and leaderboards are one-line SDK calls; multiplayer requires the SDK's token either way.
4
4
  ---
5
5
 
6
6
  # Genex Three.js Embed Auth
7
7
 
8
- `@genex-ai/embed-sdk` is how a Genex game learns **who is playing it**. Every
9
- game needs it. Published games are playable by **guests** (no account — the
10
- SDK mints a temporary identity like `Guest-1234` automatically), while
11
- **signing in** unlocks saving/loading progress; multiplayer works for both,
12
- using the SDK's token. The SDK handles every context with one
8
+ `@genex-ai/embed-sdk` is how a Genex game learns **who is playing it** — and
9
+ how it **saves**. Every game needs it. Published games are playable by
10
+ **guests** (no account — the SDK mints a temporary identity like `Guest-1234`
11
+ automatically), while **signing in** unlocks saving/loading progress (each
12
+ player gets their own save slot) and leaderboard entries; multiplayer works
13
+ for both, using the SDK's token. The SDK handles every context with one
13
14
  `initEmbed(...)` call:
14
15
 
15
16
  - **Embedded in the Genex dashboard (an iframe):** a silent handshake signs
@@ -102,10 +103,10 @@ boot-path gate; `waitForAuth()` guards saves only.
102
103
  synchronous.
103
104
  - `getUser()` → `{ id, name, image? } | null` — non-null once authenticated OR
104
105
  guest. Guest ids are prefixed `guest:`.
105
- - `getEmbedToken()` → `string | undefined` — for `Authorization: Bearer` on
106
- `GET`/`PUT ${GENEX.apiUrl}/api/projects/${GENEX.slug}/state`. Defined for
107
- guests too, but `/state` answers guests with `403 { "error": "guest_no_save" }`
108
- that's why saves gate on `waitForAuth()`, not on the token existing.
106
+ - `getEmbedToken()` → `string | undefined` — the raw token, for the RARE
107
+ advanced case of calling the Genex API by hand. The state/leaderboard
108
+ helpers below attach it automatically prefer them; never hand-roll fetch
109
+ calls to `/state` endpoints.
109
110
  - `getColyseusAuth()` → `{ embedToken } | undefined` — pass as `connect()`'s
110
111
  `auth` option (REQUIRED — the relay rejects tokenless joins; guest tokens
111
112
  are accepted). Read it fresh at every `connect()` call; tokens rotate
@@ -114,6 +115,31 @@ boot-path gate; `waitForAuth()` guards saves only.
114
115
  `"blocked"`, `"error"`. A mid-game sign-in fires `"authenticated"` after
115
116
  `"guest"` — progress saving can start right then, no reload.
116
117
 
118
+ Durable state + leaderboards (all six are safe to call from boot — they wait
119
+ for identity internally, never throw for guests, and reject only when the
120
+ session is blocked):
121
+
122
+ - `loadPlayerState()` → `Promise<{ data, version, guest? }>` — THIS player's
123
+ own save (per-player, per-game; other players can never read or overwrite
124
+ it). `{ data: null, version: 0 }` when they never saved.
125
+ - `savePlayerState(data, { ifVersion? }?)` → `Promise<{ saved, version?,
126
+ conflict?, guest?, queued? }>` — save any JSON ≤ 256KB to their slot. For
127
+ guests the value is QUEUED in memory and auto-flushed if they sign in
128
+ mid-game — no extra code. Small saves survive tab close automatically.
129
+ - `loadWorldState()` / `saveWorldState(data, { ifVersion? }?)` — the game's
130
+ ONE shared world slot (≤ 1MB, every player reads/writes the same blob) —
131
+ level layouts, persistent-world object positions. Multiplayer games: only
132
+ the host writes it (see the multiplayer skill). Always pass `ifVersion`
133
+ here (shared slot = real races); a losing write resolves
134
+ `{ conflict: true, version }` — reload, merge, retry.
135
+ - `submitScore(score, { board?, mode? }?)` → `Promise<{ submitted, best?,
136
+ improved?, guest?, queued? }>` — keep-best leaderboard submit (`mode:
137
+ "min"` for lap-time boards). Guests: best value queues, flushes on sign-in.
138
+ - `getLeaderboard({ board?, limit?, order? }?)` → `Promise<{ items, me }>` —
139
+ top entries (verified display names — never trust client-side name input
140
+ for this) + the signed-in player's own `{ rank, score }`. Works for guests
141
+ too (`me: null`).
142
+
117
143
  From `@genex-ai/embed-sdk/sentry` (crash reporting; exactly these two):
118
144
 
119
145
  - `initGameSentry({ slug, dsn?, environment? })` — call once, BEFORE
@@ -134,30 +160,53 @@ function animate() {
134
160
  }
135
161
  ```
136
162
 
137
- ## Saving progress with guests around
163
+ ## Saving progress (per-player every player has their own slot)
138
164
 
139
- Guests play but cannot save design the save path accordingly:
165
+ Use the SDK helpers; never hand-roll fetch calls to the state API. Progression,
166
+ inventory, unlocks — anything about ONE player — goes in their player slot:
140
167
 
141
168
  ```ts
142
- // Fire-and-forget save that is simply OFF for guests:
143
- async function saveProgress(data: unknown) {
144
- const token = getEmbedToken();
145
- if (getAuthState() !== "authenticated" || !token) return; // guest: skip silently
146
- await fetch(`${GENEX.apiUrl}/api/projects/${GENEX.slug}/state`, {
147
- method: "PUT",
148
- headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
149
- body: JSON.stringify(data),
150
- });
151
- }
169
+ import { loadPlayerState, savePlayerState } from "@genex-ai/embed-sdk";
170
+
171
+ // boot: load whatever this player saved last time (fine for guests — resolves
172
+ // { data: null, guest: true } instead of failing)
173
+ const { data } = await loadPlayerState();
174
+ applyProgress(data ?? defaultProgress());
175
+
176
+ // checkpoints / level-ups: fire-and-forget, DEBOUNCED (not per frame)
177
+ void savePlayerState(progress);
178
+ ```
179
+
180
+ Guests need zero special handling: their saves queue in memory and auto-flush
181
+ the moment they sign in mid-game (so a guest's progress follows them into
182
+ their new account), and `waitForAuth()` resolving is the signal that a real
183
+ account now exists. The SDK already tells guests to sign in (its popover / the
184
+ dashboard's card) — don't add another prompt.
185
+
186
+ **Per-player vs world:** `savePlayerState` is each player's own progress;
187
+ `saveWorldState` is the game's ONE shared world (persistent-world layouts —
188
+ see the multiplayer skill's persistence section for the host-writes pattern).
189
+ Never store per-player progression in the world slot: every player of the
190
+ game shares that single blob.
191
+
192
+ ## Leaderboards
193
+
194
+ ```ts
195
+ import { submitScore, getLeaderboard } from "@genex-ai/embed-sdk";
196
+
197
+ // on game over / lap complete — keep-best, so just submit every run
198
+ void submitScore(finalScore); // higher is better
199
+ void submitScore(lapMs, { board: "laps", mode: "min" }); // lower is better
152
200
 
153
- // And start loading/saving the moment a guest upgrades mid-game:
154
- waitForAuth().then(({ user }) => {
155
- // signed in (possibly after starting as a guest) — load their save now
156
- }).catch(() => { /* blocked — SDK overlay owns the UX */ });
201
+ // render a top-10 + the player's own rank
202
+ const { items, me } = await getLeaderboard({ limit: 10 });
157
203
  ```
158
204
 
159
- The SDK already tells guests to sign in (its popover / the dashboard's card) —
160
- don't add another prompt.
205
+ Scores are per-account (one row per player per board, real display names from
206
+ their Genex account) and keep-best — submitting a worse score changes nothing
207
+ (`improved: false`). Guests can READ leaderboards; their submits queue and
208
+ post when they sign in. Send a consistent `mode` per board. Scores are
209
+ client-reported (arcade-style trust) — don't present them as anti-cheat.
161
210
 
162
211
  ## Crash reporting rules
163
212
 
@@ -216,9 +265,13 @@ concerns.
216
265
  - [ ] `genex.config.ts` includes `dashboardOrigins` (from `.genex/project.json`).
217
266
  - [ ] Multiplayer `connect()` and player-name UI await `waitForPlayer()` —
218
267
  NEVER `waitForAuth()` (guests would hang forever).
219
- - [ ] `/state` load/save gates on `waitForAuth()` / `getAuthState() ===
220
- "authenticated"` and sends `Authorization: Bearer ${getEmbedToken()}`;
221
- guests skip saves silently (the server answers them `403 guest_no_save`).
268
+ - [ ] Saves/loads use the SDK helpers (`savePlayerState`/`loadPlayerState` for
269
+ per-player progress, `saveWorldState`/`loadWorldState` for the shared
270
+ world, `submitScore`/`getLeaderboard` for scores) no hand-rolled fetch
271
+ to `/state` endpoints, no manual guest gating (the helpers own it).
272
+ - [ ] Per-player progression lives in the PLAYER slot, never in the shared
273
+ world slot.
274
+ - [ ] Saves are debounced (checkpoints/level-ups), not per-frame.
222
275
  - [ ] No token value is ever logged or sent to analytics.
223
276
  - [ ] No custom sign-in prompt, guest badge, or auth overlay — the SDK popover/
224
277
  overlay and the dashboard own all of that UX.
@@ -241,6 +294,12 @@ concerns.
241
294
  - **Multiplayer join rejected with 403 "guest capacity"** — the room is at its
242
295
  guest limit; only signing in gets the player a seat right now. Surface the
243
296
  relay's message as-is.
244
- - **`/state` returns 401/403** — missing `Authorization` header (401), a guest
245
- token (403 `guest_no_save` — expected, skip saves for guests), or the token
246
- belongs to a different game (403): `GENEX.slug` doesn't match this project.
297
+ - **`/state` returns 401/403 (hand-rolled fetch)** — missing `Authorization`
298
+ header (401), a guest token (403 `guest_no_save`), or the token belongs to a
299
+ different game (403). All three mean the code bypassed the SDK helpers —
300
+ switch to `savePlayerState`/`saveWorldState`, which handle every case.
301
+ - **`saveWorldState` resolves `{ conflict: true }`** — another player wrote the
302
+ shared slot since your last read. Expected under concurrency: reload with
303
+ `loadWorldState()`, merge, retry with the fresh `version`. If it happens
304
+ constantly, more than one client is acting as the writer — in multiplayer,
305
+ only the host should save the world.
@@ -37,7 +37,62 @@ example, the shared-object/ball code, rotation, and host usage. Read
37
37
  npm i @genex-ai/multiplayer
38
38
  ```
39
39
 
40
- `objects` and `host` need `@genex-ai/multiplayer` **≥ 0.4.0**.
40
+ This skill targets `@genex-ai/multiplayer` **≥ 0.7.0** (`objects`/`host` since 0.4; `matchmake()` since 0.5; presets + `score()`/`finish()` since 0.6; `createPrivate()`/`joinPrivate()` since 0.7).
41
+
42
+ ## Matchmaking (competitive presets — server-owned)
43
+
44
+ When players should be **matched into separate capped rooms** rather than share one big room (a 1v1
45
+ duel, an FFA arena, N-v-N teams, an invite lobby), use `matchmake()` instead of `connect()`. It
46
+ returns a handle whose **`session` is `null` while searching** — render your OWN "finding a match…"
47
+ HUD from `mm.matchmaking` (its `status`, `queue.position`, `players`/`opponents`, `teams`, `scores`,
48
+ `winCondition`), and switch to the game once `session` goes live:
49
+
50
+ ```ts
51
+ // Pass auth as a FUNCTION — one matchmake() handle re-joins the queue/match many times (re-search,
52
+ // requeue) over a session, and embed tokens rotate (~10 min). A function is read fresh each (re)join;
53
+ // a static object goes stale and gets rejected mid-session.
54
+ const mm = await matchmake<MyState>({ url, room: slug, auth: () => getColyseusAuth() });
55
+ mm.on('matched', () => {/* session is live — start the game */});
56
+ // each frame: if (mm.session) renderGame(mm.session); else renderSearchingHud(mm.matchmaking);
57
+ mm.on('matchEnded', ({ winnerId, scores, draw }) => {/* result screen */});
58
+ mm.on('error', (e) => {/* a (re)join failed, e.g. auth — usually transient; the handle keeps searching */});
59
+
60
+ // Report ONLY your own outcome — the server adjudicates. Which call fits depends on the win condition:
61
+ mm.eliminated(); // I'm out (lastStanding)
62
+ mm.score(1); // I scored (firstToScore / highScoreInTime)
63
+ mm.finish(); // I finished the race (firstToFinish)
64
+ ```
65
+
66
+ Everything is **server-owned** — set once in `package.json` under `genex.matchmaking`, reported at
67
+ publish; the client declares nothing. You never run matchmaking logic: the server owns the queue,
68
+ roles, winner-stays, forfeit, timeout, and the win condition.
69
+
70
+ ```jsonc
71
+ "genex": {
72
+ "matchmaking": {
73
+ "preset": "arena", // duel | arena | teams | private
74
+ "winCondition": "firstToScore", // lastStanding | firstToScore | highScoreInTime | firstToFinish
75
+ "config": { "scoreTarget": 20, "maxPlayers": 8 } // numeric knobs, optional
76
+ }
77
+ }
78
+ ```
79
+
80
+ Presets: **duel** (1v1 winner-stays), **arena** (N-player FFA, join-anytime), **teams** (balanced
81
+ N-v-N), **private** (invite-code lobby). Win conditions: **lastStanding** (last one alive),
82
+ **firstToScore** (first to the score target), **highScoreInTime** (top score at the time cap),
83
+ **firstToFinish** (first to finish). A round that hits the time cap undecided is a draw.
84
+
85
+ **Private lobbies** (for `preset: 'private'`) don't use `matchmake()` — a host makes an invite code
86
+ and friends join it; the lobby is persistent (rounds replay, nobody is evicted):
87
+
88
+ ```ts
89
+ import { createPrivate, joinPrivate } from "@genex-ai/multiplayer";
90
+ const lobby = await createPrivate<MyState>({ url, room: slug, auth: () => getColyseusAuth() }); // live NOW
91
+ showCode(lobby.code); // share this
92
+ // a friend, elsewhere:
93
+ const lobby = await joinPrivate<MyState>(code, { url, room: slug, auth: () => getColyseusAuth() });
94
+ // same handle API as matchmake(): lobby.session, lobby.matchmaking, eliminated()/score()/finish(), cancel()
95
+ ```
41
96
 
42
97
  ## Connect
43
98
 
@@ -207,38 +262,42 @@ file layout.
207
262
  ## Persistent worlds (optional — survives restarts)
208
263
 
209
264
  The relay is in-memory: room state is gone when everyone leaves or the server restarts. For a world
210
- that persists, save/load one JSON blob keyed by the project slug, from **one authority** (the host):
211
-
212
- Both calls **require a signed-in identity** (`Authorization: Bearer` with the token
213
- from `getEmbedToken()`) — guests play multiplayer but cannot read or write saves
214
- (the server answers their token `403 guest_no_save`). Gate them on
215
- `await waitForAuth()` — the ACCOUNT gate, deliberately stricter than the
216
- `waitForPlayer()` gate `connect()` uses (see the `genex-threejs-embed-auth` skill):
265
+ that persists (a driven car stays where it was parked, built structures survive), save/load the
266
+ game's shared world slot via the embed SDK, from **one authority** (the host):
217
267
 
218
268
  ```ts
219
- import { getEmbedToken } from "@genex-ai/embed-sdk";
269
+ import { loadWorldState, saveWorldState } from "@genex-ai/embed-sdk";
220
270
 
221
- // load on boot (after waitForAuth() has resolved)
222
- const { data } = await fetch(`${GENEX.apiUrl}/api/projects/${GENEX.slug}/state`, {
223
- headers: { Authorization: `Bearer ${getEmbedToken()}` },
224
- }).then(r => r.json());
271
+ // load on boot (safe to call immediately — waits for identity internally;
272
+ // guests resolve { data: null, guest: true } and receive the live world
273
+ // through the room instead)
274
+ const { data, version } = await loadWorldState();
225
275
  initWorld(data ?? defaultWorld());
226
-
227
- // save from ONE authority — the elected host — to avoid races; max 1 MB, last-write-wins
228
- if (room.isHost) fetch(`${GENEX.apiUrl}/api/projects/${GENEX.slug}/state`, {
229
- method: "PUT",
230
- headers: {
231
- "Content-Type": "application/json",
232
- Authorization: `Bearer ${getEmbedToken()}`,
233
- },
234
- body: JSON.stringify(world),
235
- });
276
+ let worldVersion = version;
277
+
278
+ // save from ONE authority — the elected host — debounced, WITH ifVersion so a
279
+ // stale host handoff loses loudly instead of silently clobbering:
280
+ async function persistWorld(world: unknown) {
281
+ if (!room.isHost) return;
282
+ const res = await saveWorldState(world, { ifVersion: worldVersion });
283
+ if (res.saved) worldVersion = res.version!;
284
+ else if (res.conflict) {
285
+ // someone else wrote since our read (host migration race) — resync
286
+ const fresh = await loadWorldState();
287
+ worldVersion = fresh.version;
288
+ mergeWorld(fresh.data);
289
+ }
290
+ // res.guest: this host is a guest (guest-only room) — saving is off until a
291
+ // signed-in player joins; the relay prefers signed-in hosts automatically.
292
+ }
236
293
  ```
237
294
 
238
- `GET` returns `{ data }` (or `{ data: null }` if never saved). It requires a valid embed
239
- token for this exact game (401 without one, 403 with another game's token) and is
240
- size-capped at 1 MB. Don't save every frame debounce, and let `room.isHost` pick the
241
- single writer.
295
+ The slot is one JSON blob per game (≤ 1 MB) shared by every player world layout only,
296
+ NEVER per-player progression (that belongs in each player's own `savePlayerState()` slot
297
+ see the embed-auth skill). Don't save every frame: debounce (~1/sec), and also flush on
298
+ `document.visibilitychange === "hidden"` so the last edits survive the host closing the tab.
299
+ Guests can't write it, but the relay elects a signed-in host whenever one is present, so
300
+ host-driven saving works as long as ANY account is in the room.
242
301
 
243
302
  ## Checklist
244
303
 
@@ -183,35 +183,50 @@ const px = t.stateRaw.x, pz = t.stateRaw.z; // test against the raw latest
183
183
  ## Persistence helper
184
184
 
185
185
  ```ts
186
- // persistence.ts — /state requires the embed identity: call only after
187
- // `await waitForAuth()` has resolved (see the genex-threejs-embed-auth skill).
188
- import { getEmbedToken } from "@genex-ai/embed-sdk";
186
+ // persistence.ts — the embed SDK owns tokens, guest handling, and conflict
187
+ // decoding (see the genex-threejs-embed-auth skill). World slot = shared
188
+ // layout only; per-player progression goes in savePlayerState() instead.
189
+ import { loadWorldState, saveWorldState } from "@genex-ai/embed-sdk";
190
+
191
+ let worldVersion = 0;
189
192
 
190
193
  export async function loadWorld<T>(fallback: T): Promise<T> {
191
194
  try {
192
- const { data } = await fetch(`${GENEX.apiUrl}/api/projects/${GENEX.slug}/state`, {
193
- headers: { Authorization: `Bearer ${getEmbedToken()}` },
194
- }).then(r => r.json());
195
+ const { data, version } = await loadWorldState(); // waits for identity itself
196
+ worldVersion = version;
195
197
  return (data as T) ?? fallback;
196
198
  } catch { return fallback; }
197
199
  }
198
200
 
199
201
  let saveTimer: ReturnType<typeof setTimeout> | null = null;
202
+ let latest: unknown;
200
203
  export function saveWorld(world: unknown) {
201
- if (saveTimer) return; // debounce: at most one PUT/sec, from the host only
202
- saveTimer = setTimeout(() => {
203
- saveTimer = null;
204
- fetch(`${GENEX.apiUrl}/api/projects/${GENEX.slug}/state`, {
205
- method: "PUT",
206
- headers: {
207
- "Content-Type": "application/json",
208
- Authorization: `Bearer ${getEmbedToken()}`,
209
- },
210
- body: JSON.stringify(world),
211
- }).catch(() => {});
212
- }, 1000);
204
+ latest = world; // debounce: at most one write/sec, host only
205
+ if (saveTimer) return;
206
+ saveTimer = setTimeout(() => { saveTimer = null; void flushWorld(); }, 1000);
207
+ }
208
+
209
+ async function flushWorld() {
210
+ const res = await saveWorldState(latest, { ifVersion: worldVersion }).catch(() => null);
211
+ if (!res) return;
212
+ if (res.saved) worldVersion = res.version!;
213
+ else if (res.conflict) worldVersion = res.version!; // stale after a host race — next save wins
213
214
  }
215
+
216
+ // The host closing their tab must not lose the last edits: flush immediately
217
+ // when the page goes hidden (savePlayerState/saveWorldState small writes ride
218
+ // fetch keepalive, so this completes even mid-unload).
219
+ document.addEventListener("visibilitychange", () => {
220
+ if (document.visibilityState === "hidden" && saveTimer) {
221
+ clearTimeout(saveTimer);
222
+ saveTimer = null;
223
+ void flushWorld();
224
+ }
225
+ });
214
226
  ```
215
227
 
216
- State is one JSON blob per project, max 1 MB, last-write-wins. Save from a single authority
217
- (`room.isHost`) so concurrent writers don't clobber each other.
228
+ State is one JSON blob per project, max 1 MB, shared by every player. Save from a single
229
+ authority (`room.isHost`) so concurrent writers don't clobber each other; `ifVersion` turns
230
+ any remaining race into a visible `conflict` instead of silent data loss. Guests can't
231
+ write it — the relay prefers signed-in players as host, so saving works whenever any
232
+ account is in the room (guest-only rooms simply don't persist until one joins).