@genex-ai/cli-demo 0.18.0 → 0.20.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/dist/index.js CHANGED
@@ -1064,7 +1064,7 @@ async function deployGame(ctx, opts, log) {
1064
1064
  }
1065
1065
  if (!await pushSource(cwd, ctx.sshUrl, keyPath, log)) return false;
1066
1066
  log.step("Publishing\u2026");
1067
- if (!await callPublish(ctx, commit, opts.matchmaking ?? null, log)) return false;
1067
+ if (!await callPublish(ctx, commit, opts, log)) return false;
1068
1068
  const index = files.find((f) => f.relPath === "index.html");
1069
1069
  await waitUntilLive(grant.playUrl, fingerprintOf(index.bytes.toString("utf8")), opts.liveTimeoutMs ?? 2e4, log);
1070
1070
  return true;
@@ -1148,15 +1148,21 @@ async function uploadAll(uploadUrl, uploadToken, files, log) {
1148
1148
  await Promise.all(Array.from({ length: Math.min(8, files.length) }, () => worker()));
1149
1149
  return !failed;
1150
1150
  }
1151
- async function callPublish(ctx, commit, matchmaking, log) {
1151
+ async function callPublish(ctx, commit, opts, log) {
1152
1152
  let res;
1153
1153
  try {
1154
1154
  res = await fetch(`${ctx.apiUrl}/api/games/${ctx.projectId}/publish`, {
1155
1155
  method: "POST",
1156
1156
  headers: { "Content-Type": "application/json", Authorization: `Bearer ${ctx.token}` },
1157
- // matchmaking rides every go-live (preview AND publish): object = upsert,
1158
- // null = clear so a draft's declared preset reaches the relay immediately.
1159
- body: JSON.stringify({ commit, matchmaking: matchmaking ?? null })
1157
+ // Detections ride every go-live (preview AND publish): matchmaking is
1158
+ // tri-state (object = upsert, null = clear); embedSdkVersion/multiplayer
1159
+ // are sent only when detected (absent = server keeps the stored value).
1160
+ body: JSON.stringify({
1161
+ commit,
1162
+ matchmaking: opts.matchmaking ?? null,
1163
+ ...opts.embedSdkVersion ? { embedSdkVersion: opts.embedSdkVersion } : {},
1164
+ ...opts.multiplayer !== void 0 ? { multiplayer: opts.multiplayer } : {}
1165
+ })
1160
1166
  });
1161
1167
  } catch (err) {
1162
1168
  log.error(`Couldn't reach the API at ${ctx.apiUrl}: ${String(err)}`);
@@ -1370,7 +1376,12 @@ async function runPublish(opts) {
1370
1376
  if (!opts.noPush) {
1371
1377
  const ok = await deployGame(
1372
1378
  { projectId: meta.id, sshUrl: meta.sshUrl, apiUrl, token },
1373
- { noBuild: opts.noBuild, matchmaking: detections.matchmaking },
1379
+ {
1380
+ noBuild: opts.noBuild,
1381
+ matchmaking: detections.matchmaking,
1382
+ embedSdkVersion: detections.embedSdkVersion,
1383
+ multiplayer: detections.multiplayer
1384
+ },
1374
1385
  log
1375
1386
  );
1376
1387
  if (!ok) {
@@ -1434,9 +1445,16 @@ async function runPreview(opts) {
1434
1445
  const apiUrl = getApiUrl(meta.apiUrl);
1435
1446
  const ok = await deployGame(
1436
1447
  { projectId: meta.id, sshUrl: meta.sshUrl, apiUrl, token },
1437
- // matchmaking reaches the server on every preview too a draft with a declared
1438
- // preset must not silently run the default one (null clears a removed config).
1439
- { noBuild: opts.noBuild, matchmaking: detections.matchmaking },
1448
+ // Detections reach the server on every preview too: matchmaking so a draft's
1449
+ // declared preset doesn't silently run the default (null clears a removed
1450
+ // config), embedSdkVersion/multiplayer so the dashboard's Publish button can
1451
+ // list the draft without wrongly treating it as a pre-embed-auth bundle.
1452
+ {
1453
+ noBuild: opts.noBuild,
1454
+ matchmaking: detections.matchmaking,
1455
+ embedSdkVersion: detections.embedSdkVersion,
1456
+ multiplayer: detections.multiplayer
1457
+ },
1440
1458
  log
1441
1459
  );
1442
1460
  if (!ok) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@genex-ai/cli-demo",
3
- "version": "0.18.0",
3
+ "version": "0.20.0",
4
4
  "description": "Set up your ~/.claude workspace, authorize, create a game project, generate AI assets, and publish (genex CLI).",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,11 @@
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.
@@ -37,7 +37,16 @@ example, the shared-object/ball code, rotation, and host usage. Read
37
37
  npm i @genex-ai/multiplayer
38
38
  ```
39
39
 
40
- This skill targets `@genex-ai/multiplayer` **≥ 0.7.1** (`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).
40
+ 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).
41
+
42
+ ## Trust model (say it plainly in your game's copy)
43
+
44
+ This is **casual, favor-the-player multiplayer** (Haxball, not Rocket League): you are
45
+ authoritative over yourself, match outcomes are self-reported, and there is no server-side
46
+ simulation or anti-cheat. The server DOES enforce identity (verified tokens), object
47
+ ownership, match seating/adjudication, and rate/size caps — but a modified client can still
48
+ lie about its own position or score. Great for friends and casual lobbies; don't promise
49
+ ranked-grade fairness.
41
50
 
42
51
  ## Matchmaking (competitive presets — server-owned)
43
52
 
@@ -123,6 +132,36 @@ const room = await connect<State>({
123
132
  });
124
133
  ```
125
134
 
135
+ **Capacity:** a room holds up to **64 players** (up to 48 of them guests). Above that, the
136
+ relay opens a **second room for the same game** — two parallel worlds, no error. If your
137
+ game needs seated, one-world competition, use the matchmaking presets instead of one big
138
+ `connect()` room.
139
+
140
+ ## Disconnects & reconnection (built in — render it, don't rebuild it)
141
+
142
+ The SDK auto-reconnects after a network blip or brief signal loss: the relay holds your seat
143
+ for a grace window (~30 s), and on recovery **nothing changed** — same session id, objects
144
+ still yours, host unchanged, and in a match **a blip is not a forfeit**. Your only job is UI:
145
+
146
+ ```ts
147
+ room.on("reconnecting", ({ attempt }) => showOverlay(`Reconnecting… (${attempt})`));
148
+ room.on("reconnected", () => hideOverlay());
149
+ room.on("disconnect", (code) => {
150
+ // Terminal: server restart, revoked session, or the link never came back.
151
+ // To play again, read a FRESH token and connect() anew — never reuse the old auth object.
152
+ showMenu("Connection lost");
153
+ });
154
+ room.on("server:restart", () => flushSaves()); // the relay warns before a deploy — save now
155
+ ```
156
+
157
+ Keep your render loop running during `reconnecting` — remote players freeze briefly and then
158
+ glide on; don't tear the scene down. A deliberate `room.leave()` never auto-reconnects.
159
+
160
+ **One seat per player (enforced server-side):** joining the same game again — a second tab,
161
+ another device, or a page reload — instantly evicts the previous session (it gets
162
+ `disconnect`, code 4409). You never need to handle "the same player twice" and a reload
163
+ never leaves a ghost avatar behind.
164
+
126
165
  ## Which channel for which data
127
166
 
128
167
  **This table is the most important thing in this skill.** Every piece of networked state is one
@@ -165,13 +204,21 @@ fights (many writers). A ball on `objects` glides and has one owner. That's the
165
204
  `'host'` `(id)`, and any custom `send` name.
166
205
  - `room.send(type, payload)` — fire-and-forget to all **other** clients. **It never echoes to
167
206
  you**, so apply your own action's local effect directly (draw your own tracer at fire time),
168
- not inside `on(...)`. `room.leave()`.
207
+ not inside `on(...)`. Relay-internal names (`state`, `shared`, `claim`, `obj`, `release`,
208
+ `destroy`, `match:*`, `__*`) are refused — pick your own event names. `room.leave()`.
209
+ - `room.inputs.send(payload)` / `room.inputs.on((fromId, payload) => …)` — the host-routed
210
+ input channel for host-authoritative physics: anyone sends, ONLY the current host receives.
211
+ See [references/host-physics.md](references/host-physics.md).
212
+ - `room.onHostTick(hz, cb)` — run a fixed simulation tick only while you are the host
213
+ (auto-starts/stops across host migration). Returns a disposer.
169
214
 
170
215
  ## The loop you must build (input → local → tick → render)
171
216
 
172
217
  1. **Input mutates a local object only** (`me.x += …`). Never network on keypress.
173
218
  2. **A fixed tick publishes it:** `setInterval(() => room.me.set(me), 66)` (~15 Hz). If you own an
174
- object, `objects.set` it in the same tick.
219
+ object, `objects.set` it in the same tick. **Round numbers before publishing** —
220
+ `Math.round(v * 100) / 100` (2 decimals ≈ cm precision) — raw floats serialize as 17-digit
221
+ JSON and are the #1 bandwidth waste; nobody can see a 0.001-unit difference.
175
222
  3. **Render at your own framerate:** yourself from your *local* object; every other player from
176
223
  `players.get(id).state` directly (already smoothed); every object from `objects.get(id).state`.
177
224
  4. **Create-or-reuse one mesh per id**; remove a player's mesh on `'leave'`.
@@ -223,6 +270,17 @@ For host-simulated NPCs, the host claims and drives each enemy as an `object`; w
223
270
  leaves, its enemies are reassigned to the new host, which reads their `stateRaw` and keeps
224
271
  simulating. See the co-op recipe in [references/genre-recipes.md](references/genre-recipes.md).
225
272
 
273
+ ### Contested physics (two players pushing ONE thing) — host-authoritative
274
+
275
+ Claim-on-touch is perfect for one-touch objects (kick a ball). It **breaks down under
276
+ sustained contact** — two players pushing the same crate steal ownership back and forth and
277
+ the crate judders. For contested objects, ONE simulation must own the contest: the **host**
278
+ runs the physics for those objects; everyone else sends **inputs**
279
+ (`room.inputs.send({ push })`), which the relay routes to the host only; the host applies
280
+ them on `room.onHostTick(...)` and publishes results via `objects` (smooth for everyone).
281
+ Full recipe — including surviving host migration and wiring the Rapier controllers —
282
+ in [references/host-physics.md](references/host-physics.md).
283
+
226
284
  ## Smoothness is felt, not seen — hand the feel to a human
227
285
 
228
286
  Lag and stutter are *motion over time*. A screenshot is one frozen instant, so **you cannot tell
@@ -306,7 +364,10 @@ host-driven saving works as long as ANY account is in the room.
306
364
 
307
365
  ## Checklist
308
366
 
309
- - [ ] `npm i @genex-ai/multiplayer` (≥ 0.7.1); config wired into the build.
367
+ - [ ] `npm i @genex-ai/multiplayer` (≥ 0.8.0 — auto-reconnect, `inputs`, `onHostTick`); config wired into the build.
368
+ - [ ] `reconnecting`/`reconnected`/`disconnect` render an overlay (don't tear the scene down).
369
+ - [ ] Numbers rounded (~2 decimals) before `me.set`/`objects.set`.
370
+ - [ ] Contested (sustained-contact) objects use the host-physics pattern, not claim-on-touch.
310
371
  - [ ] `connect()` runs AFTER `await waitForPlayer()` (never `waitForAuth()` — guests would
311
372
  hang) and passes `auth: getColyseusAuth()!` (the relay rejects tokenless joins —
312
373
  see `genex-threejs-embed-auth`).
@@ -328,3 +389,6 @@ host-driven saving works as long as ANY account is in the room.
328
389
  read `getColyseusAuth()` fresh at every connect). 403 "wrong game": the `room` value
329
390
  doesn't match this game's own slug. 403 "guest capacity": the room is at its guest
330
391
  limit — signing in gets the player a seat; surface the message as-is.
392
+ - **`disconnect` fired and the player wants back in** — the old session is dead; run your
393
+ connect flow again from the top with a FRESH `getColyseusAuth()` (a cached auth object is
394
+ the usual cause of a rejoin failing 401).
@@ -0,0 +1,111 @@
1
+ # Host-authoritative physics — contested objects + networked controllers
2
+
3
+ Two networking tiers exist for moving things, and picking the right one per object is the
4
+ whole trick:
5
+
6
+ | Object kind | Tier | Why |
7
+ | --- | --- | --- |
8
+ | One-touch (kick a ball, throw a crate once) | **claim-on-touch** (`objects.claim` on contact, owner simulates) | lowest latency — your kick lands instantly |
9
+ | Sustained contact / contested (two players pushing one crate, tug-of-war, sumo, shared vehicles) | **host-authoritative** (this doc) | one simulation owns the contest — no ownership ping-pong, physics stays consistent |
10
+
11
+ Claim-on-touch under sustained contact means every contact steals ownership, resets the
12
+ simulation, and the object judders between two owners' views. Host-authoritative kills that
13
+ by construction: **the host runs the ONE Rapier world for contested objects; everyone else
14
+ sends inputs.**
15
+
16
+ ## The pattern (complete)
17
+
18
+ Every client runs this same code — `onHostTick` only fires on the current host, so there is
19
+ no "am I host?" bookkeeping and host migration is automatic:
20
+
21
+ ```ts
22
+ // ---- inputs: NON-hosts (and the host itself) report intent, ~10-15Hz or on action ----
23
+ // Routed by the relay to the CURRENT HOST ONLY — never broadcast, cheap.
24
+ if (pushingCrate) room.inputs.send({ obj: "crate", push: dir.toArray() });
25
+
26
+ // ---- simulation: runs ONLY on the host, survives migration ----
27
+ const pending: { obj: string; push: number[] }[] = [];
28
+ room.inputs.on((fromId, payload) => {
29
+ const p = payload as { obj?: string; push?: number[] };
30
+ if (p?.obj && Array.isArray(p.push)) pending.push(p as { obj: string; push: number[] });
31
+ });
32
+
33
+ room.onHostTick(30, (dtMs) => {
34
+ // 1) First tick after election: adopt the objects + seed the physics world from the
35
+ // last published truth (stateRaw) — NEVER from zero, or the world teleports.
36
+ for (const id of CONTESTED_IDS) {
37
+ const view = room.objects.get(id);
38
+ if (!view || !view.isMine) {
39
+ room.objects.claim(id);
40
+ seedRapierBody(id, view?.stateRaw); // position/rotation/velocity from the wire
41
+ }
42
+ }
43
+ // 2) Apply everyone's inputs to the ONE authoritative Rapier world.
44
+ for (const { obj, push } of pending.splice(0)) applyImpulse(obj, push);
45
+ // 3) Step and publish (flat state: numbers + one [x,y,z,w] quaternion).
46
+ rapierWorld.step();
47
+ for (const id of CONTESTED_IDS) {
48
+ const b = bodyOf(id);
49
+ room.objects.set(id, {
50
+ x: r2(b.translation().x), y: r2(b.translation().y), z: r2(b.translation().z),
51
+ q: quatArray(b.rotation()),
52
+ });
53
+ }
54
+ });
55
+ const r2 = (v: number) => Math.round(v * 100) / 100; // quantize — floats are JSON bloat
56
+
57
+ // ---- rendering: identical on every client, host included ----
58
+ for (const id of CONTESTED_IDS) {
59
+ const view = room.objects.get(id);
60
+ if (view) meshOf(id).position.set(view.state.x, view.state.y, view.state.z); // auto-smoothed
61
+ }
62
+ ```
63
+
64
+ Rules that make it correct:
65
+
66
+ - **Sim internals that must survive migration** (velocities, cooldowns, aggro) either live in
67
+ the published object state or get mirrored at low rate into a dedicated object
68
+ (`objects.set("sim", …)`) the next host reads on adoption. Positions/rotations come free
69
+ via `stateRaw`.
70
+ - **Latency honesty:** a non-host's push lands after ~RTT to the relay. At casual scale that
71
+ reads as weight, not lag. The host's own pushes are instant — that asymmetry is the tier's
72
+ price; don't fight it with client-side guessing.
73
+ - **Rate budget:** the host publishes N contested objects at 20–30 Hz; keep N modest (≤ ~10)
74
+ and state flat + quantized. Inputs are single-receiver and cheap.
75
+ - **Do not** run a second Rapier body for a contested object on non-hosts "for prediction" —
76
+ that's the double-simulation version of double-smoothing. Draw `state`.
77
+
78
+ ## Networked controllers (the vendored character / vehicle / drone)
79
+
80
+ The `genex controller` controllers are **local-only physics** — each player simulates their
81
+ OWN rig (self-authoritative, zero latency). Networking them is publish-and-playback, never
82
+ remote simulation:
83
+
84
+ ```ts
85
+ // You: after your controller's update, on the fixed tick (~15Hz)
86
+ room.me.set({
87
+ x: r2(rig.position.x), y: r2(rig.position.y), z: r2(rig.position.z),
88
+ q: rig.quaternion.toArray().map(r2), // quaternion — never a scalar yaw
89
+ anim: rig.animState, // discrete → remotes read via stateRaw
90
+ // vehicle extras: steer: r2(steerAngle), wheel: r2(wheelSpinPhase)
91
+ });
92
+
93
+ // Remote players: drive a VISUAL-ONLY rig from smoothed state — no Rapier body, no
94
+ // controller instance for remotes. Wheels/limbs animate from the published params.
95
+ const p = room.players.get(id)!;
96
+ remoteMesh.position.set(p.state.x, p.state.y, p.state.z);
97
+ remoteMesh.quaternion.fromArray(p.state.q);
98
+ remoteAnimator.play(p.stateRaw.anim); // discrete values from stateRaw
99
+ ```
100
+
101
+ What to publish per controller:
102
+
103
+ - **character**: `x/y/z`, `q`, `anim` (state-machine id), optionally `speed` for blend trees.
104
+ - **vehicle**: body `x/y/z` + `q`, `steer` angle, a `wheel` spin phase (remotes spin wheels
105
+ procedurally — never sync per-wheel transforms).
106
+ - **drone**: `x/y/z`, `q`, rotor throttle if the visual needs it.
107
+
108
+ Player-vs-player physical contact (bumping cars) stays approximate at this tier — each
109
+ client is authoritative over itself, so contacts are cosmetic. If a game's core loop IS
110
+ contested vehicle contact, that's the host-authoritative tier above, with the vehicles as
111
+ host-simulated objects and player inputs over `inputs.send`.
@@ -86,4 +86,8 @@ NPC, claimed on contact) and a room `host` (single writer of scores, single simu
86
86
  enemies). That skill covers the rules that keep it smooth (draw `state` directly, render
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
- persistent-world API. Use only the APIs that skill documents — do not invent transport methods.
89
+ persistent-world API. Reconnection is built into the SDK (render `reconnecting`/
90
+ `reconnected`, never rebuild it), and **contested physics** (two players pushing one
91
+ object — sumo, tug-of-war, shared crates) routes to its host-authoritative pattern
92
+ (`inputs` + `onHostTick`, see that skill's host-physics reference) — never claim-on-touch.
93
+ Use only the APIs that skill documents — do not invent transport methods.