@genex-ai/cli-demo 0.38.0 → 0.40.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/README.md +22 -1
- package/dist/index.js +153 -12
- package/package.json +5 -1
- package/templates/controllers/NETWORKING.md +29 -0
- package/templates/controllers/character/character-controller.ts +90 -0
- package/templates/controllers/drone/drone-controller.ts +8 -0
- package/templates/controllers/interact/enter-exit.ts +14 -5
- package/templates/controllers/network/networked-pushable.ts +263 -0
- package/templates/controllers/network/networked-vehicle.ts +240 -0
- package/templates/controllers/network/pose.ts +114 -0
- package/templates/controllers/shared/physics-world.ts +10 -0
- package/templates/controllers/vehicle/vehicle-controller.ts +5 -0
- package/templates/skills/genex-threejs-character-controller/SKILL.md +10 -1
- package/templates/skills/genex-threejs-multiplayer/SKILL.md +54 -34
- package/templates/skills/genex-threejs-multiplayer/references/genre-recipes.md +8 -6
- package/templates/skills/genex-threejs-multiplayer/references/host-physics.md +39 -21
- package/templates/skills/genex-threejs-multiplayer/references/realtime-patterns.md +8 -6
- package/templates/skills/genex-threejs-vehicle-controllers/SKILL.md +23 -8
- package/templates/skills/genex-threejs-vehicle-controllers/references/enter-exit.md +19 -10
- package/templates/controllers/shared/NETWORKING.md +0 -11
|
@@ -34,13 +34,17 @@ example, the shared-object/ball code, rotation, and host usage. Read
|
|
|
34
34
|
## Install
|
|
35
35
|
|
|
36
36
|
```bash
|
|
37
|
-
npm i @genex-ai/multiplayer@^0.
|
|
37
|
+
npm i @genex-ai/multiplayer@^0.9.0
|
|
38
38
|
```
|
|
39
39
|
|
|
40
|
-
> Pin `@^0.
|
|
41
|
-
>
|
|
40
|
+
> Pin `@^0.9.0` (not a bare `npm i`): confirmed object controls, discontinuity snaps, host-tick
|
|
41
|
+
> teardown, and reconnect rebasing landed in 0.9. An older resolve does not have
|
|
42
|
+
> `room.objects.claimConfirmed` or `room.me.snap`.
|
|
42
43
|
|
|
43
|
-
This skill targets `@genex-ai/multiplayer` **≥ 0.
|
|
44
|
+
This skill targets `@genex-ai/multiplayer` **≥ 0.9.0** (`objects`/`host` since 0.4;
|
|
45
|
+
`matchmake()` since 0.5; private lobbies since 0.7; auto-reconnect + `inputs`/`onHostTick`
|
|
46
|
+
since 0.8; soft ownership handoff since 0.8.4; confirmed controls, snap epochs, and host-tick
|
|
47
|
+
lifecycle guarantees since 0.9).
|
|
44
48
|
|
|
45
49
|
## Trust model (say it plainly in your game's copy)
|
|
46
50
|
|
|
@@ -189,16 +193,18 @@ const room = await connect<State>({
|
|
|
189
193
|
});
|
|
190
194
|
```
|
|
191
195
|
|
|
192
|
-
**Capacity:**
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
`connect()` room.
|
|
196
|
+
**Capacity:** 64 players is the relay's **mechanical seat cap**, not a proven high-motion physics
|
|
197
|
+
envelope. Object-heavy rooms amplify fanout; measure the exact game at 8/16/32/64 before promising a
|
|
198
|
+
supported count. Above the cap the relay opens another room for the same game. If the game needs one
|
|
199
|
+
seated competitive world, use matchmaking rather than one large `connect()` room.
|
|
196
200
|
|
|
197
201
|
## Disconnects & reconnection (built in — render it, don't rebuild it)
|
|
198
202
|
|
|
199
|
-
The SDK auto-reconnects after a network blip or brief signal loss: the relay holds your seat
|
|
200
|
-
|
|
201
|
-
|
|
203
|
+
The SDK auto-reconnects after a network blip or brief signal loss: the relay holds your seat for a
|
|
204
|
+
grace window (~30 s). A short blip keeps the same session id, ownership, and host. If a disconnected
|
|
205
|
+
host exceeds the shorter simulation lease, an active peer takes authority once; the old host can
|
|
206
|
+
return to its seat but is demoted. Long reconnects rebase remote smoothing rather than replaying a
|
|
207
|
+
whole-map catch-up streak. Your UI still reflects connection state:
|
|
202
208
|
|
|
203
209
|
```ts
|
|
204
210
|
room.on("reconnecting", ({ attempt }) => showOverlay(`Reconnecting… (${attempt})`));
|
|
@@ -243,20 +249,28 @@ fights (many writers). A ball on `objects` glides and has one owner. That's the
|
|
|
243
249
|
- `room.id` — your own session id.
|
|
244
250
|
- `room.me.set(state)` — publish your state, **replaces it wholesale**. Fixed **10–20 Hz tick**,
|
|
245
251
|
never per frame.
|
|
252
|
+
- `room.me.snap(state)` — respawn/teleport/mode edge. Publishes a discontinuity epoch so remotes
|
|
253
|
+
hard-reseed instead of interpolating from the old pose. Never use for ordinary movement.
|
|
246
254
|
- `room.players` — fresh `Map` each read, **includes you** (skip `id === room.id`). Each value is
|
|
247
255
|
`{ id, name, state, stateRaw }`: `state` is auto-smoothed (remotes) / live (you); `stateRaw` is
|
|
248
256
|
the raw latest (hit-tests, discrete values).
|
|
249
257
|
- `room.objects` — shared objects nobody owns until claimed (a ball, an NPC):
|
|
250
|
-
- `claim(id)` —
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
258
|
+
- `claim(id)` — **legacy** optimistic request. It flips local ownership immediately and is corrected
|
|
259
|
+
if the relay rejects it. Keep only for reversible old-game behavior.
|
|
260
|
+
- `await claimConfirmed(id, options?)` — authoritative accepted/rejected result for a kick, seat,
|
|
261
|
+
reset, or other irreversible action. Ordinary claims, including host-player contact, honor the
|
|
262
|
+
relay minimum hold. `reason: "held"` includes `retryAfterMs`; retry only while contact/intent is
|
|
263
|
+
still valid. `{ authority: "host" }` is reserved for current-host seed/reset lifecycle work.
|
|
264
|
+
A real hold bypass returns `host-authority`; a non-host request returns `not-host`. A current-owner
|
|
265
|
+
reassert is accepted as `already-owner` without extending the hold.
|
|
254
266
|
- `set(id, state)` — publish it (only lands while you own it; full flat object each call).
|
|
255
|
-
- `get(id)` → `{ id, owner, isMine, state, stateRaw }` or `undefined`. `state` is auto-smoothed
|
|
267
|
+
- `get(id)` → `{ id, owner, isMine, epoch, state, stateRaw }` or `undefined`. `state` is auto-smoothed
|
|
256
268
|
(or live if `isMine`); `stateRaw` is the raw latest.
|
|
257
|
-
- `release(id)`
|
|
258
|
-
|
|
259
|
-
|
|
269
|
+
- `release(id)` / `remove(id)` — legacy fire-and-forget operations.
|
|
270
|
+
- `releaseConfirmed(id)` / `removeConfirmed(id)` — acknowledged, idempotent operations; use when
|
|
271
|
+
local mode/state depends on convergence. Remove stays owner-only.
|
|
272
|
+
- `snap(id, state)` — owner-only reset/teleport with an object discontinuity epoch. Ordinary motion
|
|
273
|
+
stays on `set`.
|
|
260
274
|
- `ids()` — all object ids seen.
|
|
261
275
|
- `room.isHost` / `room.host` — you are (or who is) the elected authority. Use to pick the single
|
|
262
276
|
writer of `shared` scores/rounds and the single simulator of host-owned objects. Settles within
|
|
@@ -272,26 +286,25 @@ fights (many writers). A ball on `objects` glides and has one owner. That's the
|
|
|
272
286
|
- `room.inputs.send(payload)` / `room.inputs.on((fromId, payload) => …)` — the host-routed
|
|
273
287
|
input channel for host-authoritative physics: anyone sends, ONLY the current host receives.
|
|
274
288
|
See [references/host-physics.md](references/host-physics.md).
|
|
275
|
-
- `room.onHostTick(hz, cb)` —
|
|
276
|
-
|
|
289
|
+
- `room.onHostTick(hz, cb)` — fixed simulation only while connected and host. It pauses during
|
|
290
|
+
reconnect, resumes only if still host, and stops on demotion, deliberate leave, or terminal
|
|
291
|
+
disconnect. Returns a disposer for removing the subsystem earlier.
|
|
277
292
|
|
|
278
293
|
## Your message budget (every publish is one relay message)
|
|
279
294
|
|
|
280
|
-
Every
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
295
|
+
Every publish costs one relay message. The relay uses a global ceiling plus reserved lanes: player
|
|
296
|
+
state 30/s sustained, object control 40/s, host input 60/s, and bulk object/shared/custom traffic
|
|
297
|
+
120/s (all with bursts). A bulk-object flood therefore cannot consume the avatar or control lane.
|
|
298
|
+
Drops still produce the SDK warning; confirmed controls additionally resolve `rate-limited` rather
|
|
299
|
+
than silently disappearing. The budget math that matters:
|
|
284
300
|
|
|
285
301
|
- Your own state (`me.set`) at 15 Hz + ONE driven/owned moving object at 15 Hz = 30/s. Fine.
|
|
286
302
|
- The pattern that blows the budget: **republishing IDLE objects every tick.** A host that
|
|
287
303
|
owns several parked vehicles/props must NOT `objects.set` each of them at full tick rate —
|
|
288
304
|
publish an object **when it changed**, plus a low-rate keepalive (~1–2 Hz) so late joiners
|
|
289
305
|
converge. Unchanged pose ⇒ no message.
|
|
290
|
-
- If you see the drop warning, count
|
|
291
|
-
|
|
292
|
-
- Over budget, the relay spreads the loss across ALL your streams (everything gets choppy at
|
|
293
|
-
once) rather than freezing one — so a single stuttering object is your cue to check the whole
|
|
294
|
-
budget, not just that object. The warning is the signal; don't design at the edge of the cap.
|
|
306
|
+
- If you see the drop warning, count each lane separately and also leave headroom under the global
|
|
307
|
+
ceiling. Reserved capacity protects presence and control, but it is not permission to spam bulk.
|
|
295
308
|
|
|
296
309
|
## The loop you must build (input → local → tick → render)
|
|
297
310
|
|
|
@@ -325,7 +338,9 @@ relay enforce it), the owner simulates it, and everyone else reads it auto-smoot
|
|
|
325
338
|
interpolation as a player. Ownership survives the owner leaving (reassigned to the host).
|
|
326
339
|
|
|
327
340
|
```ts
|
|
328
|
-
|
|
341
|
+
const result = await room.objects.claimConfirmed("ball");
|
|
342
|
+
if (result.accepted) applyKickAndFeedback();
|
|
343
|
+
else if (result.reason === "held" && stillTouching) retryAfter(result.retryAfterMs);
|
|
329
344
|
|
|
330
345
|
if (room.objects.get("ball")?.isMine) {
|
|
331
346
|
room.objects.set("ball", stepBallPhysics()); // only the owner's writes land
|
|
@@ -334,8 +349,11 @@ const ball = room.objects.get("ball");
|
|
|
334
349
|
if (ball) drawBall(ball.state); // smoothed for everyone, live for the owner
|
|
335
350
|
```
|
|
336
351
|
|
|
337
|
-
**
|
|
338
|
-
|
|
352
|
+
**Keep contact validity alive until acceptance; do not consume one rejected rising edge forever.**
|
|
353
|
+
Never claim every frame: maintain one pending request, then retry after the relay delay only while the
|
|
354
|
+
contact remains valid. Keep object state flat. For Rapier pushables, install the shipped state machine
|
|
355
|
+
with `genex controller networked-physics`; see
|
|
356
|
+
[references/host-physics.md](references/host-physics.md).
|
|
339
357
|
|
|
340
358
|
## Host authority (scores, rounds, enemies)
|
|
341
359
|
|
|
@@ -454,10 +472,12 @@ host-driven saving works as long as ANY account is in the room.
|
|
|
454
472
|
|
|
455
473
|
## Checklist
|
|
456
474
|
|
|
457
|
-
- [ ] `npm i @genex-ai/multiplayer@^0.
|
|
475
|
+
- [ ] `npm i @genex-ai/multiplayer@^0.9.0` (confirmed controls, snap epochs, reconnect-safe host ticks); config wired into the build.
|
|
458
476
|
- [ ] `reconnecting`/`reconnected`/`disconnect` render an overlay (don't tear the scene down).
|
|
459
477
|
- [ ] Numbers rounded (~2 decimals) before `me.set`/`objects.set`.
|
|
460
478
|
- [ ] Pushable/ownable objects (ball, box, prop) use claim-on-touch + a Rapier proxy (the soft handoff glides the handoff); only a genuine simultaneous tug-of-war (sumo) uses the host-authoritative pattern. See host-physics.md.
|
|
479
|
+
- [ ] Irreversible actions wait for `claimConfirmed`; held contact retries after `retryAfterMs` while still valid.
|
|
480
|
+
- [ ] Respawn/reset/vehicle-mode discontinuities use `me.snap`/`objects.snap`; ordinary motion uses `set`.
|
|
461
481
|
- [ ] Idle/unchanged objects republish at ≤2 Hz keepalive, never every tick (message budget).
|
|
462
482
|
- [ ] Host renders objects OWNED BY OTHERS from the stream (authority follows ownership).
|
|
463
483
|
- [ ] `connect()` runs AFTER `await waitForPlayer()` (never `waitForAuth()` — guests would
|
|
@@ -23,8 +23,9 @@ The genre where a single contested object is the whole game.
|
|
|
23
23
|
| Goal celebration / whistle | `send` | whoever scored / the host |
|
|
24
24
|
|
|
25
25
|
**Decisions:**
|
|
26
|
-
- **
|
|
27
|
-
`room.objects.
|
|
26
|
+
- **Confirm the ball claim on contact**, not every frame: when your player's collider touches the
|
|
27
|
+
ball, await `room.objects.claimConfirmed("ball")` and apply the kick impulse only after
|
|
28
|
+
`accepted`. If the result is `held`, retry after `retryAfterMs` only while contact still exists.
|
|
28
29
|
- **Only the owner simulates** the ball (`if (objects.get("ball")?.isMine) objects.set("ball", …)`
|
|
29
30
|
in the tick). Everyone draws `objects.get("ball").state`. Non-owner writes are dropped by the
|
|
30
31
|
relay, so two players kicking at once resolve to one owner — no fighting.
|
|
@@ -85,9 +86,10 @@ and it's the host, so it survives players joining and leaving.
|
|
|
85
86
|
| Spawn flashes, hit sparks | `send` | the host / whoever hit |
|
|
86
87
|
|
|
87
88
|
**Decisions:**
|
|
88
|
-
- **The host owns and simulates the enemies.** On spawn, the host
|
|
89
|
-
|
|
90
|
-
|
|
89
|
+
- **The host owns and simulates the enemies.** On spawn, the host uses
|
|
90
|
+
`claimConfirmed(id, { authority: "host" })`; after acceptance its tick runs the AI and
|
|
91
|
+
`objects.set`s each enemy. Non-host clients never simulate enemies — they just draw
|
|
92
|
+
`objects.get("enemy:n").state` (smoothed) and read `stateRaw` for hit-tests.
|
|
91
93
|
- **One object per enemy** (flat `{x,y,z,hp}`) so each smooths independently. For a big horde keep
|
|
92
94
|
the count modest (≈8–16 active); it's casual, not a bullet-hell server.
|
|
93
95
|
- **Host migration keeps the game alive:** if the host leaves, its enemies are reassigned to the
|
|
@@ -98,7 +100,7 @@ and it's the host, so it survives players joining and leaving.
|
|
|
98
100
|
`send` never echoes to the sender — so when the **host itself** shoots an enemy it owns, it must
|
|
99
101
|
apply that damage to its local enemy sim **directly**, not via `send` (which wouldn't come back).
|
|
100
102
|
Rule of thumb: if `objects.get("enemy:3")?.isMine`, apply the hit locally; otherwise `send` it.
|
|
101
|
-
Enemy death: the host `objects.
|
|
103
|
+
Enemy death: the host awaits `objects.removeConfirmed("enemy:3")` before finalizing rewards.
|
|
102
104
|
- **Waves/score are host-only** in `shared`; late joiners read the current wave on connect.
|
|
103
105
|
|
|
104
106
|
**Acceptance feel:** enemies move smoothly for everyone; killing the host's tab mid-wave promotes a
|
|
@@ -19,6 +19,17 @@ ownership to "whoever touched last" is itself the wrong model.
|
|
|
19
19
|
|
|
20
20
|
## Tier 1 — pushable / ownable body (claim-on-touch + a Rapier proxy)
|
|
21
21
|
|
|
22
|
+
Install the product helper first:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
genex controller networked-physics
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
`NetworkedPushable` owns the confirmed-claim/retry, dynamic-to-kinematic switch, visible-proxy pose
|
|
29
|
+
plus raw-momentum handoff seed,
|
|
30
|
+
single-smoothed follower, moved-only publishing, keepalive, release-on-rest, finite pose validation,
|
|
31
|
+
and host reset snap. Prefer that helper over copying this explanatory state machine.
|
|
32
|
+
|
|
22
33
|
The object is a REAL Rapier body on every client, and its body TYPE follows ownership:
|
|
23
34
|
|
|
24
35
|
- **You own it → a `dynamic` body.** Your (dynamic) character capsule collides with it and Rapier's
|
|
@@ -45,14 +56,16 @@ const body = physics.createBody({
|
|
|
45
56
|
userData: { controller: { excludeCharacterRay: true } },
|
|
46
57
|
});
|
|
47
58
|
ballCollider(physics.world, body, RADIUS, { friction: 0.5, restitution: 0.35, density: 0.6 });
|
|
48
|
-
let
|
|
59
|
+
let touching = false, wasMine = false, pendingClaim: Promise<unknown> | null = null, retryAt = 0;
|
|
49
60
|
const MAX_SPEED = 24;
|
|
50
61
|
|
|
51
62
|
// The host seeds the canonical spawn ONCE so the object exists on `objects` for late joiners.
|
|
52
|
-
function ensureInit() {
|
|
63
|
+
async function ensureInit() {
|
|
53
64
|
if (room.isHost && room.objects.get("ball") === undefined) {
|
|
54
|
-
room.objects.
|
|
55
|
-
|
|
65
|
+
const result = await room.objects.claimConfirmed("ball", { authority: "host" });
|
|
66
|
+
if (!result.accepted) return;
|
|
67
|
+
room.objects.snap("ball", { x, y, z, q: [0, 0, 0, 1], vx: 0, vy: 0, vz: 0 });
|
|
68
|
+
await room.objects.releaseConfirmed("ball");
|
|
56
69
|
}
|
|
57
70
|
}
|
|
58
71
|
|
|
@@ -61,18 +74,17 @@ function updateBall(dt: number) {
|
|
|
61
74
|
let v = room.objects.get<BallState>("ball");
|
|
62
75
|
let mine = !!v?.isMine;
|
|
63
76
|
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
// at ground level while the ball centre is at y≈RADIUS, so a 3D distance would eat into your reach.
|
|
77
|
+
// Keep contact validity alive. One rejected collision edge must not consume the attempt forever:
|
|
78
|
+
// retry after the relay hold delay while STILL touching, with at most one request in flight.
|
|
67
79
|
const dx = ballMesh.position.x - feet.x, dz = ballMesh.position.z - feet.z;
|
|
68
|
-
|
|
69
|
-
if (
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
80
|
+
touching = onFoot && Math.hypot(dx, dz) < RADIUS + REACH;
|
|
81
|
+
if (touching && !mine && !pendingClaim && performance.now() >= retryAt) {
|
|
82
|
+
pendingClaim = room.objects.claimConfirmed("ball").then((result) => {
|
|
83
|
+
if (!result.accepted && touching && (result.reason === "held" || result.reason === "rate-limited")) {
|
|
84
|
+
retryAt = performance.now() + (result.retryAfterMs ?? 50); // both carry a server retry delay — honor it
|
|
85
|
+
} else retryAt = 0;
|
|
86
|
+
}).finally(() => { pendingClaim = null; });
|
|
74
87
|
}
|
|
75
|
-
wasTouching = touching;
|
|
76
88
|
|
|
77
89
|
if (mine && !wasMine) {
|
|
78
90
|
// Gained ownership → dynamic. SEED velocity from the wire (NEVER zero, or a rolling ball
|
|
@@ -121,15 +133,16 @@ function publishBall() {
|
|
|
121
133
|
|
|
122
134
|
Rules that make it correct:
|
|
123
135
|
|
|
124
|
-
- **
|
|
125
|
-
|
|
126
|
-
|
|
136
|
+
- **Sustained-contact confirmed claim:** keep one request pending; after `held`, retry at
|
|
137
|
+
`retryAfterMs` only while contact still exists. Never spam every frame and never let one rejected
|
|
138
|
+
collision edge permanently disable the interaction.
|
|
127
139
|
- **Never zero velocity on claim** — seed `linvel` from the published `vx/vy/vz`, or a rolling ball snaps
|
|
128
140
|
to a dead stop on every handoff.
|
|
129
141
|
- **Publish velocity, not just position** — the next owner seeds from it, so the object keeps its motion
|
|
130
142
|
across the handoff.
|
|
131
143
|
- **Release-on-rest** (optional): once you own an object that's been at rest + untouched a couple of
|
|
132
|
-
seconds, `room.objects.
|
|
144
|
+
seconds, `await room.objects.releaseConfirmed("ball")` so ownership doesn't pile up on a lone
|
|
145
|
+
wanderer — its last
|
|
133
146
|
state persists on the wire for the next toucher, who re-claims.
|
|
134
147
|
- **Distributed by construction:** each client publishes only what it currently owns, so the message
|
|
135
148
|
budget spreads across players instead of funnelling every object through one host's send budget. This
|
|
@@ -168,13 +181,14 @@ room.inputs.on((fromId, payload) => {
|
|
|
168
181
|
if (p?.obj && Array.isArray(p.push)) pending.push(p as { obj: string; push: number[] });
|
|
169
182
|
});
|
|
170
183
|
|
|
171
|
-
room.onHostTick(30, (dtMs) => {
|
|
184
|
+
room.onHostTick(30, async (dtMs) => {
|
|
172
185
|
// 1) First tick after election: adopt the objects + seed the physics world from the
|
|
173
186
|
// last published truth (stateRaw) — NEVER from zero, or the world teleports.
|
|
174
187
|
for (const id of CONTESTED_IDS) {
|
|
175
188
|
const view = room.objects.get(id);
|
|
176
|
-
if (!view
|
|
177
|
-
room.objects.
|
|
189
|
+
if (!view?.isMine) {
|
|
190
|
+
const result = await room.objects.claimConfirmed(id, { authority: "host" });
|
|
191
|
+
if (!result.accepted) continue;
|
|
178
192
|
seedRapierBody(id, view?.stateRaw); // position/rotation/velocity from the wire
|
|
179
193
|
}
|
|
180
194
|
}
|
|
@@ -201,6 +215,10 @@ for (const id of CONTESTED_IDS) {
|
|
|
201
215
|
|
|
202
216
|
Rules that make it correct:
|
|
203
217
|
|
|
218
|
+
- `onHostTick` pauses during reconnect and stops on demotion/leave/terminal disconnect. Bound the input
|
|
219
|
+
queue, attribute input from the callback's authenticated `fromId` (never payload `from`), and discard
|
|
220
|
+
stale pre-failover inputs when a new host adopts raw state.
|
|
221
|
+
|
|
204
222
|
- **Sim internals that must survive migration** (velocities, cooldowns, aggro) either live in
|
|
205
223
|
the published object state or get mirrored at low rate into a dedicated object
|
|
206
224
|
(`objects.set("sim", …)`) the next host reads on adoption. Positions/rotations come free
|
|
@@ -104,9 +104,10 @@ ownership changes (no snap), and the object survives its owner leaving (reassign
|
|
|
104
104
|
// ball.ts
|
|
105
105
|
const ball = { x: 0, y: 0.5, z: 0, vx: 0, vy: 0, vz: 0 }; // the owner's local sim
|
|
106
106
|
|
|
107
|
-
//
|
|
108
|
-
function kick(dir: THREE.Vector3, power: number) {
|
|
109
|
-
room.objects.
|
|
107
|
+
// Confirm on the contact edge; retry a `held` result only while contact remains.
|
|
108
|
+
async function kick(dir: THREE.Vector3, power: number) {
|
|
109
|
+
const result = await room.objects.claimConfirmed("ball");
|
|
110
|
+
if (!result.accepted) return;
|
|
110
111
|
ball.vx += dir.x * power; ball.vz += dir.z * power;
|
|
111
112
|
}
|
|
112
113
|
|
|
@@ -129,13 +130,14 @@ function drawBall() {
|
|
|
129
130
|
|
|
130
131
|
Rules that keep it correct:
|
|
131
132
|
|
|
132
|
-
- **
|
|
133
|
+
- **Confirm ownership before an irreversible interaction.** On `held`, respect `retryAfterMs` and
|
|
134
|
+
retry only while the original contact/action is still valid.
|
|
133
135
|
- **Keep object fields flat** (`x/y/z`, a 4-number quaternion). Nested objects snap instead of glide.
|
|
134
136
|
- **Only the owner's `set` lands** — the relay drops writes from non-owners, so you never get two
|
|
135
137
|
clients fighting over the ball. You don't need to check "am I owner" before drawing, only before
|
|
136
138
|
simulating.
|
|
137
|
-
- **Transient objects** (bullets, pickups): `room.objects.
|
|
138
|
-
|
|
139
|
+
- **Transient objects** (bullets, pickups): the owner awaits `room.objects.removeConfirmed(id)`
|
|
140
|
+
when they expire, so destruction is acknowledged and idempotent across reconnects.
|
|
139
141
|
|
|
140
142
|
## Host authority (scores, rounds, world)
|
|
141
143
|
|
|
@@ -29,6 +29,9 @@ Existing files are never overwritten (use `--force` to refresh). For the
|
|
|
29
29
|
on-foot character that enters these vehicles, also run
|
|
30
30
|
`npx genex controller character` and load `$genex-threejs-character-controller`.
|
|
31
31
|
|
|
32
|
+
These files become game-owned. Do not use `--force` as an upgrade mechanism after editing them;
|
|
33
|
+
install into a scratch project and port the targeted `syncFromBody`/transition changes.
|
|
34
|
+
|
|
32
35
|
## The loop contract (get this right first)
|
|
33
36
|
|
|
34
37
|
Every controller exposes `update(dt?)` that must run once per **fixed physics
|
|
@@ -106,20 +109,32 @@ On enter/exit: the on-foot character **disappears into the vehicle on enter
|
|
|
106
109
|
behavior, not a bug. Don't leave the character mesh standing next to a car it
|
|
107
110
|
is supposedly driving; see [references/enter-exit.md](references/enter-exit.md).
|
|
108
111
|
|
|
109
|
-
## Multiplayer: vehicle occupancy is
|
|
112
|
+
## Multiplayer: vehicle occupancy is confirmed object ownership
|
|
110
113
|
|
|
111
114
|
If the game is multiplayer, **who is in what vehicle must be synced** — a
|
|
112
115
|
remote player vanishing into an apparently empty car reads as a bug. Load
|
|
113
116
|
`$genex-threejs-multiplayer` before writing any networking code, then:
|
|
114
117
|
|
|
115
|
-
-
|
|
116
|
-
|
|
118
|
+
- Install `genex controller networked-physics` and use `NetworkedVehicle`. A seat is the vehicle
|
|
119
|
+
object's confirmed owner—not a writable `shared` key. Enter only after `claimConfirmed` accepts;
|
|
120
|
+
two simultaneous entrants therefore produce one driver and one clean loser.
|
|
121
|
+
- **Occupied means owned.** Idle vehicles stay unowned, so a present owner IS the current driver —
|
|
122
|
+
`enter()` refuses an owned vehicle by default (the relay's short hold only protects the first
|
|
123
|
+
~300 ms of a drive, so without this gate any walk-up claim would hijack a moving car and dump its
|
|
124
|
+
driver). Pass `enter({ steal: true })` only when carjacking is an intended mechanic, and wire
|
|
125
|
+
`onSeatLost` so a forcibly dismounted driver returns to on-foot controls instead of a dead seat.
|
|
126
|
+
- Publish `driving: "car" | null` in the player's state for remote visuals. Use `me.snap` on entry/exit
|
|
127
|
+
mode edges, and continuous `objects.set` only from the confirmed driver.
|
|
117
128
|
- Same authority rule as the character: the **local player simulates the
|
|
118
|
-
physics of whatever they occupy**; remote vehicles are
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
129
|
+
physics of whatever they occupy**; remote vehicles are visuals only —
|
|
130
|
+
draw the SDK-smoothed `objects.get(id).state` directly on a plain mesh
|
|
131
|
+
(never re-lerp/buffer it) and never run `VehicleController`/
|
|
132
|
+
`DroneController` for a remote player.
|
|
133
|
+
- Idle vehicles remain unowned at their last raw pose. The host may explicitly claim+snap+release to
|
|
134
|
+
seed/reset, but must not continuously adopt or republish parked vehicles. Persist raw idle truth.
|
|
135
|
+
- On accepted entry, seed the body from `stateRaw`, enable/register/wake it, call `syncFromBody`, and
|
|
136
|
+
`snapBodyInterpolation` before controls/camera read it. On exit, publish final pose, player-snap the
|
|
137
|
+
character, unregister, then wait for confirmed release.
|
|
123
138
|
|
|
124
139
|
## Boundaries and troubleshooting
|
|
125
140
|
|
|
@@ -98,9 +98,9 @@ registered vehicle wins the prompt.
|
|
|
98
98
|
reappears. Raise it if the character spawns inside a wide chassis. The
|
|
99
99
|
default equals the default sensor radius so immediate re-entry stays
|
|
100
100
|
possible — intended.
|
|
101
|
-
-
|
|
102
|
-
|
|
103
|
-
|
|
101
|
+
- Flipped/nose-down vehicles are supported: the manager projects the live forward axis and falls back
|
|
102
|
+
to body-X/world-Z before normalization, so the exit basis remains finite. Still choose an
|
|
103
|
+
`exitLength` that clears the actual chassis collider.
|
|
104
104
|
|
|
105
105
|
## Seat visuals: Sitting_Enter / Driving_Loop / Sitting_Exit
|
|
106
106
|
|
|
@@ -186,15 +186,24 @@ far-from-Y gravity direction will misbehave. For rigs beyond this, see
|
|
|
186
186
|
|
|
187
187
|
## Multiplayer
|
|
188
188
|
|
|
189
|
-
Occupancy
|
|
189
|
+
Occupancy uses confirmed object ownership — remote players must see who is in what. With
|
|
190
190
|
`$genex-threejs-multiplayer` (load it before writing any networking code):
|
|
191
191
|
|
|
192
192
|
- Put `driving: "car" | "drone" | null` in each player's synced state; on
|
|
193
193
|
remote change, show/hide that player's on-foot avatar and seat their
|
|
194
194
|
visual in the vehicle (the seat-visual pattern above, minus the physics).
|
|
195
|
-
- The occupant simulates the vehicle and publishes its pose; everyone else
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
195
|
+
- The occupant simulates the vehicle and publishes its pose; everyone else draws the
|
|
196
|
+
SDK-smoothed `objects.get(vehicleId).state` directly on a plain mesh — the SDK already
|
|
197
|
+
interpolates it. Never buffer/lerp that `state` a second time, and never run the vehicle
|
|
198
|
+
controllers for a remote player's vehicle.
|
|
199
|
+
- Install `genex controller networked-physics` and use `NetworkedVehicle`: gate entry on
|
|
200
|
+
`NetworkedVehicle.enter()` / `objects.claimConfirmed(vehicleId)` **before** parking the
|
|
201
|
+
character or switching the `EnterExitManager` active unit — a rejected seat must leave the
|
|
202
|
+
on-foot prompt and controls untouched. Do NOT put the claim inside `onOccupantEnter` after the
|
|
203
|
+
park: `performInteract` has no rollback, so a rejection there strands a hidden character wired
|
|
204
|
+
to a vehicle it never won. Never use `shared.set("occupant:car", ...)` as a lock—any client can
|
|
205
|
+
overwrite it. An accepted seat seeds from raw pose, calls `syncFromBody` +
|
|
206
|
+
`snapBodyInterpolation`, and becomes the only publisher.
|
|
207
|
+
- Exit publishes final vehicle truth, uses `me.snap` for the player mode edge, unregisters the local
|
|
208
|
+
body, and waits for `releaseConfirmed`. Idle vehicles stay unowned; host seed/reset is the narrow
|
|
209
|
+
`{ authority: "host" }` claim+snap+release path.
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
# Networking these controllers
|
|
2
|
-
|
|
3
|
-
These controllers simulate **your own player only** (self-authoritative, zero input
|
|
4
|
-
latency). To show OTHER players' rigs in a multiplayer game, publish a small flat state on
|
|
5
|
-
a fixed tick and play it back on a visual-only remote rig — never instantiate a controller
|
|
6
|
-
or a Rapier body for a remote player.
|
|
7
|
-
|
|
8
|
-
The complete recipe (what to publish per controller, remote playback, contested-contact
|
|
9
|
-
physics via the host) lives in the multiplayer skill:
|
|
10
|
-
`genex-threejs-multiplayer` → `references/host-physics.md`. Load that skill before writing
|
|
11
|
-
any networking code.
|