@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 +1 -1
- package/templates/controllers/NETWORKING.md +5 -0
- package/templates/controllers/network/networked-pushable.ts +11 -1
- package/templates/skills/genex-threejs-multiplayer/SKILL.md +38 -0
- package/templates/skills/genex-threejs-multiplayer/references/genre-recipes.md +43 -23
- package/templates/skills/genex-threejs-multiplayer/references/realtime-patterns.md +18 -1
- package/templates/skills/genex-threejs-vehicle-controllers/SKILL.md +5 -1
- package/templates/skills/genex-threejs-vehicle-controllers/references/enter-exit.md +14 -0
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
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("
|
|
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
|
-
|
|
|
53
|
-
|
|
|
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
|
|
57
|
-
|
|
58
|
-
because `send` never echoes back to you.
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
- **
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
- **
|
|
67
|
-
|
|
68
|
-
`
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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)`
|