@genex-ai/cli-demo 0.50.0-dev.96 → 0.51.0-dev.108

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
@@ -1652,7 +1652,13 @@ async function callPublish(ctx, commit, opts, log) {
1652
1652
  async function pushSource(cwd, ctx, log) {
1653
1653
  const target = await fetchPushUrl(ctx, log);
1654
1654
  if (!target) return false;
1655
- return pushWorktree(cwd, target.pushUrl, target.managed, log);
1655
+ if (await pushWorktree(cwd, target.pushUrl, target.managed, log)) return true;
1656
+ if (!target.managed) return false;
1657
+ log.info("Retrying the source push\u2026");
1658
+ await new Promise((r) => setTimeout(r, 2e3));
1659
+ const fresh = await fetchPushUrl(ctx, log);
1660
+ if (!fresh) return false;
1661
+ return pushWorktree(cwd, fresh.pushUrl, fresh.managed, log);
1656
1662
  }
1657
1663
  async function pushWorktree(cwd, pushUrl, managed, log) {
1658
1664
  const EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
@@ -2119,6 +2125,44 @@ async function runPreview(opts) {
2119
2125
  }
2120
2126
  }
2121
2127
 
2128
+ // src/lib/open.ts
2129
+ import { spawn as spawn4 } from "child_process";
2130
+ function tokenize(cmd) {
2131
+ return cmd.trim().split(/\s+/).filter(Boolean);
2132
+ }
2133
+ function openUrl(url) {
2134
+ let command;
2135
+ let args;
2136
+ const custom = (process.env.GENEX_BROWSER || process.env.BROWSER)?.trim();
2137
+ const customParts = custom ? tokenize(custom) : [];
2138
+ if (customParts.length > 0) {
2139
+ command = customParts[0];
2140
+ args = [...customParts.slice(1), url];
2141
+ } else {
2142
+ switch (process.platform) {
2143
+ case "darwin":
2144
+ command = "open";
2145
+ args = [url];
2146
+ break;
2147
+ case "win32":
2148
+ command = "cmd";
2149
+ args = ["/c", "start", "", url];
2150
+ break;
2151
+ default:
2152
+ command = "xdg-open";
2153
+ args = [url];
2154
+ break;
2155
+ }
2156
+ }
2157
+ try {
2158
+ const child = spawn4(command, args, { stdio: "ignore", detached: true });
2159
+ child.on("error", () => {
2160
+ });
2161
+ child.unref();
2162
+ } catch {
2163
+ }
2164
+ }
2165
+
2122
2166
  // src/lib/sse.ts
2123
2167
  async function* readSSE(body) {
2124
2168
  const decoder = new TextDecoder();
@@ -2255,9 +2299,9 @@ async function runGenerate(kind, opts) {
2255
2299
  log.dim(" (Re-running the generate command would start \u2014 and bill \u2014 a NEW generation.)");
2256
2300
  return;
2257
2301
  }
2258
- await awaitAndReport(apiUrl, token, id, kind, log);
2302
+ await awaitAndReport(apiUrl, token, id, kind, log, opts.open);
2259
2303
  }
2260
- async function awaitAndReport(apiUrl, token, id, kind, log) {
2304
+ async function awaitAndReport(apiUrl, token, id, kind, log, open = false) {
2261
2305
  log.step("Generating\u2026 (this can take up to a minute)");
2262
2306
  const onProgress = (p) => log.dim(` ${p}%`);
2263
2307
  const timeoutMs = waitTimeoutFor(kind);
@@ -2269,9 +2313,9 @@ async function awaitAndReport(apiUrl, token, id, kind, log) {
2269
2313
  process.exitCode = 1;
2270
2314
  return;
2271
2315
  }
2272
- await reportTerminal(kind, view, log);
2316
+ await reportTerminal(kind, view, log, open);
2273
2317
  }
2274
- async function reportTerminal(kind, view, log) {
2318
+ async function reportTerminal(kind, view, log, open = false) {
2275
2319
  if (view.status !== "completed") {
2276
2320
  log.error(`Generation ${view.status}${view.error ? `: ${view.error}` : ""}.`);
2277
2321
  process.exitCode = 1;
@@ -2290,6 +2334,14 @@ async function reportTerminal(kind, view, log) {
2290
2334
  log.plain(` ${label}${f.url}`);
2291
2335
  }
2292
2336
  log.plain("");
2337
+ if (open && files[0]) {
2338
+ openUrl(files[0].url);
2339
+ log.plain(
2340
+ ` ${c.bold("\u{1F441} Show this to the user")} \u2014 it just opened in their browser; paste this link in chat too so they can open it themselves:`
2341
+ );
2342
+ log.plain(` ${files[0].url}`);
2343
+ log.plain("");
2344
+ }
2293
2345
  printHint(kind, files, log);
2294
2346
  await firstPreviewNudge(log);
2295
2347
  }
@@ -2440,10 +2492,10 @@ async function runWait(opts) {
2440
2492
  log.dim(` ${kind} ${id}`);
2441
2493
  log.plain("");
2442
2494
  if (TERMINAL2.has(view.status)) {
2443
- await reportTerminal(kind, view, log);
2495
+ await reportTerminal(kind, view, log, opts.open);
2444
2496
  return;
2445
2497
  }
2446
- await awaitAndReport(apiUrl, token, id, kind, log);
2498
+ await awaitAndReport(apiUrl, token, id, kind, log, opts.open);
2447
2499
  }
2448
2500
 
2449
2501
  // src/commands/controller.ts
@@ -3183,7 +3235,8 @@ var UI_NUMBER_FLAGS = {
3183
3235
  "--edge-flush-max": "edgeFlushMax",
3184
3236
  "--quantize": "quantize",
3185
3237
  "--ink-dist": "inkDist",
3186
- "--min-frac": "minFrac"
3238
+ "--min-frac": "minFrac",
3239
+ "--seam-tolerance": "seamTolerance"
3187
3240
  };
3188
3241
  var UI_USAGE = `${c.bold("Options for `ui` (all tools read --in <png path or https URL>)")}
3189
3242
  ui extract --out-dir <dir> --names a,b,c (reading order) \u2014 split an
@@ -3199,7 +3252,10 @@ var UI_USAGE = `${c.bold("Options for `ui` (all tools read --in <png path or htt
3199
3252
  ink candidates as JSON. [--quantize 12] [--ink-dist 60]
3200
3253
  [--min-frac 0.004] [--out <json>] [--crop <png>]
3201
3254
  ui trim [--out <png>] \u2014 crop to the alpha bbox (default: overwrite
3202
- --in), print real dims + refresh the .bbox.json sidecar.`;
3255
+ --in), print real dims + refresh the .bbox.json sidecar.
3256
+ ui seams measure a tiling texture's wrap seam (opposite edges vs
3257
+ interior detail) \u2192 ratio + verdict as JSON; nonzero exit on
3258
+ a visible seam. [--seam-tolerance 3]`;
3203
3259
  async function runUi(opts) {
3204
3260
  const log = createLogger({ quiet: opts.quiet });
3205
3261
  try {
@@ -3216,9 +3272,12 @@ async function runUi(opts) {
3216
3272
  case "trim":
3217
3273
  await uiTrim(opts, log);
3218
3274
  return;
3275
+ case "seams":
3276
+ await uiSeams(opts, log);
3277
+ return;
3219
3278
  default:
3220
3279
  log.error(
3221
- opts.sub ? `Unknown ui tool "${opts.sub}". Tools: extract, masks, text-color, trim.` : "Missing ui tool. Tools: extract, masks, text-color, trim."
3280
+ opts.sub ? `Unknown ui tool "${opts.sub}". Tools: extract, masks, text-color, trim, seams.` : "Missing ui tool. Tools: extract, masks, text-color, trim, seams."
3222
3281
  );
3223
3282
  log.plain(UI_USAGE);
3224
3283
  process.exitCode = 1;
@@ -3925,6 +3984,78 @@ async function uiTrim(opts, log) {
3925
3984
  `
3926
3985
  );
3927
3986
  }
3987
+ async function uiSeams(opts, log) {
3988
+ const input = requireOpt(opts.input, "--in", "seams");
3989
+ const tol = opts.seamTolerance ?? 3;
3990
+ const png = await loadPng(input);
3991
+ const { width: w, height: h, data } = png;
3992
+ if (w < 4 || h < 4) fail("Image is too small to test for tiling.", { width: w, height: h });
3993
+ const chan = (x, y, ch) => data[(y * w + x) * 4 + ch] ?? 0;
3994
+ const colDiff = (a, b) => {
3995
+ let s = 0;
3996
+ for (let y = 0; y < h; y++) {
3997
+ s += Math.abs(chan(a, y, 0) - chan(b, y, 0));
3998
+ s += Math.abs(chan(a, y, 1) - chan(b, y, 1));
3999
+ s += Math.abs(chan(a, y, 2) - chan(b, y, 2));
4000
+ }
4001
+ return s / (h * 3);
4002
+ };
4003
+ const rowDiff = (a, b) => {
4004
+ let s = 0;
4005
+ for (let x = 0; x < w; x++) {
4006
+ s += Math.abs(chan(x, a, 0) - chan(x, b, 0));
4007
+ s += Math.abs(chan(x, a, 1) - chan(x, b, 1));
4008
+ s += Math.abs(chan(x, a, 2) - chan(x, b, 2));
4009
+ }
4010
+ return s / (w * 3);
4011
+ };
4012
+ const seamH = colDiff(0, w - 1);
4013
+ const seamV = rowDiff(0, h - 1);
4014
+ const stepX = Math.max(1, Math.floor(w / 64));
4015
+ const stepY = Math.max(1, Math.floor(h / 64));
4016
+ let baseH = 0;
4017
+ let nH = 0;
4018
+ for (let x = 1; x < w - 1; x += stepX) {
4019
+ baseH += colDiff(x, x + 1);
4020
+ nH++;
4021
+ }
4022
+ let baseV = 0;
4023
+ let nV = 0;
4024
+ for (let y = 1; y < h - 1; y += stepY) {
4025
+ baseV += rowDiff(y, y + 1);
4026
+ nV++;
4027
+ }
4028
+ baseH = Math.max(baseH / Math.max(1, nH), 1e-6);
4029
+ baseV = Math.max(baseV / Math.max(1, nV), 1e-6);
4030
+ const round = (n) => Math.round(n * 100) / 100;
4031
+ const ratioH = round(seamH / baseH);
4032
+ const ratioV = round(seamV / baseV);
4033
+ const worst = Math.max(ratioH, ratioV);
4034
+ const axis = ratioH >= ratioV ? "horizontal" : "vertical";
4035
+ const seamless = worst <= tol;
4036
+ const name = input.split("/").pop() ?? input;
4037
+ process.stdout.write(
4038
+ `${JSON.stringify({
4039
+ input,
4040
+ width: w,
4041
+ height: h,
4042
+ ratioHorizontal: ratioH,
4043
+ ratioVertical: ratioV,
4044
+ worstRatio: worst,
4045
+ worstAxis: axis,
4046
+ tolerance: tol,
4047
+ seamless
4048
+ })}
4049
+ `
4050
+ );
4051
+ if (seamless) {
4052
+ log.success(`${name}: tiles cleanly \u2014 worst seam ${worst}\xD7 the texture's own detail (\u2264 ${tol}\xD7).`);
4053
+ return;
4054
+ }
4055
+ fail(
4056
+ `Visible ${axis} tiling seam in ${name}: the tile boundary jumps ${worst}\xD7 the texture's own detail (want \u2264 ${tol}\xD7). Regenerate it seamless/tileable, or lower the mesh's UV repeat so the seam falls off-camera.`
4057
+ );
4058
+ }
3928
4059
 
3929
4060
  // src/index.ts
3930
4061
  var GEN_KINDS = /* @__PURE__ */ new Set(["model", "skybox", "sfx", "texture", "image", "video"]);
@@ -3969,6 +4100,8 @@ ${c.bold("Options for the generators (`model` `skybox` `sfx` `texture` `image` `
3969
4100
  --terrain (texture) seamless tiling surface for terrain/ground.
3970
4101
  --duration <sec> (sfx, video) target clip length in seconds.
3971
4102
  --transparent (image) transparent background (alpha) \u2014 for decals/stickers.
4103
+ --open (image/video) open the finished asset in the user's browser +
4104
+ print a link \u2014 show them the concept frame / anything they approve.
3972
4105
  --aspect <ratio> (image) aspect preset (e.g. square, landscape, portrait).
3973
4106
  --quality <q> (image) quality preset: low | medium | high.
3974
4107
  --candidates <n> (image) generate 2-4 variants in ONE call and print every
@@ -4153,6 +4286,9 @@ function parseArgs(argv) {
4153
4286
  case "--no-wait":
4154
4287
  parsed.options.noWait = true;
4155
4288
  break;
4289
+ case "--open":
4290
+ parsed.options.open = true;
4291
+ break;
4156
4292
  case "--terrain":
4157
4293
  parsed.options.terrain = true;
4158
4294
  break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@genex-ai/cli-demo",
3
- "version": "0.50.0-dev.96",
3
+ "version": "0.51.0-dev.108",
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": {
@@ -114,6 +114,24 @@ The test: "is this fixed identity artwork, or should runtime code own it?"
114
114
  Baking a runtime value into a sprite is a failure; stripping a carved identity
115
115
  label flattens the art.
116
116
 
117
+ ## Icons & mechanics must match the game — diff before Stage 2
118
+
119
+ Words are only half of it. Every ICON, slot, gauge, and pictogram in the mockup
120
+ implies a MECHANIC: a grenade icon promises grenades, three ability slots
121
+ promise three abilities, an armor bar promises armor. Before Stage 2, walk each
122
+ one against the game contract — the mechanics the code actually has (or that you
123
+ are committing to build):
124
+
125
+ - **Backed by a real mechanic** → keep it and wire it to that state.
126
+ - **No backing mechanic** → it is a promise the player will notice is empty.
127
+ CUT it (prompt Stage 2, or a region re-edit, to omit that icon/slot) OR add
128
+ the mechanic to the game. Shipping a depicted control the game can't do — a
129
+ grenade slot on a rifle-only HUD, an armor bar with no armor, a minimap over
130
+ no map — is a failure, and "the model drew it so I kept it" is how it happens.
131
+
132
+ The mockup (and any concept frame) anchors STYLE; the game contract owns WHAT
133
+ EXISTS. When the picture and the mechanics disagree, the mechanics win.
134
+
117
135
  ## The pipeline
118
136
 
119
137
  Each step is one command; every `<...-url>` is the R2 URL the previous command
@@ -129,6 +129,18 @@ Gotchas (all bite in practice):
129
129
  moving or skinned target, use a small `PlaneGeometry` quad offset along the normal
130
130
  (`hit.point + n * 0.01`, `quad.lookAt(hit.point.clone().add(n))`) instead.
131
131
 
132
+ ### Impact marks (bullet holes, scorch, blood) — spawn on the hit, not a keypress
133
+
134
+ A game that shoots, throws, or explodes and leaves NO mark on what it hits reads
135
+ as unfinished — and it's the same recipe, driven by the weapon's raycast you
136
+ already have. Generate ONE small `--transparent` bullet-hole / scorch / impact
137
+ image, then on each confirmed **world** hit place a decal at `hit.point` with
138
+ `hit.face.normal` (exactly the `spray()` body above), capped in a FIFO ring
139
+ (~30–50) reusing one shared material. On enemy/skinned hits skip the decal and
140
+ use the quad fallback or a hit-spark VFX instead. Impact marks are a first-class
141
+ generated surface for any weapon/collision game — inventory them up front with
142
+ the rest of your art, don't discover the bare walls at the end.
143
+
132
144
  ## Multiplayer
133
145
 
134
146
  The asset URL is public, permanent, and CORS-open, so it is safe to broadcast the
@@ -67,6 +67,24 @@ map.repeat.set(64, 64);
67
67
  scene.add(ground);
68
68
  ```
69
69
 
70
+ ## Verify it actually tiles — don't eyeball it
71
+
72
+ A texture that isn't truly seamless draws a repeating seam grid the moment
73
+ `repeat` is raised — the single most common "asset-flip" tell, and `--terrain`
74
+ asks for seamless but does not guarantee it. So measure the seam, don't guess:
75
+
76
+ ```bash
77
+ npx genex ui seams --in <texture-url> # a local .png works too
78
+ ```
79
+
80
+ It compares the texture's opposite edges against its own interior detail and
81
+ prints a ratio + verdict (`seamless: false` and a nonzero exit on a visible
82
+ seam). A fail is a real defect — fix it in this order: **regenerate** (add
83
+ "seamless tiling, no visible edges" to the prompt, or `--terrain`), then **lower
84
+ `repeat`** so any residual seam falls off-camera. Run this on every tiled
85
+ floor/wall texture before you call the scene done — a large flat surface with a
86
+ high `repeat` and an unchecked texture is exactly how seams reach the player.
87
+
70
88
  ## Publish checklist
71
89
 
72
90
  - Load it from the **URL** the command printed — absolute and permanent, so it resolves
@@ -92,6 +110,8 @@ scene.add(ground);
92
110
  - **"Email not verified" (`email_verification_required`)** — generation credits
93
111
  unlock after the account's email is verified. Give the user the verify link the
94
112
  CLI printed, wait for them to confirm, then re-run the command.
95
- - **Visible tiling seams** — use `--terrain` (tuned for seamless edges), lower the
96
- `repeat`, or blend two textures. Perfect seamlessness is a known v1 limitation.
113
+ - **Visible tiling seams** — measure first (`npx genex ui seams --in <url>`): a
114
+ high ratio is a defect to fix, not a limitation to accept. Regenerate with
115
+ `--terrain` / a "seamless tiling" prompt, lower the `repeat`, or blend two
116
+ textures until the check passes.
97
117
  - **Colors look washed/dark** — ensure `map.colorSpace = THREE.SRGBColorSpace`.
@@ -79,13 +79,25 @@ lap counter in a game without laps). `--aspect 16:9 --quality high
79
79
  --no-wait`, enqueued FIRST of all art; the URL goes into the style-brief
80
80
  comment.
81
81
 
82
- **Share it before building on it.** The moment the frame lands, show it to
83
- the user "this is roughly how the game will look: keep it, or change
84
- something?" as ONE structured question with 2–3 concrete adjustment
85
- options. Keep building gameplay-neutral work (scaffold, physics, netcode)
86
- while waiting, but do NOT anchor further art to an unapproved frame; if the
87
- user redirects, regenerate with their notes before the HUD mockup and menu
88
- frame go out.
82
+ **Share it before building on it actually SHOW the image, don't just ask.**
83
+ When the concept lands, pick it up with `genex wait <id> --open` (or generate it
84
+ with `--open`): it opens in the user's browser AND prints the link. Paste that
85
+ URL as a clickable link in your approval message — a URL is invisible in a
86
+ terminal, and "do you like it?" with no picture in front of the user is the #1
87
+ way this checkpoint fails (they end up digging logs for the file path). Then ask
88
+ ONE structured question — "this is roughly how the game will look: keep it, or
89
+ change something?" — with 2–3 concrete adjustment options. Keep building
90
+ gameplay-neutral work (scaffold, physics, netcode) while waiting, but do NOT
91
+ anchor further art to an unapproved frame. **Approval is a LOOP, not a
92
+ one-shot:** if the user dislikes ANYTHING, regenerate with their exact notes
93
+ (`--candidates 2–3` gives them options to choose from), open + link the new
94
+ frame, and ask again — repeat until they actually approve. Carry every note
95
+ forward so each round compounds; if two rounds don't converge, offer 2–3
96
+ distinct directions as a structured question instead of re-rolling blind. The
97
+ HUD mockup, the menu, and every downstream asset wait for a frame the user has
98
+ signed off — a single "meh" is never permission to move on. The same "open it +
99
+ paste the link" rule covers every image the user weighs in on — the menu still,
100
+ the HUD mockup candidates.
89
101
 
90
102
  **The concept anchors STYLE, not truth.** Palette, materials, light, and
91
103
  register come from the frame; CONTENT comes from the game contract. When
@@ -195,6 +207,48 @@ edits scattered through the code:
195
207
  HUD state come from `$genex-threejs-multiplayer`; render what the SDK
196
208
  reports, don't guess at it.
197
209
 
210
+ ## Escape must pause the game — not shrink the window
211
+
212
+ For a game that captures the mouse (first-person or pointer-lock aim — see the
213
+ pointer bucket in `$genex-threejs-camera-direction`), pressing Escape does two
214
+ **browser-reserved** things you CANNOT stop with `preventDefault`: it exits
215
+ fullscreen and releases pointer lock, and the player sees the window "shrink".
216
+ The real fix is the **Keyboard Lock API**, which routes Escape to your code
217
+ instead — Chromium only, and only in fullscreen. Enter fullscreen on a gesture
218
+ (the Deploy/Resume click), capture Escape, and own the pause:
219
+
220
+ ```ts
221
+ // call ONLY from a user gesture (the Deploy click, the Resume click) — never at boot
222
+ async function enterImmersive(): Promise<void> {
223
+ try {
224
+ if (!document.fullscreenElement)
225
+ await document.documentElement.requestFullscreen({ navigationUI: "hide" });
226
+ } catch { /* sandboxed iframe or no gesture — the pointer-lock path below still pauses */ }
227
+ // Chromium + fullscreen only: deliver Escape to us instead of exiting fullscreen.
228
+ try {
229
+ await (navigator as unknown as { keyboard?: { lock?: (k: string[]) => Promise<void> } })
230
+ .keyboard?.lock?.(["Escape"]);
231
+ } catch { /* Keyboard Lock unsupported — fine */ }
232
+ }
233
+
234
+ document.addEventListener("keydown", (e) => {
235
+ if (e.code !== "Escape") return;
236
+ e.preventDefault(); // stops any *other* default; the reserved ones the lock handles
237
+ if (phase === "playing") { document.exitPointerLock?.(); setPhase("paused"); } // free cursor for the menu
238
+ else if (phase === "paused") { void enterImmersive(); canvas.requestPointerLock?.(); } // Esc resumes
239
+ });
240
+ ```
241
+
242
+ - **Degradation is built in.** Where Keyboard Lock is absent (Safari, Firefox) or
243
+ the game isn't fullscreen, the browser still releases pointer lock on Escape —
244
+ so ALSO keep the pointer-lock-loss → pause path (`pointerlockchange`: if
245
+ unlocked while `playing`, `setPhase("paused")`). Escape then always pauses; it
246
+ only *also* drops fullscreen on browsers without the lock — unavoidable there.
247
+ - **Only for immersive/pointer-lock games.** A top-down or menu-driven game never
248
+ captures the mouse and has no window to shrink — skip all of this.
249
+ - **Dashboard embed:** full coverage needs the game iframe to permit keyboard
250
+ lock; standalone play (`<slug>.genex.technology`) works today regardless.
251
+
198
252
  ## The loader
199
253
 
200
254
  The loader is the first thing every player sees — a bare "Loading… 3/5" over
@@ -86,6 +86,46 @@ Everything is **server-owned** — set once in `package.json` under `genex.match
86
86
  next preview/publish); the client declares nothing. You never run matchmaking logic: the server
87
87
  owns the queue, roles, winner-stays, forfeit, timeout, and the win condition.
88
88
 
89
+ ### WHEN to call `matchmake()` — it IS the "Play Online" button, never a boot call (MANDATORY)
90
+
91
+ `matchmake()` is the ONE action that puts a player on the server: calling it enters the queue and
92
+ the server seats them into a room. So call it **only when the player commits to online play** —
93
+ the click on "Play Online" / "Find Match" — and NEVER on page load, in your boot code, or next to
94
+ `waitForPlayer()`. Getting this wrong is the seat-contamination bug: a player who loaded the page,
95
+ saw the menu, and started a **bots/local** game is STILL sitting in an online room counting toward
96
+ `minPlayers` — three real players wait forever for a fourth who is off fighting bots.
97
+
98
+ **The menu is pre-multiplayer — the player is in NO room and the server does not know they exist.**
99
+ Concretely, for the near-universal "Play Online / Local / Bots" title menu:
100
+
101
+ - **On page load:** boot the menu over an **offline world** generated locally. Do NOT `connect()`,
102
+ do NOT `matchmake()`. `await waitForPlayer()` MAY run here — that only mints an identity token, it
103
+ seats nobody — but nothing touches the relay yet. No room, no queue, no roster entry, no network.
104
+ - **"Bots" / "Local" / "Single-player":** run entirely offline. Never call `matchmake()` or
105
+ `connect()`. The player is a party of one against local AI; the server is never involved.
106
+ - **"Play Online":** NOW call `matchmake()`. This is the first and only relay contact. The player
107
+ enters the queue (`status: 'searching'`), the server seats them into a room, and only from this
108
+ moment are they a counted participant.
109
+
110
+ **The waiting screen belongs to online play, not to the menu.** Show it only AFTER the player
111
+ pressed "Play Online" (so `matchmake()` was called and they hold a seat) AND the room hasn't reached
112
+ `minPlayers` yet (`mm.matchmaking.status === 'waiting'`). A player still on the menu sees no waiting
113
+ screen and no player count — they are in no room, so there is genuinely nothing to show, and there
114
+ is no way for them to know how many others are waiting. The count becomes visible the instant they
115
+ commit and get seated, never before.
116
+
117
+ **Symmetric rule — leaving online play calls `mm.cancel()`.** Entering online is `matchmake()`;
118
+ LEAVING it (back to menu, quit, switching to a bots/local game after being seated) MUST call
119
+ `mm.cancel()`. Otherwise the player keeps their seat and keeps counting toward `minPlayers` for
120
+ everyone else — the same contamination from the other side. One rule, both directions: **commit to
121
+ online → `matchmake()`; leave online → `cancel()`.**
122
+
123
+ This costs the server nothing and adds NO cheat surface: the client only ever decided *whether/when*
124
+ to search (a player can always just not play). Seating, the roster, capacity, and adjudication stay
125
+ server-authoritative — a modified client still cannot fake participation, inflate the roster, or
126
+ force `minPlayers`. Full menu wiring is Recipe 5 in
127
+ [references/genre-recipes.md](references/genre-recipes.md).
128
+
89
129
  ```jsonc
90
130
  "genex": {
91
131
  "matchmaking": {
@@ -197,9 +237,11 @@ team score) is Recipe 4 in [references/genre-recipes.md](references/genre-recipe
197
237
 
198
238
  #### Waiting room / lobby — two patterns
199
239
 
200
- `open` seats you into a LIVE shared room the moment you're matched (`session` goes live, players sync)
201
- but doesn't "start" anythingso the pre-game lobby is simply **your room before it's grown to the
202
- size you want**. Set `minPlayers` to your target: the SERVER flips `mm.matchmaking.status` from
240
+ The waiting room exists only AFTER the player pressed "Play Online" and got seated (see "WHEN to call
241
+ `matchmake()`" above — the menu is pre-multiplayer and shows no waiting screen). Once seated, `open`
242
+ puts you in a LIVE shared room the moment you're matched (`session` goes live, players sync) but
243
+ doesn't "start" anything — so the pre-game lobby is simply **your room before it's grown to the size
244
+ you want**. Set `minPlayers` to your target: the SERVER flips `mm.matchmaking.status` from
203
245
  `'waiting'` to `'playing'` the instant the roster reaches it. The lobby and the game are ONE `open`
204
246
  room — never spin up a second room for it.
205
247
 
@@ -616,9 +658,13 @@ host-driven saving works as long as ANY account is in the room.
616
658
  - [ ] Hit-tests and discrete values read from `stateRaw`, not `state`.
617
659
  - [ ] A ball / shared NPC is on `objects` (claim on contact), never on `shared`.
618
660
  - [ ] `shared` scores/rounds and host-simulated enemies are written only by `room.isHost`.
619
- - [ ] Waiting room (if any): overlay driven by `mm.matchmaking.status` read every frame, gone the
620
- moment it flips to `'playing'` and you WATCHED it close in two browser windows at
621
- `minPlayers` (never gated on `matchStart` or a host `shared` signal alone).
661
+ - [ ] `matchmake()` fires ONLY on the "Play Online" commit, never on page load / in boot code — the
662
+ menu runs an offline world with NO relay contact; "Bots"/"Local" never call it; leaving online
663
+ calls `mm.cancel()`.
664
+ - [ ] Waiting room (if any): shown only AFTER "Play Online" (seated) and while under `minPlayers`;
665
+ overlay driven by `mm.matchmaking.status` read every frame, gone the moment it flips to
666
+ `'playing'` — and you WATCHED it close in two browser windows at `minPlayers` (never gated on
667
+ `matchStart` or a host `shared` signal alone).
622
668
  - [ ] Team game: the HOST reconciles the balanced `id → team` map into `shared` (leavers dropped,
623
669
  newcomers to the smallest team); every client READS its team from `shared` — never computed
624
670
  per-client, never `mm.matchmaking.teams`, never a default for the unassigned — and you
@@ -174,6 +174,57 @@ the short-handed side.
174
174
 
175
175
  ---
176
176
 
177
+ ## Recipe 5 — Title menu with Online / Local / Bots (the seat-contamination fix)
178
+
179
+ Almost every game opens on a menu: **Play Online**, **Local / Single-player**, **Bots**. The rule
180
+ that keeps online matches clean: **the menu is pre-multiplayer — there is no room, no queue, no
181
+ server contact until the player commits to online.** `matchmake()` IS the "Play Online" button. Call
182
+ it anywhere earlier (page load, boot code, beside `waitForPlayer()`) and a player who picks Bots is
183
+ still parked in an online room, counting toward `minPlayers` while three real players wait for a
184
+ fourth who never comes.
185
+
186
+ | Moment | What runs | Relay contact |
187
+ | --- | --- | --- |
188
+ | Page load → menu | An **offline world** generated locally, menu overlay on top | NONE. Not `connect()`, not `matchmake()`. `waitForPlayer()` may run (mints identity only, seats nobody). |
189
+ | "Bots" / "Local" | The same offline world + local AI / single-player | NONE, ever. |
190
+ | "Play Online" | `matchmake()` → queue → the server seats you | FIRST contact. Only now are you a counted participant. |
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
+
193
+ **Decisions:**
194
+ - **`matchmake()` is created lazily, on the click — not held from boot.** Keep the handle in a
195
+ variable so you can `cancel()` it; create it inside the "Play Online" handler, not at module load.
196
+ - **Bots/Local touch nothing networked.** They run the exact offline world the menu already booted.
197
+ A player can sit in Bots forever and the online queue never knows they exist — which is the point.
198
+ - **The waiting screen is an online-only, post-commit overlay.** Show it only when
199
+ `mm.matchmaking.status === 'waiting'` (seated, under `minPlayers`). On the menu there is no room,
200
+ so there is nothing to show and no count to know — never render a "0 players waiting" teaser there.
201
+ - **Leaving online is `mm.cancel()`, always.** Back-to-menu button, browser-quit
202
+ (`visibilitychange`/`pagehide` if you want promptness), or choosing Bots after having been seated —
203
+ each calls `cancel()`. Skipping it re-creates the contamination from the other side: a squatted
204
+ seat that never frees.
205
+ - **No new cheat surface.** The client only ever chose *when* to search — a player can always just
206
+ not play. Seating, the roster, capacity, and adjudication remain server-authoritative, so a
207
+ modified client still cannot fake participation or force the `minPlayers` gate.
208
+
209
+ ```ts
210
+ let mm = null; // no relay contact yet — we're on the menu
211
+ bootOfflineWorld(); // local world under the menu overlay
212
+ await waitForPlayer(); // identity token only; seats nobody
213
+
214
+ onClick("play-online", async () => {
215
+ mm = await matchmake({ urls, room: slug, auth: () => getColyseusAuth() });
216
+ showWaitingOverlay(); // driven by mm.matchmaking.status, per SKILL.md
217
+ });
218
+ onClick("play-bots", () => startBots()); // offline; mm stays null
219
+ onClick("leave-online", () => { mm?.cancel(); mm = null; returnToMenu(); });
220
+ ```
221
+
222
+ **Acceptance feel:** a player who picks Bots never appears in anyone's online room; the online
223
+ waiting count reflects exactly the people who pressed Play Online; a player who backs out of online
224
+ frees their seat immediately, so a 3/4 lobby doesn't hang on a ghost.
225
+
226
+ ---
227
+
177
228
  ## Not sure which? Start from the table
178
229
 
179
230
  Whatever the genre, ask per thing: *is it one player's own state* (`me.set`) *· a moving thing
@@ -67,11 +67,13 @@ direction in the same plan block — the camera rig + pointer bucket
67
67
  (`$genex-threejs-camera-direction`), the renderer baseline (tone mapping,
68
68
  exposure, output color space — set deliberately at boot; stock three.js
69
69
  defaults are not a look, `$genex-threejs-exposure-color-grading`), the post
70
- stack this game ships (bloom/AO/grade at least ONE built render-pass effect
71
- for EVERY game, justified by the shared style brief; the UI vignette div or a
72
- CSS filter on the canvas does not count, and a plan that "deliberately skips
73
- everything" is the stock default, not a look; `$genex-threejs-image-pipeline`
74
- owns ordering when 2+ compose), one named **ambient-motion loop** that keeps
70
+ stack this game ships — read off the concept frame's OWN look (the grade, bloom
71
+ level, haze, grain it already shows) and which post the 2–3 AAA references lean
72
+ on, not a default single bloom; ONE built render-pass effect is the FLOOR
73
+ against no-post for EVERY game, never the target — ship the richness the concept
74
+ implies (the UI vignette div or a CSS canvas filter does not count;
75
+ `$genex-threejs-image-pipeline` owns ordering when 2+ compose), one named
76
+ **ambient-motion loop** that keeps
75
77
  the scene alive at rest (emissive pulse, shimmer, drifting dust — shader
76
78
  work, zero generations), and the lighting/atmosphere mood from that
77
79
  same brief. Planning is not building — effects still land last in the
@@ -41,13 +41,19 @@ Three.js release or branch, and do not blindly copy demo architecture.
41
41
  - **renderer baseline**: tone mapping, exposure, and output color space set
42
42
  deliberately at boot (`$genex-threejs-exposure-color-grading` owns the
43
43
  staging — stock three.js defaults are not a look);
44
- - **the post stack**: which image effects THIS game ships (bloom? AO?
45
- grade?) each named and justified by the shared style brief. The floor
46
- for EVERY game is the deliberate renderer baseline PLUS at least one
47
- scene-serving render-pass effect (bloom, AO, or a LUT/shader grade the
48
- UI vignette div or a CSS filter on the canvas does not count); "no post
49
- at all" is the stock default, not a plan, and "it's only a draft" is not
50
- a lower floor. When 2+ effects compose, `$genex-threejs-image-pipeline`
44
+ - **the post stack**: read the look off your evidence don't default to a
45
+ single bloom. TWO sources drive it: (a) the CONCEPT FRAME a rendered
46
+ image that already carries a grade, a bloom level, haze/DoF, maybe grain or
47
+ aberration; name what it actually shows and reproduce THAT; (b) the 2–3 AAA
48
+ references name which post each one leans on (racing: motion blur + heat
49
+ haze; grounded shooter: restrained bloom + film grain + faint aberration;
50
+ clean sci-fi: crisp bloom + strong grade). Ship the stack that evidence
51
+ calls for, each effect named and justified. ONE scene-serving render-pass
52
+ effect (bloom, AO, or a LUT/shader grade — a UI vignette div or a CSS
53
+ canvas filter does NOT count) is the FLOOR against "no post", never the
54
+ target: match the concept's richness, don't stop at one token pass. "No
55
+ post at all" is the stock default, not a plan, and "it's only a draft" is
56
+ not a lower floor. When 2+ effects compose, `$genex-threejs-image-pipeline`
51
57
  owns the pass ordering;
52
58
  - **references**: name 2–3 AAA games whose look this game borrows
53
59
  (conventions, lighting mood, palette, post — never trade dress); the