@genex-ai/cli-demo 0.40.0 → 0.41.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.40.0",
3
+ "version": "0.41.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": {
@@ -16,6 +16,11 @@ release, and removal must wait for the confirmed object-control result. The netw
16
16
  the relay-owned object `epoch` and direct-place Rapier followers on snaps, so a reset cannot create a
17
17
  synthetic kinematic sweep through bystanders.
18
18
 
19
+ Game facts that must travel WITH a pushable's pose (a scorer id, a goal epoch, a power-up flag)
20
+ go in the `extraState` option — they merge into every publish after the pose fields and come back
21
+ untouched on `view.state`/`view.stateRaw`. Do not put them in a separate `shared` key when they
22
+ must stay atomic with the object's motion.
23
+
19
24
  Vehicle seat rules the helper enforces: idle vehicles stay unowned, so a present owner IS the current
20
25
  driver — `NetworkedVehicle.enter()` refuses an owned vehicle unless the game passes
21
26
  `{ steal: true }`, and `onSeatLost` fires if the relay moves the seat without a clean `exit()`
@@ -26,6 +26,13 @@ export interface NetworkedPushableOptions<TPlayer extends Record<string, unknown
26
26
  releaseAfterRestMs?: number;
27
27
  restSpeed?: number;
28
28
  minimumOwnMs?: number;
29
+ /**
30
+ * Game facts to ride along with every published pose (a scorer id, a goal epoch, a power-up
31
+ * flag). Merged into the object state AFTER the pose fields, evaluated per publish. Keep it
32
+ * small and flat; readers get it back on `view.state`/`view.stateRaw` untouched, and it also
33
+ * flows through `reset()`/`snap` payloads you build yourself.
34
+ */
35
+ extraState?: () => Record<string, unknown>;
29
36
  onInvalidState?: (raw: Record<string, unknown>) => void;
30
37
  onOwnerChange?: (owner: string | undefined) => void;
31
38
  onRecovery?: (state: NetworkPoseState) => void;
@@ -48,6 +55,7 @@ export class NetworkedPushable<TPlayer extends Record<string, unknown>> {
48
55
  private readonly releaseAfterRestMs: number;
49
56
  private readonly restSpeed: number;
50
57
  private readonly minimumOwnMs: number;
58
+ private readonly extraState?: () => Record<string, unknown>;
51
59
  private readonly onInvalidState?: (raw: Record<string, unknown>) => void;
52
60
  private readonly onOwnerChange?: (owner: string | undefined) => void;
53
61
  private readonly onRecovery?: (state: NetworkPoseState) => void;
@@ -76,6 +84,7 @@ export class NetworkedPushable<TPlayer extends Record<string, unknown>> {
76
84
  this.releaseAfterRestMs = options.releaseAfterRestMs ?? 2000;
77
85
  this.restSpeed = options.restSpeed ?? 0.1;
78
86
  this.minimumOwnMs = options.minimumOwnMs ?? 500;
87
+ this.extraState = options.extraState;
79
88
  this.onInvalidState = options.onInvalidState;
80
89
  this.onOwnerChange = options.onOwnerChange;
81
90
  this.onRecovery = options.onRecovery;
@@ -174,7 +183,8 @@ export class NetworkedPushable<TPlayer extends Record<string, unknown>> {
174
183
  const room = this.room();
175
184
  if (!room?.objects.get(this.id)?.isMine) return;
176
185
  if (now - this.lastPublishAt < this.publishEveryMs) return;
177
- const state = roundPose(stateFromBody(this.body));
186
+ // Pose first, then game facts — extras can never clobber the transport-critical fields.
187
+ const state = { ...(this.extraState?.() ?? {}), ...roundPose(stateFromBody(this.body)) };
178
188
  const payload = JSON.stringify(state);
179
189
  if (payload === this.lastPayload && now - this.lastPublishAt < this.keepaliveMs) return;
180
190
  this.lastPayload = payload;
@@ -303,6 +303,11 @@ than silently disappearing. The budget math that matters:
303
303
  owns several parked vehicles/props must NOT `objects.set` each of them at full tick rate —
304
304
  publish an object **when it changed**, plus a low-rate keepalive (~1–2 Hz) so late joiners
305
305
  converge. Unchanged pose ⇒ no message.
306
+ - **Per-projectile objects need a hard cap + confirmed removal.** Each live projectile at 30 Hz
307
+ is 30/s of bulk budget, and every spawned id counts against the room's 128-object cap forever
308
+ unless removed. Cap live projectiles per player (2–3), remove the OLDEST via
309
+ `objects.removeConfirmed` before spawning past the cap, and `removeConfirmed` on impact/expiry
310
+ — uncapped spawns are how a shooter silently kills its own object budget.
306
311
  - If you see the drop warning, count each lane separately and also leave headroom under the global
307
312
  ceiling. Reserved capacity protects presence and control, but it is not permission to spam bulk.
308
313
 
@@ -368,9 +373,37 @@ if (room.isHost) room.shared.set("round", nextRound); // only the host advance
368
373
  room.on("host", (id) => {}); // host migrated (someone left)
369
374
  ```
370
375
 
376
+ **Award points exactly once — even across a host migration.** A newly-elected host re-observes
377
+ whatever condition the old host may already have scored (the goal state, the defeat event — they
378
+ are still on the wire). Never bump a score from a re-observable condition alone: carry a
379
+ **monotonic marker in the same `shared` write**, so the score and its dedupe commit atomically:
380
+
381
+ ```ts
382
+ // One shared "scores" map holds both the tallies and reserved "__" marker rows.
383
+ function awardOnce(scorerUid: string, name: string, marker: `__${string}`, seq: number) {
384
+ if (!room.isHost || !Number.isSafeInteger(seq)) return false;
385
+ const scores = { ...((room.shared.get("scores") ?? {}) as Record<string, { name: string; points: number }>) };
386
+ if ((scores[marker]?.points ?? -1) >= seq) return false; // already awarded by SOME host
387
+ scores[scorerUid] = { name, points: (scores[scorerUid]?.points ?? 0) + 1 };
388
+ scores[marker] = { name: "", points: seq }; // the marker rides the same write
389
+ room.shared.set("scores", scores);
390
+ return true;
391
+ }
392
+ // goals: seq = a goalEpoch you bump on each reset · kills: seq = the victim's `life` counter
393
+ ```
394
+
395
+ **Key scores by a STABLE identity, never the session id.** A session id dies on every reload —
396
+ the points orphan into a duplicate row and the player's color changes. Publish a short `uid` in
397
+ each player's state (from the embed identity: `const { user } = await waitForPlayer()`,
398
+ `uid = user.id` — stable for signed-in players AND guests; see `$genex-threejs-embed-auth`), and
399
+ key `scores`/colors/`isMe` by that uid. When an event only carries a session id, map it via
400
+ `room.players.get(sid)?.stateRaw.uid`.
401
+
371
402
  For host-simulated NPCs, the host claims and drives each enemy as an `object`; when the host
372
403
  leaves, its enemies are reassigned to the new host, which reads their `stateRaw` and keeps
373
404
  simulating. See the co-op recipe in [references/genre-recipes.md](references/genre-recipes.md).
405
+ For PvP combat (hitscan, melee, projectiles, defeat/respawn) follow the shooter recipe there —
406
+ its damage/defeat dedupe rules are what keep kills exactly-once under lag.
374
407
 
375
408
  ### Pushable / contested physics — pick the tier
376
409
 
@@ -479,6 +512,11 @@ host-driven saving works as long as ANY account is in the room.
479
512
  - [ ] Irreversible actions wait for `claimConfirmed`; held contact retries after `retryAfterMs` while still valid.
480
513
  - [ ] Respawn/reset/vehicle-mode discontinuities use `me.snap`/`objects.snap`; ordinary motion uses `set`.
481
514
  - [ ] Idle/unchanged objects republish at ≤2 Hz keepalive, never every tick (message budget).
515
+ - [ ] PvP combat follows the shooter recipe: attacks/defeats are `send` events with `seq`/`life`
516
+ dedupe keys, the victim applies its own damage, respawn publishes via `me.snap`, and
517
+ projectiles are hard-capped objects removed with `removeConfirmed`.
518
+ - [ ] Host score writes are exactly-once (marker in the same `shared` write) and keyed by the
519
+ stable embed `uid`, never the session id.
482
520
  - [ ] Host renders objects OWNED BY OTHERS from the stream (authority follows ownership).
483
521
  - [ ] `connect()` runs AFTER `await waitForPlayer()` (never `waitForAuth()` — guests would
484
522
  hang) and passes `auth: getColyseusAuth()!` (the relay rejects tokenless joins —
@@ -40,36 +40,56 @@ a contested kick settles on one owner within a snapshot; the owner leaving doesn
40
40
 
41
41
  ---
42
42
 
43
- ## Recipe 2 — Shooter / arena (top-down or FPS .io)
43
+ ## Recipe 2 — Shooter / arena PvP (hitscan, melee, projectiles)
44
44
 
45
- Fast players, instant hits, a live scoreboard. No shared physics object needed for hitscan.
45
+ Fast players, instant hits, a live scoreboard. No shared physics object needed for hitscan or melee.
46
+ (This is the exact model the netcode-park reference runs live: fists/swords, a hitscan rifle, and
47
+ slow physical projectiles, all against the same damage/defeat rules.)
46
48
 
47
49
  | Thing | Channel | Authority |
48
50
  | --- | --- | --- |
49
- | Player pos + rotation + hp | `me.set` / `players` (hp read via `stateRaw`) | each player |
50
- | A shot being fired | `send("shot", …)` | the shooter |
51
+ | Player pos + rotation + hp + `life` | `me.set` / `players` (hp/life read via `stateRaw`) | each player |
52
+ | A shot / swing being fired | `send("combat:attack", { from, seq, slot, targets, })` | the attacker |
51
53
  | Damage applied | victim's own `me.set` (hp) | the victim |
52
- | Score / round | `shared` | `room.isHost` |
53
- | Slow physical projectiles (grenades, rockets) | `objects` (one per projectile, `remove` on expiry) | the thrower |
54
+ | A defeat (kill credit) | `send("combat:defeat", { victim, attacker, life })` | the victim |
55
+ | Score / round | `shared` (exactly-once marker see SKILL.md host section) | `room.isHost` |
56
+ | Slow physical projectiles (grenades, rockets) | `objects` — one per projectile, **hard cap + `removeConfirmed`** | the thrower |
54
57
 
55
58
  **Decisions:**
56
- - **The shooter judges the hit locally** ("favor the shooter"): raycast against what *you* see,
57
- then `send("shot", { hit: targetId })` — and draw your own muzzle flash / tracer **right there**,
58
- because `send` never echoes back to you. This is how casual browser shooters feel responsive; a
59
- high-ping victim occasionally "dies behind a wall", which is inherent to server-less shooters —
60
- set that expectation, don't try to fix it with more smoothing.
61
- - **The victim applies its own damage** on receiving the shot (`if (m.hit === room.id) me.hp -= …`)
62
- each player owns their own hp, so there's no write conflict.
63
- - **Hit-tests read `stateRaw`, not `state`** — you want the raw latest position, not the
64
- render-delayed smoothed one. Same for reading remote hp (a discrete value; `state` would lerp it
65
- to fractions).
66
- - **Hitscan uses `send`, not objects** a shot is an event, not continuous state. Reserve
67
- `objects` for a *slow, visible* projectile the players can dodge (a rocket): claim on spawn,
68
- `objects.set` its arc, `objects.remove` on impact.
69
- - **Scoreboard is host-only:** the host tallies kills into `shared`; everyone renders it.
70
-
71
- **Acceptance feel:** movement is smooth for 4–8 players; hits register on what you aimed at; the
72
- scoreboard updates for everyone; one player on a throttled/backgrounded tab doesn't drag others.
59
+ - **The attacker judges the hit locally** ("favor the shooter"): raycast/cone-check against what
60
+ *you* see, then broadcast ONE attack event naming the `targets` — and draw your own muzzle
61
+ flash / tracer / swing arc **right there**, because `send` never echoes back to you. A high-ping
62
+ victim occasionally "dies behind a wall"; that's inherent to server-less shooters — set the
63
+ expectation, don't chase it with more smoothing.
64
+ - **Melee is the same event, different hit-test:** a range + cone check over `players`'
65
+ `stateRaw` positions (plus a line-of-sight ray), targets sorted by distance — single-target for
66
+ a punch, every candidate in the arc for a sword sweep.
67
+ - **Skip dead targets up front:** ignore players whose `stateRaw.hp <= 0` in every hit-test, so a
68
+ corpse can't be re-hit during its respawn window.
69
+ - **The victim applies its own damage** on receiving an attack event that names it — each player
70
+ owns their own hp, so there's no write conflict. **Dedupe per attack:** the attacker stamps every
71
+ event with a `seq`; the victim keeps a small bounded set of handled `${from}:${seq}` keys and
72
+ ignores repeats (events can arrive more than once around blips).
73
+ - **Defeat is exactly-once:** at hp 0 the VICTIM broadcasts `combat:defeat { victim, attacker,
74
+ life }` `life` is a counter in its player state that increments on every respawn, so everyone
75
+ (including the scoring host) dedupes defeats on the `${victim}:${life}` key. The HOST turns that
76
+ event into a point with the exactly-once marker write from the multiplayer skill's host section.
77
+ - **Respawn is a discontinuity:** reset hp, bump `life`, place the body at the spawn point, then
78
+ publish with `room.me.snap(state)` — never `set`, or remotes glide the corpse across the map.
79
+ - **Hit-tests read `stateRaw`, not `state`** — the raw latest position, not the render-delayed
80
+ smoothed one. Same for reading remote hp/life (discrete values; `state` would lerp them).
81
+ - **Hitscan/melee use `send`, never objects** — a shot is an event, not continuous state. Reserve
82
+ `objects` for a *slow, visible* projectile players can dodge (a rocket): `claim` on spawn,
83
+ `objects.set` its arc at ~30 Hz, `removeConfirmed` on impact/expiry, and a **hard per-player
84
+ cap** that removes the oldest live projectile before spawning a new one (netcode-park ships
85
+ cap = 2). Uncapped per-projectile spawns walk into the room's 128-object cap AND the message
86
+ budget; the cap is what kept projectiles smooth for everyone in live testing.
87
+ - **Scoreboard is host-only and keyed by the stable `uid`**, never the session id (see the
88
+ multiplayer skill's host section) — a reload must keep the player's points and color.
89
+
90
+ **Acceptance feel:** movement is smooth for 4–8 players; hits register on what you aimed at; a kill
91
+ scores exactly one point even if the host changes mid-fight; the scoreboard survives a reload; one
92
+ player on a throttled/backgrounded tab doesn't drag others.
73
93
 
74
94
  ---
75
95
 
@@ -137,7 +137,24 @@ Rules that keep it correct:
137
137
  clients fighting over the ball. You don't need to check "am I owner" before drawing, only before
138
138
  simulating.
139
139
  - **Transient objects** (bullets, pickups): the owner awaits `room.objects.removeConfirmed(id)`
140
- when they expire, so destruction is acknowledged and idempotent across reconnects.
140
+ when they expire, so destruction is acknowledged and idempotent across reconnects. **Always cap
141
+ them**: every spawned id counts against the room's 128-object limit until removed, and each live
142
+ projectile publishing at 30 Hz spends 30/s of your bulk budget. Keep a small per-player cap
143
+ (2–3, netcode-park ships 2) and remove the OLDEST before spawning past it:
144
+
145
+ ```ts
146
+ const live = new Map<string, Projectile>(); // insertion order = age
147
+ function spawnProjectile(id: string, state: Record<string, unknown>) {
148
+ while (live.size >= MAX_LIVE_PROJECTILES) {
149
+ const oldest = live.keys().next().value!;
150
+ live.delete(oldest);
151
+ void room.objects.removeConfirmed(oldest); // acknowledged — no ghost debris for late joiners
152
+ }
153
+ room.objects.claim(id);
154
+ room.objects.set(id, state);
155
+ live.set(id, makeProjectile(id));
156
+ }
157
+ ```
141
158
 
142
159
  ## Host authority (scores, rounds, world)
143
160
 
@@ -75,7 +75,11 @@ games with no on-foot character.
75
75
  VELOCITY vs POSITION control modes, the PD gain mass-scaling rule, presets.
76
76
  - [references/enter-exit.md](references/enter-exit.md) — `EnterExitManager`,
77
77
  park/unpark, sensor tuning, seat animations (`Sitting_Enter` /
78
- `Driving_Loop` / `Sitting_Exit`), follow-camera handoff.
78
+ `Driving_Loop` / `Sitting_Exit`), follow-camera handoff — **including the
79
+ touch interact button**: on phones there is no F key, so the enter prompt
80
+ must be tappable (and an Exit button shown while driving) or the shared
81
+ link is unenterable on mobile. Wire it whenever you wire the touch
82
+ joysticks (`navigator.maxTouchPoints > 0`).
79
83
 
80
84
  ## Presets at a glance
81
85
 
@@ -70,6 +70,20 @@ physics.onCollisionEvent((h1, h2, started) =>
70
70
  // Interact: EDGE-triggered (key transition, never a held-key poll — that
71
71
  // would enter and exit every frame). kb.onInteract is the F key, pre-guarded.
72
72
  kb.onInteract(() => mgr.requestInteract());
73
+
74
+ // TOUCH DEVICES: there is no F key on a phone — make the SAME prompt tappable, or the
75
+ // game is unenterable on the shared link. One button, the exact same code path as the key
76
+ // (claim-before-park in multiplayer included); it doubles as the Exit button while driving.
77
+ if (navigator.maxTouchPoints > 0) {
78
+ promptEl.style.pointerEvents = "auto"; // the prompt div IS the button
79
+ promptEl.addEventListener("pointerdown", (e) => {
80
+ e.preventDefault();
81
+ onInteractPressed(); // whatever your F-key handler calls
82
+ });
83
+ // While driving there's no proximity prompt — show a fixed "Exit" button instead:
84
+ exitBtn.style.display = mgrIsDriving ? "block" : "none"; // toggle in onHandoff
85
+ exitBtn.addEventListener("pointerdown", () => mgr.requestInteract());
86
+ }
73
87
  ```
74
88
 
75
89
  Loop order per fixed substep (inside `physics.onBeforeStep`): **`mgr.update(dt)`