@genex-ai/cli-demo 0.39.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.
@@ -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.
@@ -39,36 +40,56 @@ a contested kick settles on one owner within a snapshot; the owner leaving doesn
39
40
 
40
41
  ---
41
42
 
42
- ## Recipe 2 — Shooter / arena (top-down or FPS .io)
43
+ ## Recipe 2 — Shooter / arena PvP (hitscan, melee, projectiles)
43
44
 
44
- 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.)
45
48
 
46
49
  | Thing | Channel | Authority |
47
50
  | --- | --- | --- |
48
- | Player pos + rotation + hp | `me.set` / `players` (hp read via `stateRaw`) | each player |
49
- | A shot being fired | `send("shot", …)` | the shooter |
51
+ | Player pos + rotation + hp + `life` | `me.set` / `players` (hp/life read via `stateRaw`) | each player |
52
+ | A shot / swing being fired | `send("combat:attack", { from, seq, slot, targets, })` | the attacker |
50
53
  | Damage applied | victim's own `me.set` (hp) | the victim |
51
- | Score / round | `shared` | `room.isHost` |
52
- | Slow physical projectiles (grenades, rockets) | `objects` (one per projectile, `remove` on expiry) | the thrower |
54
+ | A defeat (kill credit) | `send("combat:defeat", { victim, attacker, life })` | the victim |
55
+ | Score / round | `shared` (exactly-once marker see SKILL.md host section) | `room.isHost` |
56
+ | Slow physical projectiles (grenades, rockets) | `objects` — one per projectile, **hard cap + `removeConfirmed`** | the thrower |
53
57
 
54
58
  **Decisions:**
