@genex-ai/cli-demo 0.28.0 → 0.30.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/package.json +1 -1
- package/templates/skills/genex-getting-started/SKILL.md +18 -0
- package/templates/skills/genex-threejs-multiplayer/SKILL.md +17 -11
- package/templates/skills/genex-threejs-multiplayer/references/host-physics.md +119 -20
- package/templates/skills/genex-threejs-skill-router/references/routing-map.md +5 -3
package/package.json
CHANGED
|
@@ -69,6 +69,24 @@ keep its renderer, structure, and conventions — extend, don't rebuild.
|
|
|
69
69
|
Running `genex init` in such a folder only wires identity/publishing and
|
|
70
70
|
refreshes the genex-owned skills; the game's own files are never touched.
|
|
71
71
|
|
|
72
|
+
## Hosting your game's source
|
|
73
|
+
|
|
74
|
+
By default `genex init` creates a **managed repo** for your source — you push to
|
|
75
|
+
it over HTTPS with a token the API mints per push, so it works from any device
|
|
76
|
+
with no SSH key, and `preview`/`publish` save the source there automatically.
|
|
77
|
+
|
|
78
|
+
To keep the source in **your own git repo** instead, pass it at init:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
npx genex init my-game --repo https://git.example.com/you/my-game.git
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Then `preview`/`publish` push the source to **your** repo using the git
|
|
85
|
+
credentials `git push` already uses on this machine (https or ssh). Point it at a
|
|
86
|
+
repo dedicated to this game — each publish overwrites its `main` with the current
|
|
87
|
+
source. Without push access, publish stops with a clear error and nothing ships.
|
|
88
|
+
The playable game is served by Genex either way.
|
|
89
|
+
|
|
72
90
|
## Publishing
|
|
73
91
|
|
|
74
92
|
Before your first `genex preview`, the play URL (`https://<slug>.genex.technology/`)
|
|
@@ -40,7 +40,7 @@ npm i @genex-ai/multiplayer@^0.8.0
|
|
|
40
40
|
> Pin `@^0.8.0` (not a bare `npm i`): `inputs`/`onHostTick`, auto-reconnect, and the `reconnecting`
|
|
41
41
|
> events this skill relies on landed in 0.8. An older resolve would throw `room.onHostTick is not a function` at runtime.
|
|
42
42
|
|
|
43
|
-
This skill targets `@genex-ai/multiplayer` **≥ 0.8.0** (`objects`/`host` since 0.4; `matchmake()` since 0.5; presets + `score()`/`finish()` since 0.6; `createPrivate()`/`joinPrivate()` since 0.7; matchmake auto-retry + `retry()` since 0.7.1; auto-reconnect + `inputs`/`onHostTick` since 0.8).
|
|
43
|
+
This skill targets `@genex-ai/multiplayer` **≥ 0.8.0** (`objects`/`host` since 0.4; `matchmake()` since 0.5; presets + `score()`/`finish()` since 0.6; `createPrivate()`/`joinPrivate()` since 0.7; matchmake auto-retry + `retry()` since 0.7.1; auto-reconnect + `inputs`/`onHostTick` since 0.8; soft ownership handoff — claim-on-touch objects glide instead of teleporting — since 0.8.4).
|
|
44
44
|
|
|
45
45
|
## Trust model (say it plainly in your game's copy)
|
|
46
46
|
|
|
@@ -300,16 +300,22 @@ For host-simulated NPCs, the host claims and drives each enemy as an `object`; w
|
|
|
300
300
|
leaves, its enemies are reassigned to the new host, which reads their `stateRaw` and keeps
|
|
301
301
|
simulating. See the co-op recipe in [references/genre-recipes.md](references/genre-recipes.md).
|
|
302
302
|
|
|
303
|
-
###
|
|
303
|
+
### Pushable / contested physics — pick the tier
|
|
304
304
|
|
|
305
|
-
Claim-on-touch is
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
305
|
+
Claim-on-touch is the default for any **ownable** body — a ball, a box, a prop, a pickup — even when
|
|
306
|
+
players take turns bumping it: whoever touches it owns + simulates it with real Rapier physics, and
|
|
307
|
+
since `@genex-ai/multiplayer` 0.8.4 the ownership handoff **glides** (a soft handoff) instead of
|
|
308
|
+
teleporting. Give it a Rapier proxy that is `dynamic` while you own it (your character shoves it through
|
|
309
|
+
the solver) and a `kinematicPosition` follower of the smoothed `state` when you don't.
|
|
310
|
+
|
|
311
|
+
Reserve **host-authoritative** for a genuine *simultaneous* contest — two players pushing ONE crate
|
|
312
|
+
*against each other*, sumo, tug-of-war — where "whoever touched last owns it" is the wrong model. There
|
|
313
|
+
one neutral simulation (the host) runs the physics; everyone else sends **inputs**
|
|
314
|
+
(`room.inputs.send({ push })`), which the relay routes to the host only; the host applies them on
|
|
315
|
+
`room.onHostTick(...)` and publishes results via `objects` (smooth for everyone).
|
|
316
|
+
|
|
317
|
+
Both patterns — the claim-on-touch Rapier proxy and the host-authoritative contest, plus surviving host
|
|
318
|
+
migration and wiring the vendored controllers — are in [references/host-physics.md](references/host-physics.md).
|
|
313
319
|
|
|
314
320
|
## Smoothness is felt, not seen — hand the feel to a human
|
|
315
321
|
|
|
@@ -397,7 +403,7 @@ host-driven saving works as long as ANY account is in the room.
|
|
|
397
403
|
- [ ] `npm i @genex-ai/multiplayer@^0.8.0` (auto-reconnect, `inputs`, `onHostTick` — pin `^0.8.0`, a bare install can resolve older); config wired into the build.
|
|
398
404
|
- [ ] `reconnecting`/`reconnected`/`disconnect` render an overlay (don't tear the scene down).
|
|
399
405
|
- [ ] Numbers rounded (~2 decimals) before `me.set`/`objects.set`.
|
|
400
|
-
- [ ]
|
|
406
|
+
- [ ] 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.
|
|
401
407
|
- [ ] Idle/unchanged objects republish at ≤2 Hz keepalive, never every tick (message budget).
|
|
402
408
|
- [ ] Host renders objects OWNED BY OTHERS from the stream (authority follows ownership).
|
|
403
409
|
- [ ] `connect()` runs AFTER `await waitForPlayer()` (never `waitForAuth()` — guests would
|
|
@@ -1,22 +1,118 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Networked physics — pushable/ownable objects, contested objects + controllers
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Three networking tiers exist for moving things, and picking the right one per object is the
|
|
4
4
|
whole trick:
|
|
5
5
|
|
|
6
|
-
| Object kind | Tier |
|
|
6
|
+
| Object kind | Tier | How |
|
|
7
7
|
| --- | --- | --- |
|
|
8
|
-
|
|
|
9
|
-
|
|
|
8
|
+
| **Pushable / ownable body** — a ball, a crate you shove, a physics prop, a pickup; one player interacts at a time (even if players take turns) | **claim-on-touch + a Rapier proxy** (Tier 1) | whoever touches it owns + simulates it locally with REAL physics; the soft handoff glides ownership changes |
|
|
9
|
+
| **Genuine simultaneous contest** — two players pushing ONE object *against each other*: sumo, tug-of-war, a shared crate nobody should "own" | **host-authoritative** (Tier 2) | one neutral simulation (the host) runs the contest; everyone else sends inputs |
|
|
10
|
+
| **Your own controller** — the vendored character / vehicle / drone rig | **publish-and-playback** (Tier 3) | each player simulates their OWN rig; remotes replay the pose |
|
|
10
11
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
The old advice was "claim-on-touch breaks down under sustained contact, use host-authoritative for
|
|
13
|
+
anything contested." That was true only because an ownership handoff used to **teleport** the object
|
|
14
|
+
— the interpolation buffer reset on every claim. **Since `@genex-ai/multiplayer` 0.8.4 the handoff is
|
|
15
|
+
a soft handoff: the render glides across the ownership change instead of snapping** — so claim-on-touch
|
|
16
|
+
is now the right default for any *ownable* body (a ball, a box, a prop), even when players take turns
|
|
17
|
+
bumping it. Reserve host-authoritative (Tier 2) for a genuine *simultaneous* tug-of-war where handing
|
|
18
|
+
ownership to "whoever touched last" is itself the wrong model.
|
|
15
19
|
|
|
16
|
-
##
|
|
20
|
+
## Tier 1 — pushable / ownable body (claim-on-touch + a Rapier proxy)
|
|
17
21
|
|
|
18
|
-
|
|
19
|
-
|
|
22
|
+
The object is a REAL Rapier body on every client, and its body TYPE follows ownership:
|
|
23
|
+
|
|
24
|
+
- **You own it → a `dynamic` body.** Your (dynamic) character capsule collides with it and Rapier's
|
|
25
|
+
solver resolves the push on the fixed substep — real momentum, spin, friction, bounce, zero latency.
|
|
26
|
+
A *regular pushable object*, not a dribble and not an attachment.
|
|
27
|
+
- **You don't own it → a `kinematicPosition` body** you drive each frame to the smoothed
|
|
28
|
+
`objects.get(id).state`. It's solid, so your character physically bumps it — and that bump fires your
|
|
29
|
+
claim.
|
|
30
|
+
|
|
31
|
+
This is ONE body that IS the render — not a second "prediction" body running alongside the stream
|
|
32
|
+
(that would be double-simulation, the sibling of double-smoothing). When you own it you draw the dynamic
|
|
33
|
+
body; when you don't you draw the smoothed stream and park the kinematic body on it.
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
import RAPIER from "@dimforge/rapier3d-compat";
|
|
37
|
+
// Proxy body: kinematic to start (owned by nobody, or by someone else).
|
|
38
|
+
const body = physics.createBody({ type: "kinematicPosition", position: [x, y, z], ccd: true });
|
|
39
|
+
ballCollider(physics.world, body, RADIUS, { friction: 0.5, restitution: 0.35, density: 0.6 });
|
|
40
|
+
let wasTouching = false, wasMine = false, claimCd = 0;
|
|
41
|
+
|
|
42
|
+
// Per-frame, BEFORE physics.step (so the kinematic proxy is in place for the character to hit it):
|
|
43
|
+
function updateBall(dt: number) {
|
|
44
|
+
let v = room.objects.get("ball");
|
|
45
|
+
let mine = !!v?.isMine;
|
|
46
|
+
|
|
47
|
+
// Rising-EDGE claim: sustained contact yields NO new rising edge, so ONE owner holds it
|
|
48
|
+
// (no per-frame ownership ping-pong). A short cooldown throttles a steal-war.
|
|
49
|
+
const touching = onFoot && feet.distanceTo(ballMesh.position) < RADIUS + REACH;
|
|
50
|
+
if (claimCd > 0) claimCd -= dt;
|
|
51
|
+
if (touching && !wasTouching && !mine && claimCd <= 0) {
|
|
52
|
+
room.objects.claim("ball"); // optimistic: isMine flips true locally THIS frame
|
|
53
|
+
claimCd = 0.25;
|
|
54
|
+
v = room.objects.get("ball"); mine = !!v?.isMine;
|
|
55
|
+
}
|
|
56
|
+
wasTouching = touching;
|
|
57
|
+
|
|
58
|
+
if (mine && !wasMine) {
|
|
59
|
+
// Gained ownership → dynamic. SEED velocity from the wire (NEVER zero, or a rolling ball
|
|
60
|
+
// stalls on the handoff); keep the position where the proxy already renders (no snap).
|
|
61
|
+
body.setBodyType(RAPIER.RigidBodyType.Dynamic, true);
|
|
62
|
+
const raw = v?.stateRaw;
|
|
63
|
+
body.setLinvel({ x: raw?.vx ?? 0, y: raw?.vy ?? 0, z: raw?.vz ?? 0 }, true);
|
|
64
|
+
} else if (!mine && wasMine) {
|
|
65
|
+
body.setBodyType(RAPIER.RigidBodyType.KinematicPositionBased, true); // lost → follower
|
|
66
|
+
}
|
|
67
|
+
wasMine = mine;
|
|
68
|
+
|
|
69
|
+
if (mine) {
|
|
70
|
+
ballMesh.position.copy(body.translation()); // draw the dynamic body I simulate
|
|
71
|
+
ballMesh.quaternion.copy(body.rotation());
|
|
72
|
+
} else if (v?.state) {
|
|
73
|
+
ballMesh.position.set(v.state.x, v.state.y, v.state.z); // draw the smoothed stream
|
|
74
|
+
body.setNextKinematicTranslation(ballMesh.position); // keep the proxy on it → I can bump → claim
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Owner-gated publish on your fixed tick (30Hz for a fast ball) — moved-only + a ~2Hz keepalive.
|
|
79
|
+
// Publish VELOCITY too, so the next owner seeds from it and the handoff doesn't stall.
|
|
80
|
+
if (room.objects.get("ball")?.isMine) {
|
|
81
|
+
const t = body.translation(), q = body.rotation(), lv = body.linvel();
|
|
82
|
+
room.objects.set("ball", { x: r2(t.x), y: r2(t.y), z: r2(t.z),
|
|
83
|
+
q: [r2(q.x), r2(q.y), r2(q.z), r2(q.w)], vx: r2(lv.x), vy: r2(lv.y), vz: r2(lv.z) });
|
|
84
|
+
}
|
|
85
|
+
const r2 = (v: number) => Math.round(v * 100) / 100;
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Rules that make it correct:
|
|
89
|
+
|
|
90
|
+
- **Rising-edge claim + a short cooldown** (~0.25s), never "am I touching": sustained contact must not
|
|
91
|
+
re-claim every frame. Add a minimum-hold (~0.5s) before you'll `release`, so a shove finishes under
|
|
92
|
+
your sim.
|
|
93
|
+
- **Never zero velocity on claim** — seed `linvel` from the published `vx/vy/vz`, or a rolling ball snaps
|
|
94
|
+
to a dead stop on every handoff.
|
|
95
|
+
- **Publish velocity, not just position** — the next owner seeds from it, so the object keeps its motion
|
|
96
|
+
across the handoff.
|
|
97
|
+
- **Release-on-rest** (optional): once you own an object that's been at rest + untouched a couple of
|
|
98
|
+
seconds, `room.objects.release("ball")` so ownership doesn't pile up on a lone wanderer — its last
|
|
99
|
+
state persists on the wire for the next toucher, who re-claims.
|
|
100
|
+
- **Distributed by construction:** each client publishes only what it currently owns, so the message
|
|
101
|
+
budget spreads across players instead of funnelling every object through one host's send budget. This
|
|
102
|
+
is why Tier 1 scales to many pushable objects where a host-authoritative fleet (Tier 2) would blow one
|
|
103
|
+
client's rate cap.
|
|
104
|
+
- **Do NOT `registerBody(proxy, mesh)`** — drive the mesh yourself (from the body when you own it, from
|
|
105
|
+
the stream when you don't). Registering it fights the SDK's smoothing on the stream half.
|
|
106
|
+
|
|
107
|
+
## Tier 2 — genuine simultaneous contest (host-authoritative)
|
|
108
|
+
|
|
109
|
+
For a real tug-of-war — two players pushing ONE crate *against each other*, sumo — handing ownership to
|
|
110
|
+
"whoever touched last" is the wrong model: a single neutral simulation must own the contest. The **host**
|
|
111
|
+
runs the ONE Rapier world for those objects; everyone else sends **inputs**, which the relay routes to
|
|
112
|
+
the current host only.
|
|
113
|
+
|
|
114
|
+
Every client runs this same code — `onHostTick` only fires on the current host, so there is no "am I
|
|
115
|
+
host?" bookkeeping and host migration is automatic:
|
|
20
116
|
|
|
21
117
|
```ts
|
|
22
118
|
// ---- inputs: NON-hosts (and the host itself) report intent, ~10-15Hz or on action ----
|
|
@@ -69,13 +165,16 @@ Rules that make it correct:
|
|
|
69
165
|
via `stateRaw`.
|
|
70
166
|
- **Latency honesty:** a non-host's push lands after ~RTT to the relay. At casual scale that
|
|
71
167
|
reads as weight, not lag. The host's own pushes are instant — that asymmetry is the tier's
|
|
72
|
-
price
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
168
|
+
price. (Tier 1 has no such asymmetry — the owner IS the simulator — which is another reason to
|
|
169
|
+
prefer it for a plain pushable body.)
|
|
170
|
+
- **Rate budget:** the host publishes N contested objects at 20–30 Hz; keep N modest (≤ ~8) and
|
|
171
|
+
state flat + quantized. Inputs are single-receiver and cheap.
|
|
172
|
+
- **Do not** run a second Rapier body for a Tier-2 object on non-hosts "for prediction" — that's the
|
|
173
|
+
double-simulation version of double-smoothing. Non-hosts draw `state`. (This is the opposite of the
|
|
174
|
+
Tier-1 proxy, which is a single body that IS the render for the object you OWN — not a shadow sim of
|
|
175
|
+
one the host owns.)
|
|
77
176
|
|
|
78
|
-
##
|
|
177
|
+
## Tier 3 — networked controllers (the vendored character / vehicle / drone)
|
|
79
178
|
|
|
80
179
|
The `genex controller` controllers are **local-only physics** — each player simulates their
|
|
81
180
|
OWN rig (self-authoritative, zero latency). Networking them is publish-and-playback, never
|
|
@@ -131,5 +230,5 @@ What to publish per controller:
|
|
|
131
230
|
|
|
132
231
|
Player-vs-player physical contact (bumping cars) stays approximate at this tier — each
|
|
133
232
|
client is authoritative over itself, so contacts are cosmetic. If a game's core loop IS
|
|
134
|
-
contested vehicle contact, that's
|
|
135
|
-
|
|
233
|
+
contested vehicle contact, that's Tier 2 above, with the vehicles as host-simulated objects and
|
|
234
|
+
player inputs over `inputs.send`.
|
|
@@ -87,7 +87,9 @@ enemies). That skill covers the rules that keep it smooth (draw `state` directly
|
|
|
87
87
|
yourself and objects you own from a local object, quaternion rotation, `stateRaw` for
|
|
88
88
|
hit-tests), the per-genre recipes (sports/ball, shooter, co-op), config wiring, and the
|
|
89
89
|
persistent-world API. Reconnection is built into the SDK (render `reconnecting`/
|
|
90
|
-
`reconnected`, never rebuild it)
|
|
91
|
-
|
|
92
|
-
(
|
|
90
|
+
`reconnected`, never rebuild it). **Pushable/ownable bodies** (a ball, a box, a prop) use
|
|
91
|
+
claim-on-touch + a Rapier proxy — the soft handoff glides the ownership change; only a genuine
|
|
92
|
+
**simultaneous** contest (two players pushing one crate against each other — sumo, tug-of-war)
|
|
93
|
+
uses the host-authoritative pattern (`inputs` + `onHostTick`). Both are in that skill's
|
|
94
|
+
host-physics reference.
|
|
93
95
|
Use only the APIs that skill documents — do not invent transport methods.
|