@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 +1 -1
- package/dist/index.js +28 -4
- package/package.json +2 -1
- package/templates/controllers/NETWORKING.md +29 -0
- package/templates/controllers/character/character-controller.ts +90 -0
- package/templates/controllers/drone/drone-controller.ts +8 -0
- package/templates/controllers/interact/enter-exit.ts +14 -5
- package/templates/controllers/network/networked-pushable.ts +263 -0
- package/templates/controllers/network/networked-vehicle.ts +240 -0
- package/templates/controllers/network/pose.ts +114 -0
- package/templates/controllers/shared/physics-world.ts +10 -0
- package/templates/controllers/vehicle/vehicle-controller.ts +5 -0
- package/templates/skills/genex-threejs-character-controller/SKILL.md +10 -1
- package/templates/skills/genex-threejs-multiplayer/SKILL.md +54 -34
- package/templates/skills/genex-threejs-multiplayer/references/genre-recipes.md +8 -6
- package/templates/skills/genex-threejs-multiplayer/references/host-physics.md +39 -21
- package/templates/skills/genex-threejs-multiplayer/references/realtime-patterns.md +8 -6
- package/templates/skills/genex-threejs-vehicle-controllers/SKILL.md +23 -8
- package/templates/skills/genex-threejs-vehicle-controllers/references/enter-exit.md +19 -10
- package/templates/controllers/shared/NETWORKING.md +0 -11
|
@@ -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` +
|
|
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.
|
|
37
|
+
npm i @genex-ai/multiplayer@^0.9.0
|
|
38
38
|
```
|
|
39
39
|
|
|
40
|
-
> Pin `@^0.
|
|
41
|
-
>
|
|
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.
|
|
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:**
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
201
|
-
|
|
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)` —
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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)`
|
|
258
|
-
|
|
259
|
-
|
|
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)` —
|
|
276
|
-
|
|
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
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
|
291
|
-
|
|
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
|
-
|
|
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
|
-
**
|
|
338
|
-
|
|
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.
|
|
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
|
-
- **
|
|
27
|
-
`room.objects.
|
|
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
|
|
89
|
-
|
|
90
|
-
|
|
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.
|
|
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
|