55
- - **The shooter judges the hit locally** ("favor the shooter"): raycast against what *you* see,
56
- then `send("shot", { hit: targetId })` — and draw your own muzzle flash / tracer **right there**,
57
- because `send` never echoes back to you. This is how casual browser shooters feel responsive; a
58
- high-ping victim occasionally "dies behind a wall", which is inherent to server-less shooters —
59
- set that expectation, don't try to fix it with more smoothing.
60
- - **The victim applies its own damage** on receiving the shot (`if (m.hit === room.id) me.hp -= …`)
61
- each player owns their own hp, so there's no write conflict.
62
- - **Hit-tests read `stateRaw`, not `state`** — you want the raw latest position, not the
63
- render-delayed smoothed one. Same for reading remote hp (a discrete value; `state` would lerp it
64
- to fractions).
65
- - **Hitscan uses `send`, not objects** a shot is an event, not continuous state. Reserve
66
- `objects` for a *slow, visible* projectile the players can dodge (a rocket): claim on spawn,
67
- `objects.set` its arc, `objects.remove` on impact.
68
- - **Scoreboard is host-only:** the host tallies kills into `shared`; everyone renders it.
69
-
70
- **Acceptance feel:** movement is smooth for 4–8 players; hits register on what you aimed at; the
71
- scoreboard updates for everyone; one player on a throttled/backgrounded tab doesn't drag others.
59
+ - **The attacker judges the hit locally** ("favor the shooter"): raycast/cone-check against what
60
+ *you* see, then broadcast ONE attack event naming the `targets` — and draw your own muzzle
61
+ flash / tracer / swing arc **right there**, because `send` never echoes back to you. A high-ping
62
+ victim occasionally "dies behind a wall"; that's inherent to server-less shooters — set the
63
+ expectation, don't chase it with more smoothing.
64
+ - **Melee is the same event, different hit-test:** a range + cone check over `players`'
65
+ `stateRaw` positions (plus a line-of-sight ray), targets sorted by distance — single-target for
66
+ a punch, every candidate in the arc for a sword sweep.
67
+ - **Skip dead targets up front:** ignore players whose `stateRaw.hp <= 0` in every hit-test, so a
68
+ corpse can't be re-hit during its respawn window.
69
+ - **The victim applies its own damage** on receiving an attack event that names it — each player
70
+ owns their own hp, so there's no write conflict. **Dedupe per attack:** the attacker stamps every
71
+ event with a `seq`; the victim keeps a small bounded set of handled `${from}:${seq}` keys and
72
+ ignores repeats (events can arrive more than once around blips).
73
+ - **Defeat is exactly-once:** at hp 0 the VICTIM broadcasts `combat:defeat { victim, attacker,
74
+ life }` `life` is a counter in its player state that increments on every respawn, so everyone
75
+ (including the scoring host) dedupes defeats on the `${victim}:${life}` key. The HOST turns that
76
+ event into a point with the exactly-once marker write from the multiplayer skill's host section.
77
+ - **Respawn is a discontinuity:** reset hp, bump `life`, place the body at the spawn point, then
78
+ publish with `room.me.snap(state)` — never `set`, or remotes glide the corpse across the map.
79
+ - **Hit-tests read `stateRaw`, not `state`** — the raw latest position, not the render-delayed
80
+ smoothed one. Same for reading remote hp/life (discrete values; `state` would lerp them).
81
+ - **Hitscan/melee use `send`, never objects** — a shot is an event, not continuous state. Reserve
82
+ `objects` for a *slow, visible* projectile players can dodge (a rocket): `claim` on spawn,
83
+ `objects.set` its arc at ~30 Hz, `removeConfirmed` on impact/expiry, and a **hard per-player
84
+ cap** that removes the oldest live projectile before spawning a new one (netcode-park ships
85
+ cap = 2). Uncapped per-projectile spawns walk into the room's 128-object cap AND the message
86
+ budget; the cap is what kept projectiles smooth for everyone in live testing.
87
+ - **Scoreboard is host-only and keyed by the stable `uid`**, never the session id (see the
88
+ multiplayer skill's host section) — a reload must keep the player's points and color.
89
+
90
+ **Acceptance feel:** movement is smooth for 4–8 players; hits register on what you aimed at; a kill
91
+ scores exactly one point even if the host changes mid-fight; the scoreboard survives a reload; one
92
+ player on a throttled/backgrounded tab doesn't drag others.
72
93
 
73
94
  ---
74
95
 
@@ -85,9 +106,10 @@ and it's the host, so it survives players joining and leaving.
85
106
  | Spawn flashes, hit sparks | `send` | the host / whoever hit |
86
107
 
87
108
  **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.
109
+ - **The host owns and simulates the enemies.** On spawn, the host uses
110
+ `claimConfirmed(id, { authority: "host" })`; after acceptance its tick runs the AI and
111
+ `objects.set`s each enemy. Non-host clients never simulate enemies — they just draw
112
+ `objects.get("enemy:n").state` (smoothed) and read `stateRaw` for hit-tests.
91
113
  - **One object per enemy** (flat `{x,y,z,hp}`) so each smooths independently. For a big horde keep
92
114
  the count modest (≈8–16 active); it's casual, not a bullet-hell server.
93
115
  - **Host migration keeps the game alive:** if the host leaves, its enemies are reassigned to the
@@ -98,7 +120,7 @@ and it's the host, so it survives players joining and leaving.
98
120
  `send` never echoes to the sender — so when the **host itself** shoots an enemy it owns, it must
99
121
  apply that damage to its local enemy sim **directly**, not via `send` (which wouldn't come back).
100
122
  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")`.
123
+ Enemy death: the host awaits `objects.removeConfirmed("enemy:3")` before finalizing rewards.
102
124
  - **Waves/score are host-only** in `shared`; late joiners read the current wave on connect.
103
125
 
104
126
  **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,31 @@ 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. **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
+ ```
139
158
 
140
159
  ## Host authority (scores, rounds, world)
141
160
 
@@ -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
@@ -72,7 +75,11 @@ games with no on-foot character.
72
75
  VELOCITY vs POSITION control modes, the PD gain mass-scaling rule, presets.
73
76
  - [references/enter-exit.md](references/enter-exit.md) — `EnterExitManager`,
74
77
  park/unpark, sensor tuning, seat animations (`Sitting_Enter` /
75
- `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`).
76
83
 
77
84
  ## Presets at a glance
78
85
 
@@ -106,20 +113,32 @@ On enter/exit: the on-foot character **disappears into the vehicle on enter
106
113
  behavior, not a bug. Don't leave the character mesh standing next to a car it
107
114
  is supposedly driving; see [references/enter-exit.md](references/enter-exit.md).
108
115
 
109
- ## Multiplayer: vehicle occupancy is shared state
116
+ ## Multiplayer: vehicle occupancy is confirmed object ownership
110
117
 
111
118
  If the game is multiplayer, **who is in what vehicle must be synced** — a
112
119
  remote player vanishing into an apparently empty car reads as a bug. Load
113
120
  `$genex-threejs-multiplayer` before writing any networking code, then:
114
121
 
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.
122
+ - Install `genex controller networked-physics` and use `NetworkedVehicle`. A seat is the vehicle
123
+ object's confirmed owner—not a writable `shared` key. Enter only after `claimConfirmed` accepts;
124
+ two simultaneous entrants therefore produce one driver and one clean loser.
125
+ - **Occupied means owned.** Idle vehicles stay unowned, so a present owner IS the current driver —
126
+ `enter()` refuses an owned vehicle by default (the relay's short hold only protects the first
127
+ ~300 ms of a drive, so without this gate any walk-up claim would hijack a moving car and dump its
128
+ driver). Pass `enter({ steal: true })` only when carjacking is an intended mechanic, and wire
129
+ `onSeatLost` so a forcibly dismounted driver returns to on-foot controls instead of a dead seat.
130
+ - Publish `driving: "car" | null` in the player's state for remote visuals. Use `me.snap` on entry/exit
131
+ mode edges, and continuous `objects.set` only from the confirmed driver.
117
132
  - 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.
133
+ physics of whatever they occupy**; remote vehicles are visuals only —
134
+ draw the SDK-smoothed `objects.get(id).state` directly on a plain mesh
135
+ (never re-lerp/buffer it) and never run `VehicleController`/
136
+ `DroneController` for a remote player.
137
+ - Idle vehicles remain unowned at their last raw pose. The host may explicitly claim+snap+release to
138
+ seed/reset, but must not continuously adopt or republish parked vehicles. Persist raw idle truth.
139
+ - On accepted entry, seed the body from `stateRaw`, enable/register/wake it, call `syncFromBody`, and
140
+ `snapBodyInterpolation` before controls/camera read it. On exit, publish final pose, player-snap the
141
+ character, unregister, then wait for confirmed release.
123
142
 
124
143
  ## Boundaries and troubleshooting
125
144
 
@@ -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)`
@@ -98,9 +112,9 @@ registered vehicle wins the prompt.
98
112
  reappears. Raise it if the character spawns inside a wide chassis. The
99
113
  default equals the default sensor radius so immediate re-entry stays
100
114
  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.
115
+ - Flipped/nose-down vehicles are supported: the manager projects the live forward axis and falls back
116
+ to body-X/world-Z before normalization, so the exit basis remains finite. Still choose an
117
+ `exitLength` that clears the actual chassis collider.
104
118
 
105
119
  ## Seat visuals: Sitting_Enter / Driving_Loop / Sitting_Exit
106
120
 
@@ -186,15 +200,24 @@ far-from-Y gravity direction will misbehave. For rigs beyond this, see
186
200
 
187
201
  ## Multiplayer
188
202
 
189
- Occupancy is shared state — remote players must see who is in what. With
203
+ Occupancy uses confirmed object ownership — remote players must see who is in what. With
190
204
  `$genex-threejs-multiplayer` (load it before writing any networking code):
191
205
 
192
206
  - Put `driving: "car" | "drone" | null` in each player's synced state; on
193
207
  remote change, show/hide that player's on-foot avatar and seat their
194
208
  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".
209
+ - The occupant simulates the vehicle and publishes its pose; everyone else draws the
210
+ SDK-smoothed `objects.get(vehicleId).state` directly on a plain mesh the SDK already
211
+ interpolates it. Never buffer/lerp that `state` a second time, and never run the vehicle
212
+ controllers for a remote player's vehicle.
213
+ - Install `genex controller networked-physics` and use `NetworkedVehicle`: gate entry on
214
+ `NetworkedVehicle.enter()` / `objects.claimConfirmed(vehicleId)` **before** parking the
215
+ character or switching the `EnterExitManager` active unit — a rejected seat must leave the
216
+ on-foot prompt and controls untouched. Do NOT put the claim inside `onOccupantEnter` after the
217
+ park: `performInteract` has no rollback, so a rejection there strands a hidden character wired
218
+ to a vehicle it never won. Never use `shared.set("occupant:car", ...)` as a lock—any client can
219
+ overwrite it. An accepted seat seeds from raw pose, calls `syncFromBody` +
220
+ `snapBodyInterpolation`, and becomes the only publisher.
221
+ - Exit publishes final vehicle truth, uses `me.snap` for the player mode edge, unregisters the local
222
+ body, and waits for `releaseConfirmed`. Idle vehicles stay unowned; host seed/reset is the narrow
223
+ `{ 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.