@genex-ai/cli-demo 0.40.0 → 0.42.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 +53 -6
- 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/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
|
-
`
|
|
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
|
-
|
|
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 =
|
|
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
|
@@ -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)`
|