@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.
package/README.md CHANGED
@@ -15,7 +15,7 @@ genex sfx "<prompt>" # generate a sound fx → prints an asset URL
15
15
  genex texture "<prompt>" # generate a texture → prints an asset URL
16
16
  genex image "<prompt>" # generate an image → prints an asset URL
17
17
  genex video "<prompt>" # generate a video → prints an asset URL
18
- genex controller <type> # install a tuned character|car|drone controller → src/controllers/
18
+ genex controller <type> # character|car|drone|networked-physics → src/controllers/
19
19
  genex controller anims <sel…> # download extra character animation clips (by tag or name) → public/assets/anims/
20
20
  ```
21
21
 
package/dist/index.js CHANGED
@@ -2365,7 +2365,12 @@ function formatMb(bytes) {
2365
2365
  }
2366
2366
 
2367
2367
  // src/commands/controller.ts
2368
- var CONTROLLER_KINDS = ["character", "car", "drone"];
2368
+ var CONTROLLER_KINDS = [
2369
+ "character",
2370
+ "car",
2371
+ "drone",
2372
+ "networked-physics"
2373
+ ];
2369
2374
  var SHARED = [
2370
2375
  "shared/math.ts",
2371
2376
  "shared/physics-world.ts",
@@ -2443,6 +2448,23 @@ var CONTROLLER_FILE_SETS = {
2443
2448
  `const drone = new DroneController({ world: physics.world, body, chassis, propellers, config: dronePresets["camera-drone"].config });`,
2444
2449
  `physics.onBeforeStep(() => { drone.setMovement(keyboard.getDroneMovement()); drone.update(); });`
2445
2450
  ]
2451
+ },
2452
+ "networked-physics": {
2453
+ code: [
2454
+ ...SHARED,
2455
+ "network/pose.ts",
2456
+ "network/networked-pushable.ts",
2457
+ "network/networked-vehicle.ts",
2458
+ "NETWORKING.md",
2459
+ NOTICE
2460
+ ],
2461
+ assets: [],
2462
+ skill: "genex-threejs-multiplayer",
2463
+ sketch: [
2464
+ `const box = new NetworkedPushable({ id: "box:1", room: () => room, body, object: mesh });`,
2465
+ `physics.onBeforeStep(() => box.update()); physics.onAfterStep(() => box.publish());`,
2466
+ `contacts.onChange((active) => box.setContact(active)); // retries held claims while contact persists`
2467
+ ]
2446
2468
  }
2447
2469
  };
2448
2470
  var CODE_DEST = path13.join("src", "controllers");
