@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.
@@ -0,0 +1,240 @@
1
+ import * as THREE from "three";
2
+ import RAPIER from "@dimforge/rapier3d-compat";
3
+ import type { ObjectControlResult, Session } from "@genex-ai/multiplayer";
4
+ import {
5
+ followBody,
6
+ placeBody,
7
+ readNetworkPose,
8
+ roundPose,
9
+ stateFromBody,
10
+ type NetworkPoseState,
11
+ type PoseSafety,
12
+ } from "./pose.ts";
13
+
14
+ export interface NetworkedVehicleUnit {
15
+ syncFromBody(): void;
16
+ }
17
+
18
+ export interface NetworkedVehiclePhysics {
19
+ registerBody(body: RAPIER.RigidBody, object: THREE.Object3D): void;
20
+ unregisterBody(body: RAPIER.RigidBody): void;
21
+ snapBodyInterpolation(body: RAPIER.RigidBody): void;
22
+ }
23
+
24
+ export interface NetworkedVehicleOptions<TPlayer extends Record<string, unknown>> {
25
+ id: string;
26
+ room: () => Session<TPlayer> | null;
27
+ body: RAPIER.RigidBody;
28
+ object: THREE.Object3D;
29
+ unit: NetworkedVehicleUnit;
30
+ physics: NetworkedVehiclePhysics;
31
+ safety?: PoseSafety;
32
+ publishHz?: number;
33
+ /** Called after authority is confirmed, before vehicle controls take over. */
34
+ onEnter?: () => void;
35
+ /** Place/unpark the character and return the discontinuous player state to publish. */
36
+ onExit?: (vehiclePose: NetworkPoseState) => TPlayer | undefined;
37
+ /**
38
+ * Called when seat authority is lost WITHOUT a clean exit() — the relay reassigned the object
39
+ * (steal, orphan repair, host reset). The helper has already stopped driving and switched the
40
+ * body back to a follower; use this to dismount the character and restore on-foot controls.
41
+ */
42
+ onSeatLost?: () => void;
43
+ }
44
+
45
+ export interface NetworkedVehicleEnterOptions {
46
+ /**
47
+ * Claim the seat even when another player currently owns (occupies) it. Off by default:
48
+ * idle vehicles are unowned, so an existing owner IS the current driver, and the relay's
49
+ * short anti-thrash hold only protects the first 300 ms of a drive — without this gate any
50
+ * walk-up claim would hijack an occupied vehicle and force-dismount its driver.
51
+ */
52
+ steal?: boolean;
53
+ }
54
+
55
+ /** Confirmed single-seat ownership with idle-unowned persistence semantics. */
56
+ export class NetworkedVehicle<TPlayer extends Record<string, unknown>> {
57
+ readonly id: string;
58
+ driving = false;
59
+
60
+ private readonly room: () => Session<TPlayer> | null;
61
+ private readonly body: RAPIER.RigidBody;
62
+ private readonly object: THREE.Object3D;
63
+ private readonly unit: NetworkedVehicleUnit;
64
+ private readonly physics: NetworkedVehiclePhysics;
65
+ private readonly safety: PoseSafety;
66
+ private readonly publishEveryMs: number;
67
+ private readonly onEnter?: () => void;
68
+ private readonly onExit?: (vehiclePose: NetworkPoseState) => TPlayer | undefined;
69
+ private readonly onSeatLost?: () => void;
70
+ private registered = false;
71
+ private pendingSeat: Promise<ObjectControlResult> | null = null;
72
+ private lastPublishAt = 0;
73
+ private lastEpoch: number | undefined;
74
+
75
+ constructor(options: NetworkedVehicleOptions<TPlayer>) {
76
+ this.id = options.id;
77
+ this.room = options.room;
78
+ this.body = options.body;
79
+ this.object = options.object;
80
+ this.unit = options.unit;
81
+ this.physics = options.physics;
82
+ this.safety = options.safety ?? {};
83
+ this.publishEveryMs = 1000 / (options.publishHz ?? 30);
84
+ this.onEnter = options.onEnter;
85
+ this.onExit = options.onExit;
86
+ this.onSeatLost = options.onSeatLost;
87
+ this.body.setBodyType(RAPIER.RigidBodyType.KinematicPositionBased, true);
88
+ }
89
+
90
+ async enter(options: NetworkedVehicleEnterOptions = {}): Promise<ObjectControlResult> {
91
+ const room = this.room();
92
+ if (!room) return this.localFailure("disconnected");
93
+ if (this.pendingSeat) return this.pendingSeat;
94
+ const view = room.objects.get<NetworkPoseState>(this.id);
95
+ // Occupancy gate: idle vehicles stay unowned, so a present owner IS the current driver.
96
+ // The relay accepts a rival claim once its 300 ms anti-thrash hold lapses — it arbitrates
97
+ // ownership, not seats — so the seat semantics live here. Refuse without an explicit steal.
98
+ if (!options.steal && view?.owner !== undefined && !view.isMine) {
99
+ return {
100
+ requestId: "local",
101
+ operation: "claim",
102
+ id: this.id,
103
+ accepted: false,
104
+ owner: view.owner,
105
+ sequence: 0,
106
+ reason: "held",
107
+ };
108
+ }
109
+ const rawBeforeClaim = readNetworkPose(view?.stateRaw, this.safety);
110
+ const pending = room.objects.claimConfirmed(this.id);
111
+ this.pendingSeat = pending;
112
+ const result = await pending;
113
+ this.pendingSeat = null;
114
+ if (!result.accepted) return result;
115
+
116
+ const rawAfterClaim = readNetworkPose(
117
+ room.objects.get<NetworkPoseState>(this.id)?.stateRaw,
118
+ this.safety,
119
+ );
120
+ const entryPose = rawAfterClaim ?? rawBeforeClaim;
121
+ if (entryPose) placeBody(this.body, entryPose);
122
+ this.body.setBodyType(RAPIER.RigidBodyType.Dynamic, true);
123
+ this.body.wakeUp();
124
+ if (!this.registered) {
125
+ this.physics.registerBody(this.body, this.object);
126
+ this.registered = true;
127
+ }
128
+ this.unit.syncFromBody();
129
+ this.physics.snapBodyInterpolation(this.body);
130
+ this.driving = true;
131
+ this.onEnter?.();
132
+ room.objects.set(this.id, roundPose(stateFromBody(this.body, room.id)));
133
+ return result;
134
+ }
135
+
136
+ async exit(): Promise<ObjectControlResult> {
137
+ const room = this.room();
138
+ if (!room) return this.localFailure("disconnected", "release");
139
+ const pose = roundPose(stateFromBody(this.body));
140
+ room.objects.set(this.id, pose);
141
+ const result = await room.objects.releaseConfirmed(this.id);
142
+ if (!result.accepted) return result;
143
+
144
+ const playerState = this.onExit?.(pose);
145
+ if (playerState) room.me.snap(playerState);
146
+ this.driving = false;
147
+ if (this.registered) {
148
+ this.physics.unregisterBody(this.body);
149
+ this.registered = false;
150
+ }
151
+ this.body.setBodyType(RAPIER.RigidBodyType.KinematicPositionBased, true);
152
+ return result;
153
+ }
154
+
155
+ updateFollower(): void {
156
+ if (this.driving) {
157
+ const p = this.body.translation();
158
+ const q = this.body.rotation();
159
+ this.object.position.set(p.x, p.y, p.z);
160
+ this.object.quaternion.set(q.x, q.y, q.z, q.w);
161
+ return;
162
+ }
163
+ const room = this.room();
164
+ const view = room?.objects.get<NetworkPoseState>(this.id);
165
+ const pose = readNetworkPose(view?.state, this.safety);
166
+ if (!pose) return;
167
+ this.body.setBodyType(RAPIER.RigidBodyType.KinematicPositionBased, true);
168
+ const snapped = this.lastEpoch !== undefined && view !== undefined && view.epoch !== this.lastEpoch;
169
+ this.lastEpoch = view?.epoch;
170
+ if (snapped) {
171
+ placeBody(this.body, pose, false);
172
+ this.physics.snapBodyInterpolation(this.body);
173
+ } else {
174
+ followBody(this.body, pose);
175
+ }
176
+ this.object.position.set(pose.x, pose.y, pose.z);
177
+ this.object.quaternion.fromArray(pose.q);
178
+ }
179
+
180
+ publish(now = performance.now()): void {
181
+ const room = this.room();
182
+ if (!room || !this.driving || now - this.lastPublishAt < this.publishEveryMs) return;
183
+ if (!room.objects.get(this.id)?.isMine) {
184
+ // Seat authority moved without a clean exit (steal / orphan repair / host reset).
185
+ this.bailOut();
186
+ this.onSeatLost?.();
187
+ return;
188
+ }
189
+ this.lastPublishAt = now;
190
+ room.objects.set(this.id, roundPose(stateFromBody(this.body, room.id)));
191
+ }
192
+
193
+ /** Host-only initial seed/reset. Leaves the idle vehicle unowned afterwards. */
194
+ async seedIdle(state: NetworkPoseState): Promise<ObjectControlResult> {
195
+ const room = this.room();
196
+ if (!room) return this.localFailure("disconnected");
197
+ if (!room.isHost) return this.localFailure("not-host");
198
+ const result = await room.objects.claimConfirmed(this.id, { authority: "host" });
199
+ if (!result.accepted) return result;
200
+ placeBody(this.body, state, false);
201
+ this.unit.syncFromBody();
202
+ this.physics.snapBodyInterpolation(this.body);
203
+ room.objects.snap(this.id, roundPose(state));
204
+ const release = await room.objects.releaseConfirmed(this.id);
205
+ return release.accepted ? result : release;
206
+ }
207
+
208
+ /** Finalize an object the relay reassigned after its driver disconnected. */
209
+ async releaseOrphanIfHost(): Promise<void> {
210
+ const room = this.room();
211
+ const view = room?.objects.get<NetworkPoseState>(this.id);
212
+ if (!room?.isHost || this.driving || !view?.isMine) return;
213
+ const raw = readNetworkPose(view.stateRaw, this.safety);
214
+ if (raw) room.objects.snap(this.id, roundPose({ ...raw, driverId: undefined }));
215
+ await room.objects.releaseConfirmed(this.id);
216
+ }
217
+
218
+ bailOut(): void {
219
+ this.driving = false;
220
+ if (this.registered) {
221
+ this.physics.unregisterBody(this.body);
222
+ this.registered = false;
223
+ }
224
+ this.body.setBodyType(RAPIER.RigidBodyType.KinematicPositionBased, true);
225
+ }
226
+
227
+ private localFailure(
228
+ reason: "disconnected" | "not-host",
229
+ operation: "claim" | "release" | "remove" = "claim",
230
+ ): ObjectControlResult {
231
+ return {
232
+ requestId: "local",
233
+ operation,
234
+ id: this.id,
235
+ accepted: false,
236
+ sequence: 0,
237
+ reason,
238
+ };
239
+ }
240
+ }
@@ -0,0 +1,114 @@
1
+ import RAPIER from "@dimforge/rapier3d-compat";
2
+
3
+ export type NetworkQuaternion = [number, number, number, number];
4
+
5
+ export interface NetworkPoseState extends Record<string, unknown> {
6
+ x: number;
7
+ y: number;
8
+ z: number;
9
+ q: NetworkQuaternion;
10
+ vx?: number;
11
+ vy?: number;
12
+ vz?: number;
13
+ avx?: number;
14
+ avy?: number;
15
+ avz?: number;
16
+ driverId?: string;
17
+ }
18
+
19
+ export interface PoseSafety {
20
+ /** Absolute coordinate ceiling for this game. Default 10,000. */
21
+ maxPosition?: number;
22
+ /** Linear/angular component ceiling. Default 250. */
23
+ maxVelocity?: number;
24
+ /** Quaternion squared-length tolerance around 1. Default 0.1. */
25
+ quaternionTolerance?: number;
26
+ }
27
+
28
+ const finite = (value: unknown): value is number =>
29
+ typeof value === "number" && Number.isFinite(value);
30
+
31
+ export function readNetworkPose(
32
+ raw: Record<string, unknown> | undefined,
33
+ safety: PoseSafety = {},
34
+ ): NetworkPoseState | null {
35
+ if (!raw || !finite(raw.x) || !finite(raw.y) || !finite(raw.z)) return null;
36
+ const maxPosition = safety.maxPosition ?? 10_000;
37
+ if (Math.max(Math.abs(raw.x), Math.abs(raw.y), Math.abs(raw.z)) > maxPosition) return null;
38
+ if (!Array.isArray(raw.q) || raw.q.length !== 4 || !raw.q.every(finite)) return null;
39
+ const q = raw.q as NetworkQuaternion;
40
+ const qLen2 = q[0] ** 2 + q[1] ** 2 + q[2] ** 2 + q[3] ** 2;
41
+ if (!Number.isFinite(qLen2) || qLen2 < 1e-8) return null;
42
+ const tolerance = safety.quaternionTolerance ?? 0.1;
43
+ if (Math.abs(qLen2 - 1) > tolerance) return null;
44
+
45
+ const maxVelocity = safety.maxVelocity ?? 250;
46
+ for (const key of ["vx", "vy", "vz", "avx", "avy", "avz"] as const) {
47
+ const value = raw[key];
48
+ if (value !== undefined && (!finite(value) || Math.abs(value) > maxVelocity)) return null;
49
+ }
50
+ if (
51
+ raw.driverId !== undefined &&
52
+ (typeof raw.driverId !== "string" || raw.driverId.length > 128)
53
+ ) return null;
54
+
55
+ const invLength = 1 / Math.sqrt(qLen2);
56
+ return {
57
+ ...raw,
58
+ x: raw.x,
59
+ y: raw.y,
60
+ z: raw.z,
61
+ q: [q[0] * invLength, q[1] * invLength, q[2] * invLength, q[3] * invLength],
62
+ } as NetworkPoseState;
63
+ }
64
+
65
+ export function stateFromBody(body: RAPIER.RigidBody, driverId?: string): NetworkPoseState {
66
+ const p = body.translation();
67
+ const q = body.rotation();
68
+ const v = body.linvel();
69
+ const av = body.angvel();
70
+ return {
71
+ x: p.x,
72
+ y: p.y,
73
+ z: p.z,
74
+ q: [q.x, q.y, q.z, q.w],
75
+ vx: v.x,
76
+ vy: v.y,
77
+ vz: v.z,
78
+ avx: av.x,
79
+ avy: av.y,
80
+ avz: av.z,
81
+ ...(driverId ? { driverId } : {}),
82
+ };
83
+ }
84
+
85
+ export function placeBody(body: RAPIER.RigidBody, state: NetworkPoseState, wake = true): void {
86
+ body.setTranslation({ x: state.x, y: state.y, z: state.z }, wake);
87
+ body.setRotation({ x: state.q[0], y: state.q[1], z: state.q[2], w: state.q[3] }, wake);
88
+ body.setLinvel({ x: state.vx ?? 0, y: state.vy ?? 0, z: state.vz ?? 0 }, wake);
89
+ body.setAngvel({ x: state.avx ?? 0, y: state.avy ?? 0, z: state.avz ?? 0 }, wake);
90
+ }
91
+
92
+ export function followBody(body: RAPIER.RigidBody, state: NetworkPoseState): void {
93
+ body.setNextKinematicTranslation({ x: state.x, y: state.y, z: state.z });
94
+ body.setNextKinematicRotation({ x: state.q[0], y: state.q[1], z: state.q[2], w: state.q[3] });
95
+ }
96
+
97
+ export function roundPose(state: NetworkPoseState, places = 3): NetworkPoseState {
98
+ const scale = 10 ** places;
99
+ const round = (value: number | undefined): number | undefined =>
100
+ value === undefined ? undefined : Math.round(value * scale) / scale;
101
+ return {
102
+ ...state,
103
+ x: round(state.x)!,
104
+ y: round(state.y)!,
105
+ z: round(state.z)!,
106
+ q: state.q.map((value) => round(value)!) as NetworkQuaternion,
107
+ vx: round(state.vx),
108
+ vy: round(state.vy),
109
+ vz: round(state.vz),
110
+ avx: round(state.avx),
111
+ avy: round(state.avy),
112
+ avz: round(state.avz),
113
+ };
114
+ }
@@ -321,6 +321,16 @@ export class PhysicsWorld {
321
321
  this.bodyStates.delete(body.handle);
322
322
  }
