@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
|
@@ -19,6 +19,17 @@ ownership to "whoever touched last" is itself the wrong model.
|
|
|
19
19
|
|
|
20
20
|
## Tier 1 — pushable / ownable body (claim-on-touch + a Rapier proxy)
|
|
21
21
|
|
|
22
|
+
Install the product helper first:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
genex controller networked-physics
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
`NetworkedPushable` owns the confirmed-claim/retry, dynamic-to-kinematic switch, visible-proxy pose
|
|
29
|
+
plus raw-momentum handoff seed,
|
|
30
|
+
single-smoothed follower, moved-only publishing, keepalive, release-on-rest, finite pose validation,
|
|
31
|
+
and host reset snap. Prefer that helper over copying this explanatory state machine.
|
|
32
|
+
|
|
22
33
|
The object is a REAL Rapier body on every client, and its body TYPE follows ownership:
|
|
23
34
|
|
|
24
35
|
- **You own it → a `dynamic` body.** Your (dynamic) character capsule collides with it and Rapier's
|
|
@@ -45,14 +56,16 @@ const body = physics.createBody({
|
|
|
45
56
|
userData: { controller: { excludeCharacterRay: true } },
|
|
46
57
|
});
|
|
47
58
|
ballCollider(physics.world, body, RADIUS, { friction: 0.5, restitution: 0.35, density: 0.6 });
|
|
48
|
-
let
|
|
59
|
+
let touching = false, wasMine = false, pendingClaim: Promise<unknown> | null = null, retryAt = 0;
|
|
49
60
|
const MAX_SPEED = 24;
|
|
50
61
|
|
|
51
62
|
// The host seeds the canonical spawn ONCE so the object exists on `objects` for late joiners.
|
|
52
|
-
function ensureInit() {
|
|
63
|
+
async function ensureInit() {
|
|
53
64
|
if (room.isHost && room.objects.get("ball") === undefined) {
|
|
54
|
-
room.objects.
|
|
55
|
-
|
|
65
|
+
const result = await room.objects.claimConfirmed("ball", { authority: "host" });
|
|
66
|
+
if (!result.accepted) return;
|
|
67
|
+
room.objects.snap("ball", { x, y, z, q: [0, 0, 0, 1], vx: 0, vy: 0, vz: 0 });
|
|
68
|
+
await room.objects.releaseConfirmed("ball");
|
|
56
69
|
}
|
|
57
70
|
}
|
|
58
71
|
|
|
@@ -61,18 +74,17 @@ function updateBall(dt: number) {
|
|
|
61
74
|
let v = room.objects.get<BallState>("ball");
|
|
62
75
|
let mine = !!v?.isMine;
|
|
63
76
|
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
// at ground level while the ball centre is at y≈RADIUS, so a 3D distance would eat into your reach.
|
|
77
|
+
// Keep contact validity alive. One rejected collision edge must not consume the attempt forever:
|
|
78
|
+
// retry after the relay hold delay while STILL touching, with at most one request in flight.
|
|
67
79
|
const dx = ballMesh.position.x - feet.x, dz = ballMesh.position.z - feet.z;
|
|
68
|
-
|
|
69
|
-
if (
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
80
|
+
touching = onFoot && Math.hypot(dx, dz) < RADIUS + REACH;
|
|
81
|
+
if (touching && !mine && !pendingClaim && performance.now() >= retryAt) {
|
|
82
|
+
pendingClaim = room.objects.claimConfirmed("ball").then((result) => {
|
|
83
|
+
if (!result.accepted && touching && (result.reason === "held" || result.reason === "rate-limited")) {
|
|
84
|
+
retryAt = performance.now() + (result.retryAfterMs ?? 50); // both carry a server retry delay — honor it
|
|
85
|
+
} else retryAt = 0;
|
|
86
|
+
}).finally(() => { pendingClaim = null; });
|
|
74
87
|
}
|
|
75
|
-
wasTouching = touching;
|
|
76
88
|
|
|
77
89
|
if (mine && !wasMine) {
|
|
78
90
|
// Gained ownership → dynamic. SEED velocity from the wire (NEVER zero, or a rolling ball
|
|
@@ -121,15 +133,16 @@ function publishBall() {
|
|
|
121
133
|
|
|
122
134
|
Rules that make it correct:
|
|
123
135
|
|
|
124
|
-
- **
|
|
125
|
-
|
|
126
|
-
|
|
136
|
+
- **Sustained-contact confirmed claim:** keep one request pending; after `held`, retry at
|
|
137
|
+
`retryAfterMs` only while contact still exists. Never spam every frame and never let one rejected
|
|
138
|
+
collision edge permanently disable the interaction.
|
|
127
139
|
- **Never zero velocity on claim** — seed `linvel` from the published `vx/vy/vz`, or a rolling ball snaps
|
|
128
140
|
to a dead stop on every handoff.
|
|
129
141
|
- **Publish velocity, not just position** — the next owner seeds from it, so the object keeps its motion
|
|
130
142
|
across the handoff.
|
|
131
143
|
- **Release-on-rest** (optional): once you own an object that's been at rest + untouched a couple of
|
|
132
|
-
seconds, `room.objects.
|
|
144
|
+
seconds, `await room.objects.releaseConfirmed("ball")` so ownership doesn't pile up on a lone
|
|
145
|
+
wanderer — its last
|
|
133
146
|
state persists on the wire for the next toucher, who re-claims.
|
|
134
147
|
- **Distributed by construction:** each client publishes only what it currently owns, so the message
|
|
135
148
|
budget spreads across players instead of funnelling every object through one host's send budget. This
|
|
@@ -168,13 +181,14 @@ room.inputs.on((fromId, payload) => {
|
|
|
168
181
|
if (p?.obj && Array.isArray(p.push)) pending.push(p as { obj: string; push: number[] });
|
|
169
182
|
});
|
|
170
183
|
|
|
171
|
-
room.onHostTick(30, (dtMs) => {
|
|
184
|
+
room.onHostTick(30, async (dtMs) => {
|
|
172
185
|
// 1) First tick after election: adopt the objects + seed the physics world from the
|
|
173
186
|
// last published truth (stateRaw) — NEVER from zero, or the world teleports.
|
|
174
187
|
for (const id of CONTESTED_IDS) {
|
|
175
188
|
const view = room.objects.get(id);
|
|
176
|
-
if (!view
|
|
177
|
-
room.objects.
|
|
189
|
+
if (!view?.isMine) {
|
|
190
|
+
const result = await room.objects.claimConfirmed(id, { authority: "host" });
|
|
191
|
+
if (!result.accepted) continue;
|
|
178
192
|
seedRapierBody(id, view?.stateRaw); // position/rotation/velocity from the wire
|
|
179
193
|
}
|
|
180
194
|
}
|
|
@@ -201,6 +215,10 @@ for (const id of CONTESTED_IDS) {
|
|
|
201
215
|
|
|
202
216
|
Rules that make it correct:
|
|
203
217
|
|
|
218
|
+
- `onHostTick` pauses during reconnect and stops on demotion/leave/terminal disconnect. Bound the input
|
|
219
|
+
queue, attribute input from the callback's authenticated `fromId` (never payload `from`), and discard
|
|
220
|
+
stale pre-failover inputs when a new host adopts raw state.
|
|
221
|
+
|
|
204
222
|
- **Sim internals that must survive migration** (velocities, cooldowns, aggro) either live in
|
|
205
223
|
the published object state or get mirrored at low rate into a dedicated object
|
|
206
224
|
(`objects.set("sim", …)`) the next host reads on adoption. Positions/rotations come free
|
|
@@ -104,9 +104,10 @@ ownership changes (no snap), and the object survives its owner leaving (reassign
|
|
|
104
104
|
// ball.ts
|
|
105
105
|
const ball = { x: 0, y: 0.5, z: 0, vx: 0, vy: 0, vz: 0 }; // the owner's local sim
|
|
106
106
|
|
|
107
|
-
//
|
|
108
|
-
function kick(dir: THREE.Vector3, power: number) {
|
|
109
|
-
room.objects.
|
|
107
|
+
// Confirm on the contact edge; retry a `held` result only while contact remains.
|
|
108
|
+
async function kick(dir: THREE.Vector3, power: number) {
|
|
109
|
+
const result = await room.objects.claimConfirmed("ball");
|
|
110
|
+
if (!result.accepted) return;
|
|
110
111
|
ball.vx += dir.x * power; ball.vz += dir.z * power;
|
|
111
112
|
}
|
|
112
113
|
|
|
@@ -129,13 +130,14 @@ function drawBall() {
|
|
|
129
130
|
|
|
130
131
|
Rules that keep it correct:
|
|
131
132
|
|
|
132
|
-
- **
|
|
133
|
+
- **Confirm ownership before an irreversible interaction.** On `held`, respect `retryAfterMs` and
|
|
134
|
+
retry only while the original contact/action is still valid.
|
|
133
135
|
- **Keep object fields flat** (`x/y/z`, a 4-number quaternion). Nested objects snap instead of glide.
|
|
134
136
|
- **Only the owner's `set` lands** — the relay drops writes from non-owners, so you never get two
|
|
135
137
|
clients fighting over the ball. You don't need to check "am I owner" before drawing, only before
|
|
136
138
|
simulating.
|
|
137
|
-
- **Transient objects** (bullets, pickups): `room.objects.
|
|
138
|
-
|
|
139
|
+
- **Transient objects** (bullets, pickups): the owner awaits `room.objects.removeConfirmed(id)`
|
|
140
|
+
when they expire, so destruction is acknowledged and idempotent across reconnects.
|
|
139
141
|
|
|
140
142
|
## Host authority (scores, rounds, world)
|
|
141
143
|
|
|
@@ -29,6 +29,9 @@ Existing files are never overwritten (use `--force` to refresh). For the
|
|
|
29
29
|
on-foot character that enters these vehicles, also run
|
|
30
30
|
`npx genex controller character` and load `$genex-threejs-character-controller`.
|
|
31
31
|
|
|
32
|
+
These files become game-owned. Do not use `--force` as an upgrade mechanism after editing them;
|
|
33
|
+
install into a scratch project and port the targeted `syncFromBody`/transition changes.
|
|
34
|
+
|
|
32
35
|
## The loop contract (get this right first)
|
|
33
36
|
|
|
34
37
|
Every controller exposes `update(dt?)` that must run once per **fixed physics
|
|
@@ -106,20 +109,32 @@ On enter/exit: the on-foot character **disappears into the vehicle on enter
|
|
|
106
109
|
behavior, not a bug. Don't leave the character mesh standing next to a car it
|
|
107
110
|
is supposedly driving; see [references/enter-exit.md](references/enter-exit.md).
|
|
108
111
|
|
|
109
|
-
## Multiplayer: vehicle occupancy is
|
|
112
|
+
## Multiplayer: vehicle occupancy is confirmed object ownership
|
|
110
113
|
|
|
111
114
|
If the game is multiplayer, **who is in what vehicle must be synced** — a
|
|
112
115
|
remote player vanishing into an apparently empty car reads as a bug. Load
|
|
113
116
|
`$genex-threejs-multiplayer` before writing any networking code, then:
|
|
114
117
|
|
|
115
|
-
-
|
|
116
|
-
|
|
118
|
+
- Install `genex controller networked-physics` and use `NetworkedVehicle`. A seat is the vehicle
|
|
119
|
+
object's confirmed owner—not a writable `shared` key. Enter only after `claimConfirmed` accepts;
|
|
120
|
+
two simultaneous entrants therefore produce one driver and one clean loser.
|
|
121
|
+
- **Occupied means owned.** Idle vehicles stay unowned, so a present owner IS the current driver —
|
|
122
|
+
`enter()` refuses an owned vehicle by default (the relay's short hold only protects the first
|
|
123
|
+
~300 ms of a drive, so without this gate any walk-up claim would hijack a moving car and dump its
|
|
124
|
+
driver). Pass `enter({ steal: true })` only when carjacking is an intended mechanic, and wire
|
|
125
|
+
`onSeatLost` so a forcibly dismounted driver returns to on-foot controls instead of a dead seat.
|
|
126
|
+
- Publish `driving: "car" | null` in the player's state for remote visuals. Use `me.snap` on entry/exit
|
|
127
|
+
mode edges, and continuous `objects.set` only from the confirmed driver.
|
|
117
128
|
- Same authority rule as the character: the **local player simulates the
|
|
118
|
-
physics of whatever they occupy**; remote vehicles are
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
129
|
+
physics of whatever they occupy**; remote vehicles are visuals only —
|
|
130
|
+
draw the SDK-smoothed `objects.get(id).state` directly on a plain mesh
|
|
131
|
+
(never re-lerp/buffer it) and never run `VehicleController`/
|
|
132
|
+
`DroneController` for a remote player.
|
|
133
|
+
- Idle vehicles remain unowned at their last raw pose. The host may explicitly claim+snap+release to
|
|
134
|
+
seed/reset, but must not continuously adopt or republish parked vehicles. Persist raw idle truth.
|
|
135
|
+
- On accepted entry, seed the body from `stateRaw`, enable/register/wake it, call `syncFromBody`, and
|
|
136
|
+
`snapBodyInterpolation` before controls/camera read it. On exit, publish final pose, player-snap the
|
|
137
|
+
character, unregister, then wait for confirmed release.
|
|
123
138
|
|
|
124
139
|
## Boundaries and troubleshooting
|
|
125
140
|
|
|
@@ -98,9 +98,9 @@ registered vehicle wins the prompt.
|
|
|
98
98
|
reappears. Raise it if the character spawns inside a wide chassis. The
|
|
99
99
|
default equals the default sensor radius so immediate re-entry stays
|
|
100
100
|
possible — intended.
|
|
101
|
-
-
|
|
102
|
-
|
|
103
|
-
|
|
101
|
+
- Flipped/nose-down vehicles are supported: the manager projects the live forward axis and falls back
|
|
102
|
+
to body-X/world-Z before normalization, so the exit basis remains finite. Still choose an
|
|
103
|
+
`exitLength` that clears the actual chassis collider.
|
|
104
104
|
|
|
105
105
|
## Seat visuals: Sitting_Enter / Driving_Loop / Sitting_Exit
|
|
106
106
|
|
|
@@ -186,15 +186,24 @@ far-from-Y gravity direction will misbehave. For rigs beyond this, see
|
|
|
186
186
|
|
|
187
187
|
## Multiplayer
|
|
188
188
|
|
|
189
|
-
Occupancy
|
|
189
|
+
Occupancy uses confirmed object ownership — remote players must see who is in what. With
|
|
190
190
|
`$genex-threejs-multiplayer` (load it before writing any networking code):
|
|
191
191
|
|
|
192
192
|
- Put `driving: "car" | "drone" | null` in each player's synced state; on
|
|
193
193
|
remote change, show/hide that player's on-foot avatar and seat their
|
|
194
194
|
visual in the vehicle (the seat-visual pattern above, minus the physics).
|
|
195
|
-
- The occupant simulates the vehicle and publishes its pose; everyone else
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
195
|
+
- The occupant simulates the vehicle and publishes its pose; everyone else draws the
|
|
196
|
+
SDK-smoothed `objects.get(vehicleId).state` directly on a plain mesh — the SDK already
|
|
197
|
+
interpolates it. Never buffer/lerp that `state` a second time, and never run the vehicle
|
|
198
|
+
controllers for a remote player's vehicle.
|
|
199
|
+
- Install `genex controller networked-physics` and use `NetworkedVehicle`: gate entry on
|
|
200
|
+
`NetworkedVehicle.enter()` / `objects.claimConfirmed(vehicleId)` **before** parking the
|
|
201
|
+
character or switching the `EnterExitManager` active unit — a rejected seat must leave the
|
|
202
|
+
on-foot prompt and controls untouched. Do NOT put the claim inside `onOccupantEnter` after the
|
|
203
|
+
park: `performInteract` has no rollback, so a rejection there strands a hidden character wired
|
|
204
|
+
to a vehicle it never won. Never use `shared.set("occupant:car", ...)` as a lock—any client can
|
|
205
|
+
overwrite it. An accepted seat seeds from raw pose, calls `syncFromBody` +
|
|
206
|
+
`snapBodyInterpolation`, and becomes the only publisher.
|
|
207
|
+
- Exit publishes final vehicle truth, uses `me.snap` for the player mode edge, unregisters the local
|
|
208
|
+
body, and waits for `releaseConfirmed`. Idle vehicles stay unowned; host seed/reset is the narrow
|
|
209
|
+
`{ authority: "host" }` claim+snap+release path.
|
|
@@ -1,11 +0,0 @@
|
|
|
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
|
-
The complete recipe (what to publish per controller, remote playback, contested-contact
|
|
9
|
-
physics via the host) lives in the multiplayer skill:
|
|
10
|
-
`genex-threejs-multiplayer` → `references/host-physics.md`. Load that skill before writing
|
|
11
|
-
any networking code.
|