@@ -2457,7 +2479,7 @@ async function runController(opts) {
2457
2479
  if (!kind || !CONTROLLER_KINDS.includes(kind)) {
2458
2480
  log.error(
2459
2481
  `Missing or unknown controller type${kind ? ` "${kind}"` : ""}. Usage: ${c.cyan(
2460
- "genex controller <character|car|drone> [--force]"
2482
+ "genex controller <character|car|drone|networked-physics> [--force]"
2461
2483
  )} or ${c.cyan("genex controller anims <tag|clip \u2026>")}`
2462
2484
  );
2463
2485
  process.exitCode = 1;
@@ -2508,7 +2530,7 @@ async function runController(opts) {
2508
2530
  log.plain(c.bold("Next steps"));
2509
2531
  log.plain(
2510
2532
  ` 1. ${c.cyan(
2511
- kind === "character" ? "npm i @dimforge/rapier3d-compat @pixiv/three-vrm" : "npm i @dimforge/rapier3d-compat"
2533
+ kind === "character" ? "npm i @dimforge/rapier3d-compat @pixiv/three-vrm" : kind === "networked-physics" ? "npm i @dimforge/rapier3d-compat @genex-ai/multiplayer" : "npm i @dimforge/rapier3d-compat"
2512
2534
  )} (three is already in the scaffold).`
2513
2535
  );
2514
2536
  log.plain(` 2. Load the ${c.cyan(set.skill)} skill for wiring, presets, and tuning.`);
@@ -2641,7 +2663,8 @@ ${c.bold("Usage")}
2641
2663
  genex texture "<prompt>" [options] Generate a PBR texture into public/assets/textures.
2642
2664
  genex image "<prompt>" [options] Generate an image (PNG); prints a public asset URL.
2643
2665
  genex video "<prompt>" [options] Generate a video (mp4); prints a public asset URL.
2644
- genex controller <type> [--force] Install a physics controller (character|car|drone)
2666
+ genex controller <type> [--force] Install a physics controller
2667
+ (character|car|drone|networked-physics)
2645
2668
  into src/controllers (+ assets into public/assets).
2646
2669
  genex controller anims <sel \u2026> Download extra character animation clips by tag or
2647
2670
  exact name (sword, stealth, Celebration, \u2026) into
@@ -2740,6 +2763,7 @@ ${c.bold("Examples")}
2740
2763
  genex image "neon graffiti tag, spray-paint style" --transparent
2741
2764
  genex video "swirling neon plasma, seamless loop" --loop
2742
2765
  genex controller character
2766
+ genex controller networked-physics
2743
2767
  genex explore "grass"
2744
2768
  genex explore
2745
2769
  `;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@genex-ai/cli-demo",
3
- "version": "0.39.0",
3
+ "version": "0.40.0",
4
4
  "description": "Set up your ~/.claude workspace, authorize, create a game project, generate AI assets, and publish (genex CLI).",
5
5
  "type": "module",
6
6
  "bin": {
@@ -40,6 +40,7 @@
40
40
  },
41
41
  "devDependencies": {
42
42
  "@dimforge/rapier3d-compat": "^0.19.3",
43
+ "@genex-ai/multiplayer": "workspace:*",
43
44
  "@pixiv/three-vrm": "^3.5.4",
44
45
  "@types/three": "^0.185.0",
45
46
  "three": "^0.185.1",
@@ -0,0 +1,29 @@
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
+ Install `genex controller networked-physics` for confirmed-authority Rapier pushable and vehicle
9
+ state machines. They use one local fixed-step body: dynamic only for the confirmed owner and a
10
+ kinematic proxy driven directly from the SDK-smoothed remote state otherwise. Do not add a second
11
+ remote lerp or shadow physics simulation.
12
+
13
+ Use ordinary `set` for continuous movement. Use `me.snap` / `objects.snap` only for a deliberate
14
+ respawn, teleport, goal reset, or vehicle mode edge. Irreversible impulses, seats, reset feedback,
15
+ release, and removal must wait for the confirmed object-control result. The networked helpers watch
16
+ the relay-owned object `epoch` and direct-place Rapier followers on snaps, so a reset cannot create a
17
+ synthetic kinematic sweep through bystanders.
18
+
19
+ Vehicle seat rules the helper enforces: idle vehicles stay unowned, so a present owner IS the current
20
+ driver — `NetworkedVehicle.enter()` refuses an owned vehicle unless the game passes
21
+ `{ steal: true }`, and `onSeatLost` fires if the relay moves the seat without a clean `exit()`
22
+ (steal, orphan repair, host reset). When a driver disconnects, the relay parks the orphaned vehicle
23
+ on the host — the host should call `releaseOrphanIfHost()` (each vehicle, when it observes itself as
24
+ a non-driving owner) to snap the pose clean and return the vehicle to the idle-unowned state.
25
+
26
+ The complete recipe (what to publish per controller, remote playback, and genuine simultaneous
27
+ contested physics via the host) lives in the multiplayer skill:
28
+ `genex-threejs-multiplayer` → `references/host-physics.md`. Load that skill before writing
29
+ any networking code.
@@ -19,6 +19,14 @@ export type { ControllerUserData };
19
19
 
20
20
  const clamp = THREE.MathUtils.clamp;
21
21
 
22
+ function finiteVec(v: { x: number; y: number; z: number }): boolean {
23
+ return Number.isFinite(v.x) && Number.isFinite(v.y) && Number.isFinite(v.z);
24
+ }
25
+
26
+ function finiteQuat(q: { x: number; y: number; z: number; w: number }): boolean {
27
+ return finiteVec(q) && Number.isFinite(q.w) && Math.hypot(q.x, q.y, q.z, q.w) > 1e-6;
28
+ }
29
+
22
30
  /** Default platform mass-ratio falloff curve: flat 0 until half the character's
23
31
  * mass, then rising to full inheritance at equal-or-heavier platforms. */
24
32
  const DEFAULT_CURVE_DATA: CurveData = {
@@ -72,6 +80,12 @@ type ResolvedMovementInput = {
72
80
  /** Crouch input interpretation — see {@link CharacterControllerOptions.crouchMode}. */
73
81
  export type CrouchMode = "toggle" | "hold";
74
82
 
83
+ export interface CharacterRecoveryEvent {
84
+ reason: "non-finite" | "out-of-bounds";
85
+ position: { x: number; y: number; z: number };
86
+ linearVelocity: { x: number; y: number; z: number };
87
+ }
88
+
75
89
  /**
76
90
  * Options for {@link CharacterController}. Every value has a tuned default —
77
91
  * start from a preset in `./presets.ts` and only override what feels wrong.
@@ -97,6 +111,20 @@ export interface CharacterControllerOptions {
97
111
  density?: number;
98
112
  /** Allow the body to sleep when at rest. Default `true`. */
99
113
  canSleep?: boolean;
114
+ /** Continuous collision detection for high-speed prop/vehicle impacts. Default `true`. */
115
+ ccd?: boolean;
116
+ /**
117
+ * Optional absolute body-speed ceiling in m/s. Set it comfortably above intended run/jump/fall
118
+ * speeds; it is a last-resort external-impulse guard, not locomotion tuning.
119
+ */
120
+ maxExternalLinearSpeed?: number;
121
+ /** Optional game-owned arena/bounds predicate. Non-finite poses are rejected regardless. */
122
+ isPoseAllowed?: (position: Readonly<{ x: number; y: number; z: number }>) => boolean;
123
+ /**
124
+ * Called after an unsafe pose was atomically restored to the constructor spawn. Pair this with
125
+ * `physics.snapBodyInterpolation(body)`, camera reset, and `room.me.snap(...)` when networked.
126
+ */
127
+ onRecovery?: (event: CharacterRecoveryEvent) => void;
100
128
  /** Initial gravity scale while airborne and not falling. Default `1`. */
101
129
  gravityScale?: number;
102
130
  /**
@@ -314,6 +342,11 @@ export class CharacterController {
314
342
  private readonly counterMoveImpFactor: number;
315
343
  private readonly initialGravityScale: number;
316
344
  private readonly massRatioFallOffCurve: CurveLUT;
345
+ private readonly maxExternalLinearSpeed: number | undefined;
346
+ private readonly isPoseAllowed: CharacterControllerOptions["isPoseAllowed"];
347
+ private readonly onRecovery: CharacterControllerOptions["onRecovery"];
348
+ private readonly recoveryPosition = new THREE.Vector3();
349
+ private readonly recoveryRotation = new THREE.Quaternion();
317
350
 
318
351
  // ── input state ──
319
352
  private readonly movementState: ResolvedMovementInput = {
@@ -531,6 +564,9 @@ export class CharacterController {
531
564
  this.applyCounterMoveImp = options.applyCounterMoveImp ?? true;
532
565
  this.counterMoveImpFactor = options.counterMoveImpFactor ?? 1;
533
566
  this.initialGravityScale = options.gravityScale ?? 1;
567
+ this.maxExternalLinearSpeed = options.maxExternalLinearSpeed;
568
+ this.isPoseAllowed = options.isPoseAllowed;
569
+ this.onRecovery = options.onRecovery;
534
570
 
535
571
  const curveData = options.massRatioFallOffCurveData ?? DEFAULT_CURVE_DATA;
536
572
  this.massRatioFallOffCurve = bakeCurveLUT(curveData.points, curveData.samples ?? 50);
@@ -547,7 +583,10 @@ export class CharacterController {
547
583
  .setCanSleep(options.canSleep ?? true)
548
584
  .setGravityScale(this.initialGravityScale);
549
585
  this._body = world.createRigidBody(bodyDesc);
586
+ this._body.enableCcd(options.ccd ?? true);
550
587
  this._body.userData = options.userData ?? {};
588
+ this.recoveryPosition.set(position.x, position.y, position.z);
589
+ this.recoveryRotation.copy(rotation);
551
590
 
552
591
  // Capsule args order matches the JSX args: (halfHeight, radius).
553
592
  const colliderDesc = RAPIER.ColliderDesc.capsule(capsuleHalfHeight, this.capsuleRadius)
@@ -838,6 +877,27 @@ export class CharacterController {
838
877
  this.lastInputDir.set(0, 0, 1).applyQuaternion(this.unparkQuat);
839
878
  this.parked = false;
840
879
  this._body.wakeUp();
880
+ // Camera/gameplay getters read these caches on the same exit frame, before
881
+ // the next controller update. Keep body, root, and cached truth aligned.
882
+ this.updateCharacterInfo();
883
+ }
884
+
885
+ /**
886
+ * Atomically restore the body, visual root, velocities, and controller pose cache. The caller owns
887
+ * camera/interpolation/network discontinuity state; use `onRecovery` to update those in this frame.
888
+ */
889
+ recover(position = this.recoveryPosition, rotation = this.recoveryRotation): void {
890
+ const p = finiteVec(position) ? position : this.recoveryPosition;
891
+ const q = finiteQuat(rotation) ? rotation : this.recoveryRotation;
892
+ this._body.setTranslation(p, false);
893
+ this._body.setRotation(q, false);
894
+ this._body.setLinvel(this.fixedZero, false);
895
+ this._body.setAngvel(this.fixedZero, false);
896
+ this.root.position.copy(p);
897
+ this.root.quaternion.copy(q);
898
+ this.lastInputDir.set(0, 0, 1).applyQuaternion(q);
899
+ this.updateCharacterInfo();
900
+ this._body.wakeUp();
841
901
  }
842
902
 
843
903
  /**
@@ -851,6 +911,7 @@ export class CharacterController {
851
911
  // Skip the whole controller loop when disabled or parked
852
912
  if (!this.enabled || this.parked) return;
853
913
  const characterBody = this._body;
914
+ if (this.recoverUnsafePose()) return;
854
915
  let isSleeping = characterBody.isSleeping();
855
916
 
856
917
  // Correct frame rate difference
@@ -1028,6 +1089,35 @@ export class CharacterController {
1028
1089
  this.currentAngVelOnUp.copy(this.currentAngVel).projectOnVector(this.characterYAxis);
1029
1090
  }
1030
1091
 
1092
+ /** Recover before any impulses or publication can observe an unsafe pose. */
1093
+ private recoverUnsafePose(): boolean {
1094
+ const p = this._body.translation();
1095
+ const q = this._body.rotation();
1096
+ const v = this._body.linvel();
1097
+ const av = this._body.angvel();
1098
+ const finite = finiteVec(p) && finiteQuat(q) && finiteVec(v) && finiteVec(av);
1099
+ const allowed = finite && (this.isPoseAllowed?.(p) ?? true);
1100
+ if (!finite || !allowed) {
1101
+ const event: CharacterRecoveryEvent = {
1102
+ reason: finite ? "out-of-bounds" : "non-finite",
1103
+ position: { x: p.x, y: p.y, z: p.z },
1104
+ linearVelocity: { x: v.x, y: v.y, z: v.z },
1105
+ };
1106
+ this.recover();
1107
+ this.onRecovery?.(event);
1108
+ return true;
1109
+ }
1110
+ const max = this.maxExternalLinearSpeed;
1111
+ if (max !== undefined && Number.isFinite(max) && max > 0) {
1112
+ const speed = Math.hypot(v.x, v.y, v.z);
1113
+ if (speed > max) {
1114
+ const scale = max / speed;
1115
+ this._body.setLinvel({ x: v.x * scale, y: v.y * scale, z: v.z * scale }, true);
1116
+ }
1117
+ }
1118
+ return false;
1119
+ }
1120
+
1031
1121
  /**
1032
1122
  * Update gravity/upAxis direction and value (upstream l.499-513; the custom
1033
1123
  * gravity-field branch is dropped per the v1 port scope — world gravity may
@@ -356,6 +356,9 @@ export class DroneController {
356
356
  this.addPropeller(propellerOptions);
357
357
  }
358
358
  }
359
+ // Cache the supplied body's real pose immediately. Without this, getters
360
+ // and the chassis can remain at zero until the first awake physics step.
361
+ this.updateVehicleInfo();
359
362
  }
360
363
 
361
364
  // ---- per-frame ----
@@ -595,6 +598,11 @@ export class DroneController {
595
598
 
596
599
  // ---- internals ----
597
600
 
601
+ /** Refresh cached pose/velocity/axes immediately after an external body placement. */
602
+ syncFromBody(): void {
603
+ this.updateVehicleInfo();
604
+ }
605
+
598
606
  /** Update vehicle collider pos/vel/quat/axis from the rigid body. */
599
607
  private updateVehicleInfo(): void {
600
608
  const translation = this.bodyRef.translation();
@@ -68,6 +68,8 @@ export interface VehicleUnitLike extends FollowTargetLike {
68
68
  readonly bodyXAxis: THREE.Vector3;
69
69
  /** World-space body Z axis (live vector). */
70
70
  readonly bodyZAxis: THREE.Vector3;
71
+ /** Refresh cached pose/axes after an external body placement. */
72
+ syncFromBody(): void;
71
73
  }
72
74
 
73
75
  /**
@@ -479,10 +481,8 @@ export class EnterExitManager {
479
481
  * Port of the upstream `computeExitTransform` — exact math and order.
480
482
  * Writes `this.exitPos` / `this.exitRot`.
481
483
  *
482
- * Faithful non-guard: if the vehicle is flipped so bodyZAxis is parallel
483
- * to upAxis, projectOnPlane yields a near-zero vector and normalize()
484
- * produces NaN — upstream does not guard this either (realistic trigger:
485
- * exiting a nose-down drone along its up axis).
484
+ * A flipped/nose-down vehicle can make bodyZ parallel to up. Establish a
485
+ * finite fallback basis before normalizing so an exit can never create NaN.
486
486
  */
487
487
  private computeExitTransform(
488
488
  vehicle: VehicleUnitLike,
@@ -491,7 +491,16 @@ export class EnterExitManager {
491
491
  ): void {
492
492
  this.exitPos.copy(vehicle.currPos).addScaledVector(exitDirection, exitLength);
493
493
  const up = vehicle.upAxis;
494
- this.exitZAxis.copy(vehicle.bodyZAxis).projectOnPlane(up).normalize();
494
+ this.exitZAxis.copy(vehicle.bodyZAxis).projectOnPlane(up);
495
+ if (this.exitZAxis.lengthSq() < 1e-6) {
496
+ this.exitZAxis.copy(vehicle.bodyXAxis).projectOnPlane(up);
497
+ if (this.exitZAxis.lengthSq() < 1e-6) {
498
+ if (Math.abs(up.y) < 0.9) this.exitZAxis.set(0, 1, 0);
499
+ else this.exitZAxis.set(1, 0, 0);
500
+ this.exitZAxis.projectOnPlane(up);
501
+ }
502
+ }
503
+ this.exitZAxis.normalize();
495
504
  // Cross order matters: X = up x Z. Swapping mirrors the spawn basis.
496
505
  this.exitXAxis.crossVectors(up, this.exitZAxis);
497
506
  // makeBasis takes COLUMN vectors X, Y, Z.
@@ -0,0 +1,263 @@
1
+ import * as THREE from "three";
2
+ import RAPIER from "@dimforge/rapier3d-compat";
3
+ import type {
4
+ ObjectControlOptions,
5
+ ObjectControlResult,
6
+ Session,
7
+ } from "@genex-ai/multiplayer";
8
+ import {
9
+ followBody,
10
+ placeBody,
11
+ readNetworkPose,
12
+ roundPose,
13
+ stateFromBody,
14
+ type NetworkPoseState,
15
+ type PoseSafety,
16
+ } from "./pose.ts";
17
+
18
+ export interface NetworkedPushableOptions<TPlayer extends Record<string, unknown>> {
19
+ id: string;
20
+ room: () => Session<TPlayer> | null;
21
+ body: RAPIER.RigidBody;
22
+ object: THREE.Object3D;
23
+ safety?: PoseSafety;
24
+ publishHz?: number;
25
+ keepaliveMs?: number;
26
+ releaseAfterRestMs?: number;
27
+ restSpeed?: number;
28
+ minimumOwnMs?: number;
29
+ onInvalidState?: (raw: Record<string, unknown>) => void;
30
+ onOwnerChange?: (owner: string | undefined) => void;
31
+ onRecovery?: (state: NetworkPoseState) => void;
32
+ onControl?: (result: ObjectControlResult) => void;
33
+ }
34
+
35
+ /**
36
+ * Reusable distributed Rapier proxy: dynamic only for the confirmed owner,
37
+ * kinematic follower otherwise. It consumes the SDK-smoothed state directly;
38
+ * it never adds a second interpolation buffer.
39
+ */
40
+ export class NetworkedPushable<TPlayer extends Record<string, unknown>> {
41
+ private readonly id: string;
42
+ private readonly room: () => Session<TPlayer> | null;
43
+ private readonly body: RAPIER.RigidBody;
44
+ private readonly object: THREE.Object3D;
45
+ private readonly safety: PoseSafety;
46
+ private readonly publishEveryMs: number;
47
+ private readonly keepaliveMs: number;
48
+ private readonly releaseAfterRestMs: number;
49
+ private readonly restSpeed: number;
50
+ private readonly minimumOwnMs: number;
51
+ private readonly onInvalidState?: (raw: Record<string, unknown>) => void;
52
+ private readonly onOwnerChange?: (owner: string | undefined) => void;
53
+ private readonly onRecovery?: (state: NetworkPoseState) => void;
54
+ private readonly onControl?: (result: ObjectControlResult) => void;
55
+
56
+ private touching = false;
57
+ private wasMine = false;
58
+ private pendingClaim: Promise<ObjectControlResult> | null = null;
59
+ private pendingRelease: Promise<ObjectControlResult> | null = null;
60
+ private retryAt = 0;
61
+ private ownedAt = 0;
62
+ private restSince = 0;
63
+ private lastPublishAt = 0;
64
+ private lastPayload = "";
65
+ private lastEpoch: number | undefined;
66
+ private lastOwner: string | undefined;
67
+
68
+ constructor(options: NetworkedPushableOptions<TPlayer>) {
69
+ this.id = options.id;
70
+ this.room = options.room;
71
+ this.body = options.body;
72
+ this.object = options.object;
73
+ this.safety = options.safety ?? {};
74
+ this.publishEveryMs = 1000 / (options.publishHz ?? 20);
75
+ this.keepaliveMs = options.keepaliveMs ?? 500;
76
+ this.releaseAfterRestMs = options.releaseAfterRestMs ?? 2000;
77
+ this.restSpeed = options.restSpeed ?? 0.1;
78
+ this.minimumOwnMs = options.minimumOwnMs ?? 500;
79
+ this.onInvalidState = options.onInvalidState;
80
+ this.onOwnerChange = options.onOwnerChange;
81
+ this.onRecovery = options.onRecovery;
82
+ this.onControl = options.onControl;
83
+ this.body.setBodyType(RAPIER.RigidBodyType.KinematicPositionBased, true);
84
+ }
85
+
86
+ /** Keep this true for the whole contact, not just the collision rising edge. */
87
+ setContact(active: boolean): void {
88
+ this.touching = active;
89
+ if (!active) this.retryAt = 0;
90
+ }
91
+
92
+ async requestClaim(options: ObjectControlOptions = {}): Promise<ObjectControlResult> {
93
+ const room = this.room();
94
+ if (!room) return this.localFailure("disconnected");
95
+ if (this.pendingClaim) return this.pendingClaim;
96
+ const promise = room.objects.claimConfirmed(this.id, options);
97
+ this.pendingClaim = promise;
98
+ const result = await promise;
99
+ this.pendingClaim = null;
100
+ this.onControl?.(result);
101
+ if (!result.accepted && this.touching && (result.reason === "held" || result.reason === "rate-limited")) {
102
+ // Both reasons are transient and carry (or imply) a server-computed retry delay. Ignoring
103
+ // it for rate-limited would re-claim every round-trip and keep the control lane saturated.
104
+ this.retryAt = performance.now() + Math.max(16, result.retryAfterMs ?? 50);
105
+ } else if (!result.accepted && (result.reason === "timeout" || result.reason === "disconnected")) {
106
+ // Outcome unknown / transport down — back off instead of hammering; the shared owner
107
+ // mirror converges regardless (an actually-applied claim flips isMine on its own).
108
+ this.retryAt = performance.now() + 250;
109
+ } else {
110
+ this.retryAt = 0;
111
+ }
112
+ return result;
113
+ }
114
+
115
+ /** Confirm authority before applying an irreversible kick/weapon impulse. */
116
+ async withAuthority(action: (body: RAPIER.RigidBody) => void): Promise<ObjectControlResult> {
117
+ const result = await this.requestClaim();
118
+ if (!result.accepted) return result;
119
+ this.becomeOwner();
120
+ action(this.body);
121
+ return result;
122
+ }
123
+
124
+ update(): void {
125
+ const room = this.room();
126
+ if (!room) return;
127
+ const view = room.objects.get<NetworkPoseState>(this.id);
128
+ if (view?.owner !== this.lastOwner) {
129
+ this.lastOwner = view?.owner;
130
+ this.onOwnerChange?.(this.lastOwner);
131
+ }
132
+ const mine = view?.isMine === true;
133
+ const now = performance.now();
134
+
135
+ if (this.touching && !mine && !this.pendingClaim && now >= this.retryAt) {
136
+ void this.requestClaim();
137
+ }
138
+
139
+ if (mine && !this.wasMine) this.becomeOwner();
140
+ if (!mine && this.wasMine) {
141
+ this.body.setBodyType(RAPIER.RigidBodyType.KinematicPositionBased, true);
142
+ this.restSince = 0;
143
+ }
144
+ this.wasMine = mine;
145
+
146
+ if (mine) {
147
+ const p = this.body.translation();
148
+ const q = this.body.rotation();
149
+ this.object.position.set(p.x, p.y, p.z);
150
+ this.object.quaternion.set(q.x, q.y, q.z, q.w);
151
+ this.maybeReleaseAtRest(now);
152
+ return;
153
+ }
154
+
155
+ const raw = view?.state as Record<string, unknown> | undefined;
156
+ const pose = readNetworkPose(raw, this.safety);
157
+ if (!pose) {
158
+ if (raw && Object.keys(raw).length > 0) this.onInvalidState?.(raw);
159
+ return;
160
+ }
161
+ this.object.position.set(pose.x, pose.y, pose.z);
162
+ this.object.quaternion.fromArray(pose.q);
163
+ const snapped = this.lastEpoch !== undefined && view !== undefined && view.epoch !== this.lastEpoch;
164
+ this.lastEpoch = view?.epoch;
165
+ if (snapped) {
166
+ placeBody(this.body, pose, false);
167
+ this.onRecovery?.(pose);
168
+ } else {
169
+ followBody(this.body, pose);
170
+ }
171
+ }
172
+
173
+ publish(now = performance.now()): void {
174
+ const room = this.room();
175
+ if (!room?.objects.get(this.id)?.isMine) return;
176
+ if (now - this.lastPublishAt < this.publishEveryMs) return;
177
+ const state = roundPose(stateFromBody(this.body));
178
+ const payload = JSON.stringify(state);
179
+ if (payload === this.lastPayload && now - this.lastPublishAt < this.keepaliveMs) return;
180
+ this.lastPayload = payload;
181
+ this.lastPublishAt = now;
182
+ room.objects.set(this.id, state);
183
+ }
184
+
185
+ async release(): Promise<ObjectControlResult> {
186
+ const room = this.room();
187
+ if (!room) return this.localFailure("disconnected", "release");
188
+ if (this.pendingRelease) return this.pendingRelease;
189
+ const pending = room.objects.releaseConfirmed(this.id);
190
+ this.pendingRelease = pending;
191
+ const result = await pending;
192
+ this.pendingRelease = null;
193
+ this.onControl?.(result);
194
+ return result;
195
+ }
196
+
197
+ async reset(state: NetworkPoseState): Promise<ObjectControlResult> {
198
+ const room = this.room();
199
+ if (!room) return this.localFailure("disconnected");
200
+ const result = await room.objects.claimConfirmed(this.id, { authority: "host" });
201
+ this.onControl?.(result);
202
+ if (!result.accepted) return result;
203
+ this.body.setBodyType(RAPIER.RigidBodyType.Dynamic, true);
204
+ placeBody(this.body, state);
205
+ this.object.position.set(state.x, state.y, state.z);
206
+ this.object.quaternion.fromArray(state.q);
207
+ room.objects.snap(this.id, roundPose(state));
208
+ this.wasMine = true;
209
+ this.ownedAt = performance.now();
210
+ return result;
211
+ }
212
+
213
+ private becomeOwner(): void {
214
+ const room = this.room();
215
+ const raw = room?.objects.get<NetworkPoseState>(this.id)?.stateRaw;
216
+ const pose = readNetworkPose(raw, this.safety);
217
+ this.body.setBodyType(RAPIER.RigidBodyType.Dynamic, true);
218
+ // Preserve the current kinematic proxy pose: it is exactly what the local
219
+ // player saw at acceptance, so replacing it with delayed raw truth creates
220
+ // a visible backward jump. Seed velocity from raw authority instead so
221
+ // momentum survives the handoff without rewinding the body.
222
+ if (pose) {
223
+ this.body.setLinvel({ x: pose.vx ?? 0, y: pose.vy ?? 0, z: pose.vz ?? 0 }, true);
224
+ this.body.setAngvel({ x: pose.avx ?? 0, y: pose.avy ?? 0, z: pose.avz ?? 0 }, true);
225
+ }
226
+ this.body.wakeUp();
227
+ this.wasMine = true;
228
+ this.ownedAt = performance.now();
229
+ this.restSince = 0;
230
+ }
231
+
232
+ private maybeReleaseAtRest(now: number): void {
233
+ if (this.touching || now - this.ownedAt < this.minimumOwnMs) {
234
+ this.restSince = 0;
235
+ return;
236
+ }
237
+ const velocity = this.body.linvel();
238
+ const speed = Math.hypot(velocity.x, velocity.y, velocity.z);
239
+ if (speed > this.restSpeed) {
240
+ this.restSince = 0;
241
+ return;
242
+ }
243
+ this.restSince ||= now;
244
+ if (now - this.restSince >= this.releaseAfterRestMs) {
245
+ this.restSince = now;
246
+ void this.release();
247
+ }
248
+ }
249
+
250
+ private localFailure(
251
+ reason: "disconnected",
252
+ operation: "claim" | "release" | "remove" = "claim",
253
+ ): ObjectControlResult {
254
+ return {
255
+ requestId: "local",
256
+ operation,
257
+ id: this.id,
258
+ accepted: false,
259
+ sequence: 0,
260
+ reason,
261
+ };
262
+ }
263
+ }