323
323
 
324
+ /** Reset render interpolation history to the body's CURRENT pose. */
325
+ snapBodyInterpolation(body: RAPIER.RigidBody): void {
326
+ const t = body.translation();
327
+ const r = body.rotation();
328
+ this.previousState.set(body.handle, {
329
+ position: new THREE.Vector3(t.x, t.y, t.z),
330
+ rotation: new THREE.Quaternion(r.x, r.y, r.z, r.w),
331
+ });
332
+ }
333
+
324
334
  /**
325
335
  * Unregister the body's Object3D, drop event handlers for all of its
326
336
  * colliders, and remove the body (and its colliders) from the world.
@@ -484,6 +484,11 @@ export class VehicleController {
484
484
  }
485
485
  }
486
486
 
487
+ /** Refresh cached pose/velocity/axes immediately after an external body placement. */
488
+ syncFromBody(): void {
489
+ this.updateVehicleInfo();
490
+ }
491
+
487
492
  private updateVehicleInfo(): void {
488
493
  this.vehiclePos.copy(this.body.translation());
489
494
  this.vehicleQuat.copy(this.body.rotation());
@@ -28,6 +28,9 @@ a character controller from scratch and do not swap in a kinematic-controller
28
28
  tutorial: this one is a real dynamic body that pushes crates, rides moving
29
29
  platforms, climbs stairs and slides on too-steep slopes out of the box.
30
30
 
31
+ Because these files are game-owned, never run `genex controller character --force` over an edited
32
+ fork as a migration strategy. Install a fresh copy elsewhere and port only the named changes.
33
+
31
34
  ## What you get
32
35
 
33
36
  | Module (under `src/controllers/`) | Exports you use | Job |
@@ -131,6 +134,11 @@ preset table with provenance, the density/spring scaling rule, and the
131
134
  out of the box**. Cap it (e.g. `Math.PI / 4`) if steep slopes should slide.
132
135
  - The capsule ships with friction `-0.5` **on purpose** (grip is synthesized by
133
136
  the controller). Do not "fix" it to a positive value.
137
+ - CCD is enabled by default. For high-energy multiplayer arenas, set
138
+ `maxExternalLinearSpeed` above every intended run/jump/fall speed and provide `isPoseAllowed`.
139
+ `onRecovery` fires after body, root, velocity, and controller caches are restored in the same
140
+ frame; use it to call `physics.snapBodyInterpolation(character.body)`, reset the camera target,
141
+ and publish `room.me.snap(...)`. Bounds and spawn coordinates stay in game code.
134
142
 
135
143
  ## Animations + animation packs
136
144
 
@@ -209,7 +217,8 @@ body, a `CharacterController`, or any physics for it. Simulating remote
209
217
  players' physics locally guarantees divergence — every client would compute a
210
218
  different world.
211
219
 
212
- - Publish your own `currPos` + yaw on the fixed 10–20 Hz tick, not per frame.
220
+ - Publish your own `currPos` + `currQuat` as a four-number quaternion on the fixed 10–20 Hz tick,
221
+ not per frame. Never reduce multiplayer rotation to scalar yaw.
213
222
  - To animate remotes, sync the six animation booleans (`isOnGround`,
214
223
  `isFalling`, `isMoving`, `runActive`, `jumpActive`, `crouchActive`) and feed
215
224
  them to a per-remote `CharacterAnimations` — see the animations reference.
@@ -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,30 @@ 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
+ - **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.
311
+ - If you see the drop warning, count each lane separately and also leave headroom under the global
312
+ ceiling. Reserved capacity protects presence and control, but it is not permission to spam bulk.
295
313
 
296
314
  ## The loop you must build (input → local → tick → render)
297
315
 
@@ -325,7 +343,9 @@ relay enforce it), the owner simulates it, and everyone else reads it auto-smoot
325
343
  interpolation as a player. Ownership survives the owner leaving (reassigned to the host).
