@genex-ai/cli-demo 0.42.0 → 0.44.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@genex-ai/cli-demo",
3
- "version": "0.42.0",
3
+ "version": "0.44.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": {
@@ -65,6 +65,40 @@ export type FollowCameraOptions = {
65
65
  * Default []. Mutable after construction via the public `colliderMeshes` field.
66
66
  */
67
67
  colliderMeshes?: THREE.Mesh[];
68
+ /**
69
+ * Pointer-lock aim mode (AG-754). When true, a left-mouse click on `domElement`
70
+ * requests pointer lock; while locked, raw mouse movement drives the orbit
71
+ * directly (no drag needed). Esc — or any lock loss — returns to "unlocked".
72
+ * Ships NO DOM UI: wire `onAimChange` to render a reticle while locked and a
73
+ * "click to aim" cue while unlocked. Touch pointers never trigger lock (mobile
74
+ * controls are unchanged) and coarse-pointer-only devices report "off". First-
75
+ * person is this same mode plus a pinned zoom + eye-height target (see the
76
+ * character-controller wiring reference). Default false.
77
+ */
78
+ pointerLockAim?: boolean;
79
+ /** Radians of view rotation per pixel of locked mouse movement. Default 0.0023. */
80
+ aimSensitivity?: number;
81
+ /** Fired on every aim-state transition (plus an initial "init" emit when aim is enabled). */
82
+ onAimChange?: (e: FollowCameraAimEvent) => void;
83
+ };
84
+
85
+ /**
86
+ * Aim-mode lifecycle state (AG-754). Games render UI off these via `onAimChange`:
87
+ * a reticle while "locked", a "click to aim" cue while "unlocked", a "drag to
88
+ * look" hint while "unavailable".
89
+ */
90
+ export type FollowCameraAimState =
91
+ | "off" // pointerLockAim not enabled, no DOM document (node/SSR), or a coarse-pointer-only device
92
+ | "unlocked" // aim available, waiting for a click
93
+ | "locked" // pointer locked, mouse drives the view
94
+ | "paused" // suspended by the game (menu open / driving) — clicks don't re-lock
95
+ | "unavailable"; // lock permanently rejected (e.g. an iframe without allow="pointer-lock") → drag-orbit fallback
96
+
97
+ /** Payload for {@link FollowCameraOptions.onAimChange}. */
98
+ export type FollowCameraAimEvent = {
99
+ state: FollowCameraAimState;
100
+ prev: FollowCameraAimState;
101
+ reason: "user-click" | "esc-or-lost" | "paused" | "resumed" | "rejected" | "init";
68
102
  };
69
103
 
70
104
  /** Mutable scalar velocity slot for SmoothDamp (Unity-style ref param). */
@@ -212,6 +246,15 @@ export class FollowCamera {
212
246
  private _onWheel: (e: WheelEvent) => void;
213
247
  private _onContextMenu: (e: MouseEvent) => void;
214
248
 
249
+ // Pointer-lock aim (AG-754). _pausedByGame gates re-lock while a menu or vehicle
250
+ // owns input; the two document listeners exist only when aim is capable.
251
+ private _aimSensitivity: number;
252
+ private _onAimChange?: (e: FollowCameraAimEvent) => void;
253
+ private _aimState: FollowCameraAimState;
254
+ private _pausedByGame: boolean;
255
+ private _onLockChange: () => void;
256
+ private _onLockError: () => void;
257
+
215
258
  constructor(camera: THREE.PerspectiveCamera, options: FollowCameraOptions) {
216
259
  this._camera = camera;
217
260
  this._domElement = options.domElement;
@@ -277,6 +320,12 @@ export class FollowCamera {
277
320
  this._onPointerDown = (e: PointerEvent) => {
278
321
  if (!this.enabled) return;
279
322
  if (e.pointerType === "mouse" && e.button !== 0) return;
323
+ // Aim mode: an unlocked left-click on a mouse requests pointer lock, then
324
+ // falls through to the drag path — so if the lock request is rejected, this
325
+ // same click seamlessly becomes a drag-orbit (the graceful-degradation path).
326
+ if (this._aimState === "unlocked" && e.pointerType === "mouse" && e.button === 0) {
327
+ this._requestLock();
328
+ }
280
329
  this._domElement.setPointerCapture(e.pointerId);
281
330
  this._pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
282
331
  if (this._pointers.size === 1) {
@@ -294,6 +343,18 @@ export class FollowCamera {
294
343
 
295
344
  this._onPointerMove = (e: PointerEvent) => {
296
345
  if (!this.enabled) return;
346
+ if (this._aimState === "locked") {
347
+ // Locked aim: raw movement deltas drive the orbit directly (no pointer
348
+ // tracking). SIGN (AG-754): mouse-right looks right, so azimuth ADDS
349
+ // movementX — the OPPOSITE of the drag path's -dx (drag moves the world,
350
+ // aim moves the view); pitch matches drag (mouse-up looks up = -movementY).
351
+ // Reasoned from the spherical math; verify both axes on the template-world
352
+ // testbed and flip a sign here if a scene disagrees (see the drag sign note).
353
+ const s = this._aimSensitivity;
354
+ this.rotate(e.movementX * s, -e.movementY * s, true);
355
+ this._userDragRotate = true; // reuse the dragging smoothTime, like manual orbit
356
+ return;
357
+ }
297
358
  const p = this._pointers.get(e.pointerId);
298
359
  if (!p) return;
299
360
  const dx = e.clientX - p.x;
@@ -360,6 +421,51 @@ export class FollowCamera {
360
421
  this._domElement.addEventListener("pointercancel", this._onPointerUp);
361
422
  this._domElement.addEventListener("wheel", this._onWheel, { passive: false });
362
423
  this._domElement.addEventListener("contextmenu", this._onContextMenu);
424
+
425
+ // ---- pointer-lock aim (AG-754) ----
426
+ this._aimSensitivity = options.aimSensitivity ?? 0.0023;
427
+ this._onAimChange = options.onAimChange;
428
+ this._pausedByGame = false;
429
+ this._onLockChange = () => {
430
+ const locked =
431
+ typeof document !== "undefined" && document.pointerLockElement === this._domElement;
432
+ if (locked) {
433
+ if (this._pausedByGame) {
434
+ // A lock grant that lands AFTER setPaused(true): requestPointerLock is
435
+ // async, so the click's request can resolve once a menu/vehicle has
436
+ // already paused aim. Drop it and stay paused — never go live in a menu.
437
+ document.exitPointerLock();
438
+ return;
439
+ }
440
+ this._setAimState("locked", "user-click");
441
+ } else if (this._aimState === "locked") {
442
+ // Lock lost. A game-driven pause routes to "paused"; anything else (Esc,
443
+ // focus loss, tab hide) is the browser's release valve → back to "unlocked".
444
+ this._setAimState(
445
+ this._pausedByGame ? "paused" : "unlocked",
446
+ this._pausedByGame ? "paused" : "esc-or-lost",
447
+ );
448
+ }
449
+ };
450
+ // pointerlockerror carries no detail; a genuine permission denial is caught on
451
+ // the requestPointerLock() promise instead. Transient errors just stay "unlocked".
452
+ this._onLockError = () => {};
453
+ // Capability gate: opt-in flag, a DOM document present (node/SSR safe), the
454
+ // element supports the API, and the device has a fine pointer (phones never
455
+ // lock — the mode reports "off" so games don't draw a desktop-only cue).
456
+ const aimCapable =
457
+ options.pointerLockAim === true &&
458
+ typeof document !== "undefined" &&
459
+ typeof this._domElement.requestPointerLock === "function" &&
460
+ (typeof matchMedia !== "function" || matchMedia("(pointer: fine)").matches);
461
+ this._aimState = aimCapable ? "unlocked" : "off";
462
+ if (aimCapable) {
463
+ document.addEventListener("pointerlockchange", this._onLockChange);
464
+ document.addEventListener("pointerlockerror", this._onLockError);
465
+ // Emit the opening state so one onAimChange handler owns ALL aim UI — the
466
+ // "click to aim" cue appears immediately, before any interaction.
467
+ this._onAimChange?.({ state: "unlocked", prev: "off", reason: "init" });
468
+ }
363
469
  }
364
470
 
365
471
  // ---- follow feed (call before update(), every frame the controller is active) ----
@@ -498,6 +604,36 @@ export class FollowCamera {
498
604
  return this._orbiting || this._pinching;
499
605
  }
500
606
 
607
+ /**
608
+ * Current pointer-lock aim state (AG-754). "off" means aim is disabled or the
609
+ * device has no fine pointer; "unavailable" means lock was permanently rejected.
610
+ */
611
+ get aimState(): FollowCameraAimState {
612
+ return this._aimState;
613
+ }
614
+
615
+ /**
616
+ * Suspend or resume aim without disabling the camera. Call `setPaused(true)`
617
+ * when a menu opens or the player starts driving; call `setPaused(false)` INSIDE
618
+ * the closing click/keypress handler (browsers only grant re-lock from a user
619
+ * gesture). While paused, canvas clicks do NOT re-lock. No-op when aim is "off"
620
+ * or "unavailable".
621
+ */
622
+ setPaused(paused: boolean): void {
623
+ if (this._aimState === "off" || this._aimState === "unavailable") return;
624
+ this._pausedByGame = paused;
625
+ if (paused) {
626
+ if (typeof document !== "undefined" && document.pointerLockElement === this._domElement) {
627
+ document.exitPointerLock(); // _onLockChange lands on "paused" (_pausedByGame is set)
628
+ } else {
629
+ this._setAimState("paused", "paused");
630
+ }
631
+ } else {
632
+ this._setAimState("unlocked", "resumed");
633
+ this._requestLock(); // legal because the caller is inside a user-gesture handler
634
+ }
635
+ }
636
+
501
637
  // ---- per-frame ----
502
638
 
503
639
  /**
@@ -553,6 +689,11 @@ export class FollowCamera {
553
689
  this._domElement.removeEventListener("pointercancel", this._onPointerUp);
554
690
  this._domElement.removeEventListener("wheel", this._onWheel);
555
691
  this._domElement.removeEventListener("contextmenu", this._onContextMenu);
692
+ if (typeof document !== "undefined") {
693
+ document.removeEventListener("pointerlockchange", this._onLockChange);
694
+ document.removeEventListener("pointerlockerror", this._onLockError);
695
+ if (document.pointerLockElement === this._domElement) document.exitPointerLock();
696
+ }
556
697
  this._pointers.clear();
557
698
  this._orbiting = false;
558
699
  this._pinching = false;
@@ -574,6 +715,31 @@ export class FollowCamera {
574
715
  return Math.atan2(this._crossAxis.dot(this._upAxis), dot);
575
716
  }
576
717
 
718
+ /**
719
+ * Request pointer lock and classify the outcome (AG-754). A SecurityError
720
+ * (permission denied — e.g. an iframe without allow="pointer-lock") is permanent,
721
+ * so we fall back to drag-orbit ("unavailable"). Any other rejection (Chrome's
722
+ * post-Esc re-lock cooldown, a missing gesture) is transient: stay "unlocked",
723
+ * the next click retries.
724
+ */
725
+ private _requestLock(): void {
726
+ if (typeof document === "undefined") return;
727
+ const ret = this._domElement.requestPointerLock() as unknown as Promise<void> | undefined;
728
+ ret?.catch?.((err: unknown) => {
729
+ if ((err as DOMException)?.name === "SecurityError") {
730
+ this._setAimState("unavailable", "rejected");
731
+ }
732
+ });
733
+ }
734
+
735
+ /** Transition aim state and fire onAimChange (no-op if unchanged). */
736
+ private _setAimState(state: FollowCameraAimState, reason: FollowCameraAimEvent["reason"]): void {
737
+ if (state === this._aimState) return;
738
+ const prev = this._aimState;
739
+ this._aimState = state;
740
+ this._onAimChange?.({ state, prev, reason });
741
+ }
742
+
577
743
  /**
578
744
  * Collision test (upstream _collisionTest): 4 rays from the target's near-plane corners toward
579
745
  * the camera along the DAMPED orbit direction (a single center ray would let the near plane
@@ -58,8 +58,10 @@ side — reads as broken at a glance.
58
58
  - **Verify it, don't assume it.** Orientation **is** visible in a still — in your
59
59
  self-check screenshot confirm the hero faces its travel direction AND that NPCs driven
60
60
  by chase/aim code face their target (an enemy rotated 90° from its victim is this
61
- pipeline's most common visible bug). If you can't capture real gameplay (a draft's
62
- sign-in gate is up), say so plainly instead of skipping the check silently.
61
+ pipeline's most common visible bug). On an unpublished draft, capture real gameplay in
62
+ local test mode (`?genex_local_test=1` on the dev server the embed-auth skill's
63
+ "Self-testing a draft" section); if you still can't capture gameplay, say so plainly
64
+ instead of skipping the check silently.
63
65
 
64
66
  ## Load it into the scene
65
67
 
@@ -35,15 +35,60 @@ rules, floating-origin shot, pointer controls, and implementation limits.
35
35
 
36
36
  ## Aiming and pointer lock
37
37
 
38
- Camera-aimed action a third-person shooter reticle, first-person look
39
- wants **pointer lock**, not drag-orbit: request it on canvas click
40
- (`canvas.requestPointerLock()`), drive yaw/pitch from `mousemove` deltas while
41
- locked, and treat lock loss (Esc) as aim-paused show a small "click to aim"
42
- hint whenever unlocked. Keep drag-orbit for non-combat cameras (exploration,
43
- building, spectating). Pointer lock works everywhere a Genex game runs:
44
- standalone the game is the top-level page, and the platform's game frame
45
- already grants the pointer-lock permission no setup needed. Re-sync
46
- yaw/pitch from the camera whenever lock is acquired (rule below).
38
+ Decide the bucket first and say it in the build plan:
39
+
40
+ - **MANDATORY** — first-person of any kind (FPS, walking sim, horror) and any
41
+ mouse-aimed action (third-person shooter, turret/range). Shipping without
42
+ pointer lock here is a defect, not a style choice — validation fails it.
43
+ - **HIGHLY RECOMMENDED** third-person free-camera action/adventure (the
44
+ default `genex controller character` game). Lock is the default; keep
45
+ drag-orbit only for a stated reason (a cursor-heavy UI at the core of play).
46
+ - **NEVER** cursor-core games (top-down click-to-move, tower defense,
47
+ builders, card/puzzle), spectator/orbit showcases, and touch (pointer lock
48
+ does not exist on touch — the bundled mode no-ops there automatically).
49
+
50
+ **Mechanism — games on the bundled controller (most games):** do NOT hand-roll
51
+ lock handling; enable the `FollowCamera` aim mode and wire the two UI states:
52
+
53
+ const followCam = new FollowCamera(camera, {
54
+ domElement: renderer.domElement,
55
+ pointerLockAim: true,
56
+ onAimChange: ({ state }) => {
57
+ reticle.style.display = state === "locked" ? "" : "none";
58
+ cue.textContent = state === "unavailable" ? "Drag to look" : "Click to aim";
59
+ cue.style.display = state === "unlocked" || state === "unavailable" ? "" : "none";
60
+ },
61
+ });
62
+
63
+ Yaw/pitch re-sync on lock acquire is built in (aim shares the orbit state — the
64
+ view never snaps). First-person is the same mode plus three lines: pin the zoom
65
+ (`minDistance`/`maxDistance` ≈ 0.1), feed an eye-height target to `moveTo`, and
66
+ hide the avatar model. Pair aim with `controller.setLockForward(true)` so the
67
+ body faces where the camera looks. Pointer lock needs no setup on Genex —
68
+ standalone the game is the top-level page, and the platform's game frame already
69
+ grants the permission.
70
+
71
+ **Custom rigs only** (no bundled controller): build the pointer-look pattern in
72
+ [references/camera-rigs.md](references/camera-rigs.md) — the same contract applies.
73
+
74
+ **The aim contract (lock lifecycle — non-negotiable):**
75
+
76
+ 1. Two states only: locked = playing, unlocked = menu/paused. "Unlocked but
77
+ gameplay continues" is the imprecise-aim defect in disguise.
78
+ 2. Opening any menu (inventory/shop/dialog): exit the lock
79
+ (`followCam.setPaused(true)`), cursor returns, gameplay input pauses. Menu
80
+ keys are Tab/I/E — never Esc.
81
+ 3. Closing a menu re-locks INSIDE the close click/keypress handler
82
+ (`followCam.setPaused(false)`) — the browser requires a user gesture, so
83
+ menus close by click/keypress, never by timeout.
84
+ 4. Esc is the browser's release valve (you can't intercept it; Chrome enforces
85
+ a re-lock cooldown) → treat Esc as pause: show the overlay with a
86
+ "click to resume" button.
87
+ 5. Always-visible state: reticle while locked; real cursor + "click to
88
+ aim/resume" cue while unlocked. The cue is also the validation hook.
89
+ 6. If the lock request is rejected (a third-party page embedding the game
90
+ without `allow="pointer-lock"`), the mode falls back to drag-orbit — show
91
+ "Drag to look" instead. Never a dead game.
47
92
 
48
93
  ## Non-negotiable rules
49
94
 
@@ -39,7 +39,7 @@ fork as a migration strategy. Install a fresh copy elsewhere and port only the n
39
39
  | `shared/colliders.ts` | `cuboidCollider`, `collidersFromObject`, … | colliders for level geometry and GLB props |
40
40
  | `character/character-controller.ts` | `CharacterController` | the floating-capsule movement brain |
41
41
  | `character/presets.ts` | `characterPresets` | six named tunings |
42
- | `character/follow-camera.ts` | `FollowCamera` | orbit/zoom chase camera with collision pullback |
42
+ | `character/follow-camera.ts` | `FollowCamera` | orbit/zoom chase camera with collision pullback and an opt-in pointer-lock aim mode |
43
43
  | `character/keyboard-input.ts` | `KeyboardInput` | WASD/arrows/Shift/Space/F state, no per-frame polling setup |
44
44
  | `character/touch-joystick.ts` | `TouchJoystick`, `VirtualButton` | mobile controls |
45
45
  | `character/character-animations.ts` | `CharacterAnimations` | animation state machine + fuzzy clip binding + `playOneShot` + procedural fallback |
@@ -121,9 +121,39 @@ Rules that matter:
121
121
  - Feel: `smoothTime` (0.05 snappy → 0.25 cinematic, default 0.1),
122
122
  `initialDistance` (default 4), `initialAzimuthAngle` (default `Math.PI` —
123
123
  camera starts behind a +Z-facing character).
124
- - v1 limits (by design): no pointer-lock mode, no truck/pan, and the orbit
125
- space assumes the up axis stays roughly world +Y — far-from-Y custom gravity
126
- will misbehave.
124
+ - v1 limits (by design): no truck/pan, and the orbit space assumes the up axis
125
+ stays roughly world +Y — far-from-Y custom gravity will misbehave.
126
+
127
+ ### Pointer-lock aim (opt-in)
128
+
129
+ Set `pointerLockAim: true` and a mouse click locks the pointer; while locked,
130
+ mouse movement drives the orbit directly (no drag). The controller ships NO DOM —
131
+ wire `onAimChange` to draw a reticle (locked) and a "click to aim" cue (unlocked):
132
+
133
+ ```ts
134
+ const followCam = new FollowCamera(camera, {
135
+ domElement: renderer.domElement,
136
+ colliderMeshes: staticWallMeshes,
137
+ pointerLockAim: true,
138
+ onAimChange: ({ state }) => {
139
+ reticle.style.display = state === "locked" ? "" : "none";
140
+ cue.style.display = state === "unlocked" || state === "unavailable" ? "" : "none";
141
+ },
142
+ });
143
+ ```
144
+
145
+ - **Menus / vehicles:** `followCam.setPaused(true)` when a menu opens or the
146
+ player starts driving; `followCam.setPaused(false)` INSIDE the closing
147
+ click/keypress handler (re-lock needs a user gesture). Aim is on-foot only.
148
+ - **First-person:** the same mode plus `minDistance`/`maxDistance` ≈ 0.1, an
149
+ eye-height target fed to `moveTo`, `avatar.visible = false`, and
150
+ `controller.setLockForward(true)`.
151
+ - **Touch / rejected lock:** touch never locks (the mode reports `"off"`); a
152
+ third-party embed without `allow="pointer-lock"` goes `"unavailable"` and
153
+ drag-orbit stays as the fallback — never a dead camera.
154
+
155
+ The three-bucket rule (mandatory / recommended / never) and the full lock
156
+ lifecycle contract live in `$genex-threejs-camera-direction`.
127
157
 
128
158
  ## 6. The loop — exact order
129
159
 
@@ -23,6 +23,9 @@ for both, using the SDK's token. The SDK handles every context with one
23
23
  zero clicks; everyone else arrives as a guest with a small dismissible
24
24
  "sign in to save progress" popover (rendered by the SDK — don't build your
25
25
  own). Nobody ever hits a login wall on a published game.
26
+ - **Local test mode (your own self-testing):** `?genex_local_test=1` on the
27
+ local dev server boots a credential-less local session — see "Self-testing
28
+ a draft" below.
26
29
 
27
30
  While identity is resolving (or blocked) the SDK shows its own full-screen
28
31
  overlay over the game, so never build a separate "connecting" screen for auth.
@@ -102,7 +105,7 @@ boot-path gate; `waitForAuth()` guards saves only.
102
105
  - `getAuthState()` → `"pending" | "authenticated" | "guest" | "blocked"` —
103
106
  synchronous.
104
107
  - `getUser()` → `{ id, name, image? } | null` — non-null once authenticated OR
105
- guest. Guest ids are prefixed `guest:`.
108
+ guest. Guest ids are prefixed `guest:` (local test mode: `local:test`).
106
109
  - `getEmbedToken()` → `string | undefined` — the raw token, for the RARE
107
110
  advanced case of calling the Genex API by hand. The state/leaderboard
108
111
  helpers below attach it automatically — prefer them; never hand-roll fetch
@@ -138,7 +141,8 @@ session is blocked):
138
141
  - `getLeaderboard({ board?, limit?, order? }?)` → `Promise<{ items, me }>` —
139
142
  top entries (verified display names — never trust client-side name input
140
143
  for this) + the signed-in player's own `{ rank, score }`. Works for guests
141
- too (`me: null`). `limit` caps at 100 server-side.
144
+ too (`me: null`). `limit` caps at 100 server-side. Local test mode resolves
145
+ `{ items: [], me: null }` locally.
142
146
 
143
147
  Server write limits (per player, per minute): **60 player-saves, 120
144
148
  world-saves, 30 score submits**. A debounced ~1/sec checkpoint never gets near
@@ -256,30 +260,63 @@ SDK's own top-right "sign in to save progress" popover. The return trip
256
260
  carries a one-time pass (or an inert guest marker) in the URL that the SDK
257
261
  consumes and removes immediately. Unpublished drafts are the exception:
258
262
  strangers can't play them, so a draft link shows the SDK's sign-in gate
259
- instead. Don't code around any of this: no `?`/`#` URL params of yours will
260
- be affected, and `isEmbedded()` / the return-trip handling are internal SDK
261
- concerns.
263
+ instead (for self-testing, see local test mode below). Don't code around any
264
+ of this: no `?`/`#` URL params of yours will be affected, and `isEmbedded()`
265
+ / the return-trip handling are internal SDK concerns.
262
266
 
263
- ### Validating a draft (read before self-testing)
267
+ ### Self-testing a draft: local test mode
264
268
 
265
269
  An unpublished draft shows the sign-in gate to any browser that isn't signed
266
270
  in as the owner — **including your own test browser** (Playwright, headless
267
- Chrome). The game still boots behind the overlay: console logs, DOM snapshots,
268
- and key events all work but every screenshot shows the gate, not the game,
269
- and a gate capture is NOT visual evidence.
270
-
271
- - Validate what the gate can't hide: a clean console, the canvas booting, the
272
- HUD present in a DOM snapshot, controls registering.
273
- - Do NOT work around the gate: don't dig through the SDK's internals for
274
- undocumented URL fragments, and don't drive the user's own signed-in
275
- browser.
276
- - For the visual pass on a draft, the owner IS the QA loop — not a fallback:
277
- push `genex preview` at each playable milestone and hand it off plainly
278
- ("check the draft you can now X; ping me if something feels off"), then
279
- keep building while they look. Their run-around catches exactly what the
280
- gate hides from you: facing, proportions, feel. Once the game is
281
- **published**, any fresh browser gets in as a guest, so your own test
282
- browser works again for full visual validation.
271
+ Chrome) and the hosted draft URL applies the same identity rule. The one
272
+ supported way to see and play the game yourself is **local test mode**: open
273
+ the local dev server with the explicit opt-in marker —
274
+
275
+ ```
276
+ http://localhost:5173/?genex_local_test=1
277
+ ```
278
+
279
+ (any port; append with `&` if the URL already has a query). On an exact http
280
+ loopback origin (`localhost`, `127.0.0.1`, `[::1]`) the SDK skips the
281
+ identity flow entirely and boots a guest-like session: no redirect, no
282
+ overlay, `waitForPlayer()` resolves with the unmistakable local identity
283
+ `{ id: "local:test", name: "Local Tester" }`, and the console prints a
284
+ "local test mode" notice. Requires `@genex-ai/embed-sdk` 0.5.0+ on an older
285
+ project the marker does nothing; apply the pending platform update first (any
286
+ `genex` command prints the update nudge and how to apply it), then retry.
287
+
288
+ **It validates:** rendering, camera, controls, HUD, game feel, and real
289
+ gameplay screenshots — everything local.
290
+
291
+ **It does NOT validate** (nothing online exists in this mode; no credential
292
+ is ever minted): real sign-in (`waitForAuth()` stays pending, exactly like a
293
+ guest), saves (they queue in memory), leaderboards (`getLeaderboard()`
294
+ resolves `{ items: [], me: null }` locally), score submits, and multiplayer
295
+ (`getColyseusAuth()` is `undefined`, so `connect()` fails at the relay —
296
+ expected in this mode, not a bug to chase).
297
+
298
+ Rules:
299
+
300
+ - **Label the evidence** in your handoff: "validated in local test mode —
301
+ auth, saves, and multiplayer not exercised." Presenting a local-test
302
+ capture as full validation is an over-claim.
303
+ - Pointer lock still can't be acquired headlessly (`requestPointerLock`
304
+ throws in headless Chromium): for aim games validate the unlocked "click
305
+ to aim" cue and the wiring, not the lock itself (see
306
+ `$genex-threejs-visual-validation` step 6).
307
+ - The marker is inert on any hosted URL, on https, and inside any iframe —
308
+ local test mode cannot open the hosted draft; hosted draft access stays
309
+ owner-only. Do NOT work around that gate: no undocumented URL fragments,
310
+ no auth mocks, and never drive the user's own signed-in browser.
311
+ - Opening localhost WITHOUT the marker keeps the normal real-auth flow (the
312
+ identity bounce) — that's for testing real sign-in, not for self-testing.
313
+ - The owner's draft run-through stays a required beat, not a fallback: push
314
+ `genex preview` at each playable milestone and hand it off plainly ("check
315
+ the draft — you can now X; ping me if something feels off"), then keep
316
+ building while they look. Hosted QA catches what local test mode can't:
317
+ real identity, saves, multiplayer, feel. Once the game is **published**,
318
+ any fresh browser gets in as a guest, so full visual validation also works
319
+ without the marker.
283
320
 
284
321
  ## Checklist
285
322
 
@@ -300,6 +337,8 @@ and a gate capture is NOT visual evidence.
300
337
  - [ ] No token value is ever logged or sent to analytics.
301
338
  - [ ] No custom sign-in prompt, guest badge, or auth overlay — the SDK popover/
302
339
  overlay and the dashboard own all of that UX.
340
+ - [ ] Self-test evidence captured in local test mode is labeled as such in the
341
+ handoff ("local test mode — auth, saves, and multiplayer not exercised").
303
342
 
304
343
  ## Troubleshooting
305
344
 
@@ -312,7 +351,15 @@ and a gate capture is NOT visual evidence.
312
351
  - **State is `"blocked"` / `waitForPlayer()` rejects** — an unpublished draft
313
352
  opened by a non-owner, or auth infrastructure was unreachable. The SDK
314
353
  overlay (or the dashboard, when embedded) shows the sign-in prompt; the game
315
- just stays paused behind it. Don't retry in a loop.
354
+ just stays paused behind it. Don't retry in a loop. Self-testing a draft
355
+ locally? Use local test mode (`?genex_local_test=1`) instead.
356
+ - **Local test mode doesn't activate** — check all four: the value is exactly
357
+ `genex_local_test=1`, the origin is http loopback (`localhost`/`127.0.0.1`/
358
+ `[::1]` — not https, not a LAN IP), the page is not inside an iframe, and
359
+ `@genex-ai/embed-sdk` is 0.5.0+ (older: apply the pending platform update).
360
+ - **Multiplayer `connect()` fails in local test mode** — by design: no relay
361
+ credential exists there. Validate multiplayer on the hosted draft (the
362
+ owner's session) or the published game, and say plainly when it wasn't.
316
363
  - **Multiplayer join rejected with 401** — `connect()` ran before
317
364
  `waitForPlayer()` resolved, without `auth: getColyseusAuth()!`, or with a
318
365
  stale cached token on reconnect (read it fresh each call).
@@ -33,10 +33,11 @@ no matter how good it looks.
33
33
  itself, or apply them only to hand-rolled movement.)
34
34
  - If an action can't fire (cooldown, no ammo), say so instantly — a click, a
35
35
  dimmed icon — silence reads as broken input.
36
- - Camera-aimed shooting wants **pointer lock** (click to aim, Esc releases
37
- `$genex-threejs-camera-direction` has the rig rules); firing at a reticle
38
- with an unlocked drag-to-turn camera feels imprecise no matter how tight
39
- the numbers are.
36
+ - Camera-aimed shooting wants **pointer lock** firing at a reticle with an
37
+ unlocked drag-to-turn camera feels imprecise no matter how tight the numbers
38
+ are. The three-bucket rule + the bundled `FollowCamera` aim mode live in
39
+ `$genex-threejs-camera-direction`; on the bundled controller it's one option
40
+ flag, not hand-rolled events.
40
41
 
41
42
  ## Movement: snappy beats realistic
42
43
 
@@ -139,21 +139,43 @@ With `open`, `mm.matchmaking.status` only goes `searching`→`waiting`→`playin
139
139
 
140
140
  `open` seats you into a LIVE shared room the moment you're matched (`session` goes live, players sync)
141
141
  but doesn't "start" anything — so the pre-game lobby is simply **your room before it's grown to the
142
- size you want**. Set `minPlayers` to your target (so `status` stays `waiting` until enough arrive) and
143
- let the **host** own the "go" moment by writing a start signal to `shared` (it survives host migration).
144
- The lobby and the game are ONE `open` room — never spin up a second room for it. Two ways to present it:
145
-
146
- - **A) UI lobby (Dota-style).** While waiting, render an OVERLAY from `mm.matchmaking` instead of the
147
- game: the roster + count (`players.length` / target), each player's name, and optionally a per-player
148
- "ready" toggle (store it in per-player state or a `shared` map). The host begins when all are ready
149
- (or `players.length >= N`) by writing e.g. `session.shared.set('phase', { started: true, at: <ts> })`;
150
- every client sees it and swaps the overlay for the game.
142
+ size you want**. Set `minPlayers` to your target: the SERVER flips `mm.matchmaking.status` from
143
+ `'waiting'` to `'playing'` the instant the roster reaches it. The lobby and the game are ONE `open`
144
+ room — never spin up a second room for it.
145
+
146
+ **MANDATORY the waiting screen closes on `status === 'playing'`, and you verify it.** The single
147
+ most common lobby bug is a waiting screen that never goes away even with two players seated. Both
148
+ halves of this rule are non-negotiable:
149
+
150
+ 1. Drive the overlay's visibility from `mm.matchmaking.status` **re-read every frame in your render
151
+ loop** (it's a view you poll, not an event): `status === 'waiting'` → overlay visible with the
152
+ `players.length / minPlayers` count; `status === 'playing'` → overlay GONE, game visible. Do NOT
153
+ gate dismissal on `matchStart`/`matchEnded` (they NEVER fire for `open`), on a one-time status
154
+ read at connect, or on a host-written `shared` "go" signal as the *only* path — if that signal
155
+ is never written (host bug, host left), every player is stuck on the waiting screen forever.
156
+ A ready-check or countdown is fine as an ADDITION layered on top of `status === 'playing'`
157
+ (host writes it to `shared` so it survives host migration), never as a replacement for it.
158
+ 2. **Verify it for real before calling multiplayer done**: open the game in two browser windows
159
+ (one regular + one incognito, so they're two players), join with both, and watch the waiting
160
+ screen disappear on BOTH the moment the count reaches `minPlayers` (with
161
+ `minPlayers: 2, maxPlayers: 2`: the instant the second player joins). If it doesn't close on
162
+ both, the lobby is broken — fix it; do not ship a waiting room you haven't watched close.
163
+
164
+ Two ways to present the lobby:
165
+
166
+ - **A) UI lobby (Dota-style).** While `status === 'waiting'`, render an OVERLAY from `mm.matchmaking`
167
+ instead of the game: the roster + count (`players.length` / target), each player's name, and
168
+ optionally a per-player "ready" toggle (store it in per-player state or a `shared` map). When
169
+ `status` flips to `'playing'`, swap the overlay for the game — the host may additionally write
170
+ e.g. `session.shared.set('phase', { started: true, at: <ts> })` to sequence a countdown or
171
+ ready-gate on top, but the overlay's dismissal must not depend on it alone.
151
172
  - **B) Physical lobby (Roblox-style).** The waiting area IS a 3D scene in the SAME room: render a lobby
152
173
  and let players walk their avatars around, syncing position with `me.set` on the tick exactly like
153
174
  in-game. Show a "N / target — starting soon" sign driven by `players.length`. A "ready pad" is a nice
154
175
  affordance: players stand on it, the host counts how many are on it (from their synced positions) and
155
176
  writes a `shared` countdown; when it elapses everyone moves their camera/scene into the match — no
156
- re-matchmaking, they're already together.
177
+ re-matchmaking, they're already together. The transition into the match still keys off
178
+ `status === 'playing'` first; the pad only sequences what happens after quorum.
157
179
 
158
180
  **Private lobbies** (for `preset: 'private'`) don't use `matchmake()` — a host makes an invite code
159
181
  and friends join it; the lobby is persistent (rounds replay, nobody is evicted):
@@ -429,7 +451,11 @@ from any still capture whether movement feels smooth.** Don't try — it leads t
429
451
 
430
452
  1. **Trust the SDK's smoothing.** Draw `state` directly; don't add your own.
431
453
  2. **Verify it *runs*:** two clients, distinct meshes, both move, no console errors, each sees the
432
- other (and the ball, if any). That's all a capture can prove.
454
+ other (and the ball, if any). That's all a capture can prove. Local test mode (the embed-auth
455
+ skill's `?genex_local_test=1`) can NOT do this: it mints no relay credential, so `connect()`
456
+ fails there by design and two local-test tabs never see each other — run the two-client check
457
+ on the published game (or have the owner open their draft), and if multiplayer wasn't
458
+ exercised, say exactly that in your handoff instead of implying it was.
433
459
  3. **Then say plainly:** *"Multiplayer smoothness depends on your network and can only be felt by a
434
460
  person — open it in two tabs or with a friend and tell me how it feels."* Stop there.
435
461
 
@@ -530,6 +556,9 @@ host-driven saving works as long as ANY account is in the room.
530
556
  - [ ] Hit-tests and discrete values read from `stateRaw`, not `state`.
531
557
  - [ ] A ball / shared NPC is on `objects` (claim on contact), never on `shared`.
532
558
  - [ ] `shared` scores/rounds and host-simulated enemies are written only by `room.isHost`.
559
+ - [ ] Waiting room (if any): overlay driven by `mm.matchmaking.status` read every frame, gone the
560
+ moment it flips to `'playing'` — and you WATCHED it close in two browser windows at
561
+ `minPlayers` (never gated on `matchStart` or a host `shared` signal alone).
533
562
  - [ ] Picked the matching recipe from [references/genre-recipes.md](references/genre-recipes.md).
534
563
 
535
564
  ## Troubleshooting auth
@@ -56,6 +56,9 @@ slow physical projectiles, all against the same damage/defeat rules.)
56
56
  | Slow physical projectiles (grenades, rockets) | `objects` — one per projectile, **hard cap + `removeConfirmed`** | the thrower |
57
57
 
58
58
  **Decisions:**
59
+ - **Aim with pointer lock** — a first-person or mouse-aimed shooter is the MANDATORY pointer-lock
60
+ bucket (see `$genex-threejs-camera-direction`); ship it locked, not drag-to-turn, or aim feels
61
+ imprecise before netcode is even in play.
59
62
  - **The attacker judges the hit locally** ("favor the shooter"): raycast/cone-check against what
60
63
  *you* see, then broadcast ONE attack event naming the `targets` — and draw your own muzzle
61
64
  flash / tracer / swing arc **right there**, because `send` never echoes back to you. A high-ping
@@ -15,7 +15,7 @@ map, execution order, and acceptance gate.
15
15
 
16
16
  | Work needed | Load |
17
17
  | --- | --- |
18
- | shot composition, chase/side/orbit rigs, camera handoffs, projection ownership, pointer look, floating origins | `$genex-threejs-camera-direction` |
18
+ | shot composition, chase/side/orbit rigs, camera handoffs, projection ownership, pointer look, mouse-aimed action (shooter, FPS/first-person, sniper, turret, crosshair/reticle), mouse-look, floating origins | `$genex-threejs-camera-direction` |
19
19
  | on-foot player movement: walk/run/jump/crouch, third-person character, slopes, stairs, moving platforms, animation binding, extra animation packs (sword/pistol/magic/climb/swim/emotes via `genex controller anims`) | `$genex-threejs-character-controller` |
20
20
  | the player drives or flies something: cars, drones, vehicle physics, gearbox, enter/exit between character and vehicle | `$genex-threejs-vehicle-controllers` |
21
21
  | anything falls, collides, gets pushed, or needs physics: Rapier world setup, colliders for meshes and GLBs, collision events | `$genex-threejs-physics-rapier` |
@@ -99,6 +99,13 @@ concept-driven — a richer first build beats a grey-box one.
99
99
 
100
100
  - Start from the playable game target: player verb, scene scale, camera distance,
101
101
  input mode, and frame budget.
102
+ - Pointer bucket (decide before building, state it in the plan): **mandatory
103
+ pointer lock** — first-person of any kind, and any mouse-aimed action
104
+ (third-person shooter, turret/range). **Lock by default** — third-person
105
+ free-camera action/adventure; drag-orbit only with a stated reason (e.g. a
106
+ cursor-heavy UI core). **Never** — cursor-core games (click-to-move, tower
107
+ defense, builder, card/puzzle), orbit showcases, touch-only. The mechanism and
108
+ the full aim contract live in `$genex-threejs-camera-direction`.
102
109
  - Build silhouette, motion, and material readability before adding image effects.
103
110
  Never dress a primitive shape in glow or bloom to fake quality — authored
104
111
  forms first, then materials, then lighting, then effects last.
@@ -30,7 +30,8 @@ Three.js release or branch, and do not blindly copy demo architecture.
30
30
  overlay from the very first asset load — a player must never stare at a black
31
31
  screen; the rest of the UI states come at step 10.
32
32
  5. Add camera direction when framing, controls, transitions, or scale perception
33
- affect play.
33
+ affect play — or the game aims with the mouse (shooter/FPS/turret): pointer-lock
34
+ bucket decisions live there.
34
35
  6. Add procedural animation when object motion needs authored phases,
35
36
  convergence, looping, or deterministic timelines.
36
37
  7. Add shared fields before writing multiple independent noise layers.
@@ -192,11 +192,24 @@ followCam.update(delta);
192
192
  (default 5; 0 disables) and always yields to an active user drag-orbit.
193
193
  `cameraTarget`/`cameraUp` are reused internal vectors — copy, never mutate.
194
194
 
195
- **Follow camera v1 limits (by design):** no pointer-lock look mode, no
196
- truck/pan (the pivot is always the followed unit), and the orbit space
197
- assumes up ≈ +Y — `camera.up` lerps toward the fed up-axis, but a
198
- far-from-Y gravity direction will misbehave. For rigs beyond this, see
199
- `$genex-threejs-camera-direction`.
195
+ **Follow camera v1 limits (by design):** no truck/pan (the pivot is always the
196
+ followed unit), and the orbit space assumes up ≈ +Y — `camera.up` lerps toward
197
+ the fed up-axis, but a far-from-Y gravity direction will misbehave. For rigs
198
+ beyond this, see `$genex-threejs-camera-direction`.
199
+
200
+ **Pointer-lock aim + vehicles:** `FollowCamera`'s aim mode (see
201
+ `$genex-threejs-camera-direction`) is on-foot only — one shared camera serves
202
+ both character and vehicle, so pause aim while driving and it resumes on foot:
203
+
204
+ ```ts
205
+ onHandoff: (fromId, toId) => followCam.setPaused(toId !== CHARACTER_ID),
206
+ ```
207
+
208
+ Enter is clean — exiting a lock needs no gesture. On **exit**, though, `onHandoff`
209
+ runs deferred inside `update()`, NOT in the F-key handler, so `setPaused(false)`'s
210
+ re-lock request has no live user gesture and the browser refuses it: aim returns to
211
+ **unlocked** (the "click to aim" cue shows) and the player clicks once to re-aim —
212
+ don't assume a seamless re-lock. Vehicle cameras keep their `alignHeading` behavior.
200
213
 
201
214
  ## Multiplayer
202
215
 
@@ -25,18 +25,26 @@ evidence, temporal checks, budgets, and explicit rejection criteria.
25
25
  ## Interaction smoke check (the game fast path)
26
26
 
27
27
  For plain game tasks — nothing from the procedural/visual-system pack loaded —
28
- this is the whole acceptance gate, and it is also the minimum for every game:
28
+ this is the whole acceptance gate, and it is also the *ceiling*: a smoke check,
29
+ not a certification. Run it ONCE per milestone to catch obvious breakage, fix
30
+ what's clearly broken, and hand the feel/polish judgment to the player — don't
31
+ loop re-testing the same build, and don't try to exercise every button and edge
32
+ case yourself. The player deciding "does it feel right?" is faster and truer
33
+ than you clicking everything twice.
29
34
 
30
35
  1. Load the page in a real browser; the canvas renders (no black screen, no
31
- console errors).
36
+ console errors). For an unpublished draft, open the dev server in local
37
+ test mode — `http://localhost:5173/?genex_local_test=1` (the embed-auth
38
+ skill's "Self-testing a draft" section) — so you see the game, not the
39
+ sign-in gate.
32
40
  2. Press each documented control once (keys, pointer); assert a **visible
33
41
  response** to every one — the player moves, the camera turns, the button
34
42
  fires.
35
43
  3. Capture one screenshot of live gameplay — of the **game**, not a sign-in
36
44
  gate or loading screen. A capture of the SDK's "Sign in to play" overlay is
37
- NOT gameplay evidence; if a draft's gate blocks the view, say so plainly
38
- (see the embed-auth skill's "Validating a draft" note) instead of passing
39
- the capture off as validation.
45
+ NOT gameplay evidence. Evidence captured in local test mode must be labeled
46
+ as such in the handoff ("local test mode auth, saves, and multiplayer not
47
+ exercised"); presenting it as full validation is an over-claim.
40
48
  4. In that screenshot, check oriented models: the hero faces its travel
41
49
  direction, and NPCs driven by chase/aim code face their target. A model
42
50
  rotated 90° reads as broken — `$genex-ai-model` has the one-time facing
@@ -47,6 +55,12 @@ this is the whole acceptance gate, and it is also the minimum for every game:
47
55
  floating above detached wheels means the model was box-fit against the
48
56
  preset's wheelbase (the vehicle skill's "Custom generated bodies" rules
49
57
  fix it).
58
+ 6. Aim games get one extra pass (MANDATORY bucket — first-person or mouse-aimed):
59
+ state which pointer bucket the game chose; click the canvas and assert the
60
+ pointer locks (cursor gone, mouse turns the view); press Esc and assert the
61
+ "click to aim/resume" cue appears. Headless caveat: `requestPointerLock`
62
+ throws in headless Chromium — assert the wiring and the unlocked cue in a
63
+ screenshot, and say plainly that the lock itself needs one manual click.
50
64
 
51
65
  Everything deeper (baselines, seed sweeps, mosaics, budgets) belongs to
52
66
  visual-system work — the sequence above.
@@ -63,6 +77,8 @@ visual-system work — the sequence above.
63
77
 
64
78
  ## Failure conditions
65
79
 
80
+ - a MANDATORY-bucket aim game never requests pointer lock, or locks with no
81
+ visible unlocked cue;
66
82
  - approval relies on a single frame;
67
83
  - post-processing cannot be disabled per pass;
68
84
  - random seeds are not reproducible;