@genex-ai/cli-demo 0.41.0 → 0.43.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
@@ -652,9 +652,6 @@ function tokenizeCommand(input) {
652
652
  return tokens;
653
653
  }
654
654
 
655
- // src/lib/project.ts
656
- import crypto2 from "crypto";
657
-
658
655
  // src/utils/colors.ts
659
656
  var useColor = Boolean(process.stdout.isTTY) && process.env.NO_COLOR === void 0 && process.env.TERM !== "dumb";
660
657
  var ESC = String.fromCharCode(27);
@@ -753,8 +750,21 @@ async function apiFetch(url, init2 = {}) {
753
750
  }
754
751
  return res;
755
752
  }
753
+ async function fetchSignedInEmail(apiUrl, token) {
754
+ try {
755
+ const res = await apiFetch(`${apiUrl}/api/auth/get-session`, {
756
+ headers: { Authorization: `Bearer ${token}` }
757
+ });
758
+ if (!res.ok) return null;
759
+ const data = await res.json().catch(() => null);
760
+ return data?.user?.email ?? null;
761
+ } catch {
762
+ return null;
763
+ }
764
+ }
756
765
 
757
766
  // src/lib/project.ts
767
+ import crypto2 from "crypto";
758
768
  async function createDraftProject(opts) {
759
769
  const { apiUrl, token, colyseusUrl, dashboardUrl, log } = opts;
760
770
  log.step("Creating your project\u2026");
@@ -798,7 +808,7 @@ async function createDraftProject(opts) {
798
808
  }
799
809
  log.success(`Created project ${c.cyan(project.slug)}.`);
800
810
  log.plain(
801
- ` your game's page (live now \u2014 share this link): ${c.cyan(`${new URL(dashboardUrl).origin}/draft/${project.slug}`)}`
811
+ ` \u279C Send this link to the user right now \u2014 their game's page is already live (a placeholder world until the first preview): ${c.cyan(`${new URL(dashboardUrl).origin}/draft/${project.slug}`)}`
802
812
  );
803
813
  if (project.playUrl) log.dim(` play (after preview/publish): ${project.playUrl}`);
804
814
  log.dim(` dashboard: ${dashboardUrl}/dashboard`);
@@ -821,6 +831,19 @@ async function createDraftProject(opts) {
821
831
  function randomSuffix() {
822
832
  return crypto2.randomBytes(3).toString("hex");
823
833
  }
834
+ async function fetchProjectStatus(apiUrl, token, slug) {
835
+ try {
836
+ const res = await apiFetch(`${apiUrl}/api/projects/by-slug/${encodeURIComponent(slug)}`, {
837
+ headers: { Authorization: `Bearer ${token}` }
838
+ });
839
+ if (!res.ok) return null;
840
+ const data = await res.json().catch(() => null);
841
+ if (!data?.project) return null;
842
+ return data.project.status ?? "draft";
843
+ } catch {
844
+ return null;
845
+ }
846
+ }
824
847
 
825
848
  // src/lib/ssh.ts
826
849
  import fs4 from "fs/promises";
@@ -1099,6 +1122,8 @@ async function runInit(opts) {
1099
1122
  await writeGitignore(process.cwd(), log);
1100
1123
  const apiUrl = getApiUrl(opts.apiUrl);
1101
1124
  const colyseusUrl = getColyseusUrl(opts.colyseusUrl);
1125
+ const signedInEmail = await fetchSignedInEmail(apiUrl, token);
1126
+ if (signedInEmail) log.plain(` signed in as ${c.cyan(signedInEmail)}`);
1102
1127
  const projectName = opts.name?.trim() || path8.basename(process.cwd());
1103
1128
  const meta = await createDraftProject({
1104
1129
  apiUrl,
@@ -1157,6 +1182,8 @@ async function runLink(opts) {
1157
1182
  log.plain("");
1158
1183
  }
1159
1184
  const apiUrl = getApiUrl(opts.apiUrl);
1185
+ const email = await fetchSignedInEmail(apiUrl, token);
1186
+ if (email) log.plain(` signed in as ${c.cyan(email)}`);
1160
1187
  log.step(`Looking up ${c.cyan(slug)}\u2026`);
1161
1188
  const project = await fetchOwnProject(apiUrl, token, slug, log);
1162
1189
  if (!project) {
@@ -1382,6 +1409,16 @@ async function getUploadToken(ctx, commit, log) {
1382
1409
  log.error(`Couldn't reach the API at ${ctx.apiUrl}: ${String(err)}`);
1383
1410
  return null;
1384
1411
  }
1412
+ if (res.status === 401) {
1413
+ log.error("Not authorized (HTTP 401) \u2014 your sign-in expired or was revoked.");
1414
+ log.plain(" Fix: run `genex link <your-game>` and sign in as the game's owner, then re-run this command.");
1415
+ return null;
1416
+ }
1417
+ if (res.status === 404) {
1418
+ log.error("This account doesn't own this game (HTTP 404) \u2014 you're signed in as a different account.");
1419
+ log.plain(" Fix: run `genex link <your-game>` and sign in as the OWNER account, then re-run this command.");
1420
+ return null;
1421
+ }
1385
1422
  if (!res.ok) {
1386
1423
  log.error(`Couldn't get an upload token (HTTP ${res.status}).`);
1387
1424
  return null;
@@ -1831,11 +1868,21 @@ async function runPreview(opts) {
1831
1868
  process.exitCode = 1;
1832
1869
  return;
1833
1870
  }
1871
+ const freshStatus = await fetchProjectStatus(apiUrl, token, meta.slug);
1872
+ if (freshStatus && freshStatus !== meta.status) {
1873
+ meta.status = freshStatus;
1874
+ await writeProject(meta);
1875
+ }
1876
+ const published = meta.status === "published";
1834
1877
  log.plain("");
1835
- log.success("Preview deployed \u2014 unlisted draft (run `genex publish` to list it).");
1878
+ if (published) {
1879
+ log.success("Update deployed \u2014 this game is PUBLISHED, so the new build is already live for everyone.");
1880
+ } else {
1881
+ log.success("Preview deployed \u2014 unlisted draft (run `genex publish` to list it).");
1882
+ }
1836
1883
  const dashboard = meta.dashboardOrigins?.[0];
1837
1884
  if (dashboard) {
1838
- const page = meta.status === "published" ? "world" : "draft";
1885
+ const page = published ? "world" : "draft";
1839
1886
  log.plain(` your game's page (share this link): ${c.cyan(`${dashboard}/${page}/${meta.slug}`)}`);
1840
1887
  }
1841
1888
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@genex-ai/cli-demo",
3
- "version": "0.41.0",
3
+ "version": "0.43.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
@@ -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
 
@@ -270,6 +270,10 @@ and a gate capture is NOT visual evidence.
270
270
 
271
271
  - Validate what the gate can't hide: a clean console, the canvas booting, the
272
272
  HUD present in a DOM snapshot, controls registering.
273
+ - Pointer lock can't be acquired headlessly either (`requestPointerLock` throws
274
+ in headless Chromium): for aim games validate the unlocked "click to aim" cue
275
+ and the wiring, not the lock itself (see `$genex-threejs-visual-validation`
276
+ step 6).
273
277
  - Do NOT work around the gate: don't dig through the SDK's internals for
274
278
  undocumented URL fragments, and don't drive the user's own signed-in
275
279
  browser.
@@ -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):
@@ -530,6 +552,9 @@ host-driven saving works as long as ANY account is in the room.
530
552
  - [ ] Hit-tests and discrete values read from `stateRaw`, not `state`.
531
553
  - [ ] A ball / shared NPC is on `objects` (claim on contact), never on `shared`.
532
554
  - [ ] `shared` scores/rounds and host-simulated enemies are written only by `room.isHost`.
555
+ - [ ] Waiting room (if any): overlay driven by `mm.matchmaking.status` read every frame, gone the
556
+ moment it flips to `'playing'` — and you WATCHED it close in two browser windows at
557
+ `minPlayers` (never gated on `matchStart` or a host `shared` signal alone).
533
558
  - [ ] Picked the matching recipe from [references/genre-recipes.md](references/genre-recipes.md).
534
559
 
535
560
  ## 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
 
@@ -47,6 +47,12 @@ this is the whole acceptance gate, and it is also the minimum for every game:
47
47
  floating above detached wheels means the model was box-fit against the
48
48
  preset's wheelbase (the vehicle skill's "Custom generated bodies" rules
49
49
  fix it).
50
+ 6. Aim games get one extra pass (MANDATORY bucket — first-person or mouse-aimed):
51
+ state which pointer bucket the game chose; click the canvas and assert the
52
+ pointer locks (cursor gone, mouse turns the view); press Esc and assert the
53
+ "click to aim/resume" cue appears. Headless caveat: `requestPointerLock`
54
+ throws in headless Chromium — assert the wiring and the unlocked cue in a
55
+ screenshot, and say plainly that the lock itself needs one manual click.
50
56
 
51
57
  Everything deeper (baselines, seed sweeps, mosaics, budgets) belongs to
52
58
  visual-system work — the sequence above.
@@ -63,6 +69,8 @@ visual-system work — the sequence above.
63
69
 
64
70
  ## Failure conditions
65
71
 
72
+ - a MANDATORY-bucket aim game never requests pointer lock, or locks with no
73
+ visible unlocked cue;
66
74
  - approval relies on a single frame;
67
75
  - post-processing cannot be disabled per pass;
68
76
  - random seeds are not reproducible;