326
344
 
327
345
  ```ts
328
- if (iKickedIt) room.objects.claim("ball"); // become the owner on contact
346
+ const result = await room.objects.claimConfirmed("ball");
347
+ if (result.accepted) applyKickAndFeedback();
348
+ else if (result.reason === "held" && stillTouching) retryAfter(result.retryAfterMs);
329
349
 
330
350
  if (room.objects.get("ball")?.isMine) {
331
351
  room.objects.set("ball", stepBallPhysics()); // only the owner's writes land
@@ -334,8 +354,11 @@ const ball = room.objects.get("ball");
334
354
  if (ball) drawBall(ball.state); // smoothed for everyone, live for the owner
335
355
  ```
336
356
 
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).
357
+ **Keep contact validity alive until acceptance; do not consume one rejected rising edge forever.**
358
+ Never claim every frame: maintain one pending request, then retry after the relay delay only while the
359
+ contact remains valid. Keep object state flat. For Rapier pushables, install the shipped state machine
360
+ with `genex controller networked-physics`; see
361
+ [references/host-physics.md](references/host-physics.md).
339
362
 
340
363
  ## Host authority (scores, rounds, enemies)
341
364
 
@@ -350,9 +373,37 @@ if (room.isHost) room.shared.set("round", nextRound); // only the host advance
350
373
  room.on("host", (id) => {}); // host migrated (someone left)
