@genex-ai/cli-demo 0.39.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.
@@ -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,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