@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.
@@ -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.8.0
37
+ npm i @genex-ai/multiplayer@^0.9.0
38
38
  ```
39
39
 
40
- > Pin `@^0.8.0` (not a bare `npm i`): `inputs`/`onHostTick`, auto-reconnect, and the `reconnecting`
41
- > events this skill relies on landed in 0.8. An older resolve would throw `room.onHostTick is not a function` at runtime.
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.8.0** (`objects`/`host` since 0.4; `matchmake()` since 0.5; presets + `score()`/`finish()` since 0.6; `createPrivate()`/`joinPrivate()` since 0.7; matchmake auto-retry + `retry()` since 0.7.1; auto-reconnect + `inputs`/`onHostTick` since 0.8; soft ownership handoff — claim-on-touch objects glide instead of teleporting — since 0.8.4).
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:** a room holds up to **64 players** (up to 48 of them guests). Above that, the
193
- relay opens a **second room for the same game** two parallel worlds, no error. If your
194
- game needs seated, one-world competition, use the matchmaking presets instead of one big
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
- for a grace window (~30 s), and on recovery **nothing changed** same session id, objects
201
- still yours, host unchanged, and in a match **a blip is not a forfeit**. Your only job is UI:
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)` — take ownership (last claim wins; call on kick/contact). Claiming is optimistic:
251
- you own it locally the instant you call it, but if another player claimed the same tick the
252
- server's last-claim-wins verdict can revoke you a `set()` you sent before losing the race is
253
- dropped. For contested objects, keep publishing while `isMine` stays true, not just once.
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)` give up ownership. `remove(id)` — destroy it (for transient bullets/pickups);
258
- **owner-only** claim it first, or let the current owner remove it (the relay rejects a
259
- non-owner's destroy).
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)` — run a fixed simulation tick only while you are the host
276
- (auto-starts/stops across host migration). Returns a disposer.
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 `me.set`, `objects.set`, `objects.claim`, `send`, and `inputs.send` costs **one relay
281
- message**, and the relay caps each connection at **~120 messages/second sustained** (drops
282
- above that you'll see a console warning "the relay dropped N of your messages"). The
283
- budget math that matters:
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 your sends-per-tick: streams × tick-rate must stay well
291
- under 120/s with headroom for claims and events.
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
- if (iKickedIt) room.objects.claim("ball"); // become the owner on contact
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
- **Only claim on interaction, not every frame.** Keep object state flat. Full code and the handoff
338
- details are in [references/realtime-patterns.md](references/realtime-patterns.md).
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.8.0` (auto-reconnect, `inputs`, `onHostTick` pin `^0.8.0`, a bare install can resolve older); config wired into the build.
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
- - **Claim the ball on contact**, not every frame: when your player's collider touches the ball,
27
- `room.objects.claim("ball")` and apply the kick impulse to your local ball sim.
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 `claim`s each enemy object and,
89
- in its tick, runs the AI and `objects.set`s each one. Non-host clients never simulate enemies —
90
- they just draw `objects.get("enemy:n").state` (smoothed) and read `stateRaw` for hit-tests.
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.remove("enemy:3")`.
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 wasTouching = false, wasMine = false, claimCd = 0;
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.claim("ball");
55
- room.objects.set("ball", { x, y, z, q: [0, 0, 0, 1], vx: 0, vy: 0, vz: 0 });
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
- // Rising-EDGE claim: sustained contact yields NO new rising edge, so ONE owner holds it (no per-frame
65
- // ownership ping-pong). A short cooldown throttles a steal-war. Use HORIZONTAL distance the feet sit
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
- const touching = onFoot && Math.hypot(dx, dz) < RADIUS + REACH;
69
- if (claimCd > 0) claimCd -= dt;
70
- if (touching && !wasTouching && !mine && claimCd <= 0) {
71
- room.objects.claim("ball"); // optimistic: isMine flips true locally THIS frame
72
- claimCd = 0.25;
73
- v = room.objects.get<BallState>("ball"); mine = !!v?.isMine;
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
- - **Rising-edge claim + a short cooldown** (~0.25s), never "am I touching": sustained contact must not
125
- re-claim every frame. Add a minimum-hold (~0.5s) before you'll `release`, so a shove finishes under
126
- your sim.
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.release("ball")` so ownership doesn't pile up on a lone wanderer — its last
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 || !view.isMine) {
177
- room.objects.claim(id);
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
- // claim on contact (you kicked it) NOT every frame
108
- function kick(dir: THREE.Vector3, power: number) {
109
- room.objects.claim("ball");
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
- - **Claim on interaction, not continuously.** Whoever last kicked owns and simulates it.
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.remove(id)` when they expire, so they
138
- don't pile up.
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 shared state
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
- - Publish occupancy with the player's state (e.g. `driving: "car" | null`)
116
- or as a shared key and the occupied vehicle's pose with it.
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 interpolated
119
- visuals only. Never run `VehicleController`/`DroneController` for a
120
- remote player drive a plain mesh from their synced pose.
121
- - An unoccupied vehicle needs one owner too: elect one client (e.g. the
122
- first in the room) to simulate it and publish its pose as shared state.
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
- - Exiting a flipped vehicle whose forward axis is parallel to up produces a
102
- degenerate exit basis (faithful upstream non-guard). If vehicles in your
103
- game can end up nose-down, right them before allowing exit.
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 is shared state — remote players must see who is in what. With
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
- interpolates a plain mesh. Never run the vehicle controllers for a remote
197
- player's vehicle.
198
- - Gate entry on a shared occupancy key so two players can't board the same
199
- seat: claim it (e.g. `room.shared.set("occupant:car", room.id)`) and treat
200
- a losing race as "prompt stays up".
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.