351
374
  ```
352
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
+
353
402
  For host-simulated NPCs, the host claims and drives each enemy as an `object`; when the host
354
403
  leaves, its enemies are reassigned to the new host, which reads their `stateRaw` and keeps
355
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.
356
407
 
357
408
  ### Pushable / contested physics — pick the tier
358
409
 
@@ -454,11 +505,18 @@ host-driven saving works as long as ANY account is in the room.
454
505
 
455
506
  ## Checklist
456
507
 
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.
508
+ - [ ] `npm i @genex-ai/multiplayer@^0.9.0` (confirmed controls, snap epochs, reconnect-safe host ticks); config wired into the build.
458
509
  - [ ] `reconnecting`/`reconnected`/`disconnect` render an overlay (don't tear the scene down).
459
510
  - [ ] Numbers rounded (~2 decimals) before `me.set`/`objects.set`.
460
511
  - [ ] 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.
512
+ - [ ] Irreversible actions wait for `claimConfirmed`; held contact retries after `retryAfterMs` while still valid.
513
+ - [ ] Respawn/reset/vehicle-mode discontinuities use `me.snap`/`objects.snap`; ordinary motion uses `set`.
461
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.
462
520
  - [ ] Host renders objects OWNED BY OTHERS from the stream (authority follows ownership).
463
521
  - [ ] `connect()` runs AFTER `await waitForPlayer()` (never `waitForAuth()` — guests would
464
522
  hang) and passes `auth: getColyseusAuth()!` (the relay rejects tokenless joins —