@genex-ai/cli-demo 0.39.0 → 0.41.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/index.js +28 -4
- package/package.json +2 -1
- package/templates/controllers/NETWORKING.md +34 -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 +273 -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 +92 -34
- package/templates/skills/genex-threejs-multiplayer/references/genre-recipes.md +51 -29
- package/templates/skills/genex-threejs-multiplayer/references/host-physics.md +39 -21
- package/templates/skills/genex-threejs-multiplayer/references/realtime-patterns.md +25 -6
- package/templates/skills/genex-threejs-vehicle-controllers/SKILL.md +28 -9
- package/templates/skills/genex-threejs-vehicle-controllers/references/enter-exit.md +33 -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,30 @@ fights (many writers). A ball on `objects` glides and has one owner. That's the
|
|
|
272
286
|
- `room.inputs.send(payload)` / `room.inputs.on((fromId, payload) => …)` — the host-routed
|
|
273
287
|
input channel for host-authoritative physics: anyone sends, ONLY the current host receives.
|
|
274
288
|
See [references/host-physics.md](references/host-physics.md).
|
|
275
|
-
- `room.onHostTick(hz, cb)` —
|
|
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
|
-
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
306
|
+
- **Per-projectile objects need a hard cap + confirmed removal.** Each live projectile at 30 Hz
|
|
307
|
+
is 30/s of bulk budget, and every spawned id counts against the room's 128-object cap forever
|
|
308
|
+
unless removed. Cap live projectiles per player (2–3), remove the OLDEST via
|
|
309
|
+
`objects.removeConfirmed` before spawning past the cap, and `removeConfirmed` on impact/expiry
|
|
310
|
+
— uncapped spawns are how a shooter silently kills its own object budget.
|
|
311
|
+
- If you see the drop warning, count each lane separately and also leave headroom under the global
|
|
312
|
+
ceiling. Reserved capacity protects presence and control, but it is not permission to spam bulk.
|
|
295
313
|
|
|
296
314
|
## The loop you must build (input → local → tick → render)
|
|
297
315
|
|
|
@@ -325,7 +343,9 @@ relay enforce it), the owner simulates it, and everyone else reads it auto-smoot
|
|
|
325
343
|
interpolation as a player. Ownership survives the owner leaving (reassigned to the host).
|
|
326
344
|
|
|
327
345
|
```ts
|
|
328
|
-
|
|
346
|
+
const result = await room.objects.claimConfirmed("ball");
|
|
347
|
+
if (result.accepted) applyKickAndFeedback();
|
|
348
|
+
else if (result.reason === "held" && stillTouching) retryAfter(result.retryAfterMs);
|
|
329
349
|
|
|
330
350
|
if (room.objects.get("ball")?.isMine) {
|
|
331
351
|
room.objects.set("ball", stepBallPhysics()); // only the owner's writes land
|
|
@@ -334,8 +354,11 @@ const ball = room.objects.get("ball");
|
|
|
334
354
|
if (ball) drawBall(ball.state); // smoothed for everyone, live for the owner
|
|
335
355
|
```
|
|
336
356
|
|
|
337
|
-
**
|
|
338
|
-
|
|
357
|
+
**Keep contact validity alive until acceptance; do not consume one rejected rising edge forever.**
|
|
358
|
+
Never claim every frame: maintain one pending request, then retry after the relay delay only while the
|
|
359
|
+
contact remains valid. Keep object state flat. For Rapier pushables, install the shipped state machine
|
|
360
|
+
with `genex controller networked-physics`; see
|
|
361
|
+
[references/host-physics.md](references/host-physics.md).
|
|
339
362
|
|
|
340
363
|
## Host authority (scores, rounds, enemies)
|
|
341
364
|
|
|
@@ -350,9 +373,37 @@ if (room.isHost) room.shared.set("round", nextRound); // only the host advance
|
|
|
350
373
|
room.on("host", (id) => {}); // host migrated (someone left)
|
|
351
374
|
```
|
|
352
375
|
|
|
376
|
+
**Award points exactly once — even across a host migration.** A newly-elected host re-observes
|
|
377
|
+
whatever condition the old host may already have scored (the goal state, the defeat event — they
|
|
378
|
+
are still on the wire). Never bump a score from a re-observable condition alone: carry a
|
|
379
|
+
**monotonic marker in the same `shared` write**, so the score and its dedupe commit atomically:
|
|
380
|
+
|
|
381
|
+
```ts
|
|
382
|
+
// One shared "scores" map holds both the tallies and reserved "__" marker rows.
|
|
383
|
+
function awardOnce(scorerUid: string, name: string, marker: `__${string}`, seq: number) {
|
|
384
|
+
if (!room.isHost || !Number.isSafeInteger(seq)) return false;
|
|
385
|
+
const scores = { ...((room.shared.get("scores") ?? {}) as Record<string, { name: string; points: number }>) };
|
|
386
|
+
if ((scores[marker]?.points ?? -1) >= seq) return false; // already awarded by SOME host
|
|
387
|
+
scores[scorerUid] = { name, points: (scores[scorerUid]?.points ?? 0) + 1 };
|
|
388
|
+
scores[marker] = { name: "", points: seq }; // the marker rides the same write
|
|
389
|
+
room.shared.set("scores", scores);
|
|
390
|
+
return true;
|
|
391
|
+
}
|
|
392
|
+
// goals: seq = a goalEpoch you bump on each reset · kills: seq = the victim's `life` counter
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
**Key scores by a STABLE identity, never the session id.** A session id dies on every reload —
|
|
396
|
+
the points orphan into a duplicate row and the player's color changes. Publish a short `uid` in
|
|
397
|
+
each player's state (from the embed identity: `const { user } = await waitForPlayer()`,
|
|
398
|
+
`uid = user.id` — stable for signed-in players AND guests; see `$genex-threejs-embed-auth`), and
|
|
399
|
+
key `scores`/colors/`isMe` by that uid. When an event only carries a session id, map it via
|
|
400
|
+
`room.players.get(sid)?.stateRaw.uid`.
|
|
401
|
+
|
|
353
402
|
For host-simulated NPCs, the host claims and drives each enemy as an `object`; when the host
|
|
354
403
|
leaves, its enemies are reassigned to the new host, which reads their `stateRaw` and keeps
|
|
355
404
|
simulating. See the co-op recipe in [references/genre-recipes.md](references/genre-recipes.md).
|
|
405
|
+
For PvP combat (hitscan, melee, projectiles, defeat/respawn) follow the shooter recipe there —
|
|
406
|
+
its damage/defeat dedupe rules are what keep kills exactly-once under lag.
|
|
356
407
|
|
|
357
408
|
### Pushable / contested physics — pick the tier
|
|
358
409
|
|
|
@@ -454,11 +505,18 @@ host-driven saving works as long as ANY account is in the room.
|
|
|
454
505
|
|
|
455
506
|
## Checklist
|
|
456
507
|
|
|
457
|
-
- [ ] `npm i @genex-ai/multiplayer@^0.
|
|
508
|
+
- [ ] `npm i @genex-ai/multiplayer@^0.9.0` (confirmed controls, snap epochs, reconnect-safe host ticks); config wired into the build.
|
|
458
509
|
- [ ] `reconnecting`/`reconnected`/`disconnect` render an overlay (don't tear the scene down).
|
|
459
510
|
- [ ] Numbers rounded (~2 decimals) before `me.set`/`objects.set`.
|
|
460
511
|
- [ ] Pushable/ownable objects (ball, box, prop) use claim-on-touch + a Rapier proxy (the soft handoff glides the handoff); only a genuine simultaneous tug-of-war (sumo) uses the host-authoritative pattern. See host-physics.md.
|
|
512
|
+
- [ ] Irreversible actions wait for `claimConfirmed`; held contact retries after `retryAfterMs` while still valid.
|
|
513
|
+
- [ ] Respawn/reset/vehicle-mode discontinuities use `me.snap`/`objects.snap`; ordinary motion uses `set`.
|
|
461
514
|
- [ ] Idle/unchanged objects republish at ≤2 Hz keepalive, never every tick (message budget).
|
|
515
|
+
- [ ] PvP combat follows the shooter recipe: attacks/defeats are `send` events with `seq`/`life`
|
|
516
|
+
dedupe keys, the victim applies its own damage, respawn publishes via `me.snap`, and
|
|
517
|
+
projectiles are hard-capped objects removed with `removeConfirmed`.
|
|
518
|
+
- [ ] Host score writes are exactly-once (marker in the same `shared` write) and keyed by the
|
|
519
|
+
stable embed `uid`, never the session id.
|
|
462
520
|
- [ ] Host renders objects OWNED BY OTHERS from the stream (authority follows ownership).
|
|
463
521
|
- [ ] `connect()` runs AFTER `await waitForPlayer()` (never `waitForAuth()` — guests would
|
|
464
522
|
hang) and passes `auth: getColyseusAuth()!` (the relay rejects tokenless joins —
|