@genex-ai/cli-demo 0.26.0 → 0.27.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
@@ -598,7 +598,7 @@ async function createDraftProject(opts) {
598
598
  "Content-Type": "application/json",
599
599
  Authorization: `Bearer ${token}`
600
600
  },
601
- body: JSON.stringify({ name })
601
+ body: JSON.stringify(opts.repoUrl ? { name, repoUrl: opts.repoUrl } : { name })
602
602
  });
603
603
  } catch (err) {
604
604
  log.warn(`Couldn't reach the API at ${apiUrl} to create the project.`);
@@ -924,15 +924,20 @@ async function runInit(opts) {
924
924
  apiUrl,
925
925
  token,
926
926
  name: projectName,
927
+ repoUrl: opts.repo?.trim() || void 0,
927
928
  colyseusUrl,
928
929
  dashboardUrl: authBaseUrl,
929
930
  log
930
931
  });
931
- if (meta) {
932
- const { path: metaPath } = await writeProject(meta);
933
- log.dim(` saved ${c.cyan(metaPath)}`);
934
- await writeGameConfigFiles(meta, log);
932
+ if (!meta) {
933
+ log.plain("");
934
+ log.warn("Setup finished, but creating your game failed \u2014 fix the error above and re-run `genex init`.");
935
+ process.exitCode = 1;
936
+ return;
935
937
  }
938
+ const { path: metaPath } = await writeProject(meta);
939
+ log.dim(` saved ${c.cyan(metaPath)}`);
940
+ await writeGameConfigFiles(meta, log);
936
941
  log.plain("");
937
942
  log.success("All set. \u{1F680}");
938
943
  }
@@ -1259,12 +1264,13 @@ async function callPublish(ctx, commit, opts, log) {
1259
1264
  }
1260
1265
  async function pushSource(cwd, ctx, log) {
1261
1266
  const EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
1267
+ const target = await fetchPushUrl(ctx, log);
1268
+ if (!target) return false;
1269
+ const { pushUrl, managed } = target;
1262
1270
  const failed = () => {
1263
1271
  log.error("Couldn't save your game's source \u2014 please try again.");
1264
1272
  return false;
1265
1273
  };
1266
- const pushUrl = await fetchPushUrl(ctx, log);
1267
- if (!pushUrl) return false;
1268
1274
  const gitDir = await fs9.mkdtemp(path10.join(os2.tmpdir(), "genex-source-"));
1269
1275
  const base = { GIT_DIR: gitDir };
1270
1276
  const ident = {
@@ -1277,7 +1283,7 @@ async function pushSource(cwd, ctx, log) {
1277
1283
  if ((await run("git", ["init", "-q"], base)).code !== 0) return failed();
1278
1284
  await fs9.writeFile(
1279
1285
  path10.join(gitDir, "info", "exclude"),
1280
- // .env* are secrets — the managed repo is public. `!` keeps the non-secret template.
1286
+ // .env* are secrets — never publish them; `!` keeps the non-secret template.
1281
1287
  ["node_modules/", "dist/", ".git/", ".genex/", ".env", ".env.*", "!.env.example", ""].join("\n")
1282
1288
  );
1283
1289
  const env = { ...base, GIT_WORK_TREE: cwd, GIT_INDEX_FILE: path10.join(gitDir, "index-source") };
@@ -1290,7 +1296,14 @@ async function pushSource(cwd, ctx, log) {
1290
1296
  const commit = (await run("git", ["commit-tree", tree, "-m", "source"], { ...base, ...ident })).out.trim();
1291
1297
  await run("git", ["update-ref", "refs/heads/main", commit], base);
1292
1298
  const push = await run("git", ["push", "-q", pushUrl, "+refs/heads/main:main"], base);
1293
- return push.code === 0 ? true : failed();
1299
+ if (push.code === 0) return true;
1300
+ if (!managed) {
1301
+ log.error(
1302
+ "Couldn't push to your repo. Check that you have push access (SSH key or git credentials) and that the repo exists."
1303
+ );
1304
+ return false;
1305
+ }
1306
+ return failed();
1294
1307
  } catch {
1295
1308
  return failed();
1296
1309
  } finally {
@@ -1322,7 +1335,7 @@ async function fetchPushUrl(ctx, log) {
1322
1335
  log.error("The API didn't return a push URL.");
1323
1336
  return null;
1324
1337
  }
1325
- return data.pushUrl;
1338
+ return { pushUrl: data.pushUrl, managed: data.managed !== false };
1326
1339
  }
1327
1340
  async function isDir2(p) {
1328
1341
  try {
@@ -2055,6 +2068,8 @@ ${c.bold("Options for the generators (`model` `skybox` `sfx` `texture`)")}
2055
2068
  ${c.bold("Options for `init`")}
2056
2069
  <name> Project name (positional; default: current directory name).
2057
2070
  --name <name> Same as the positional name.
2071
+ --repo <url> Host the source in your own git repo (https/ssh) instead of a managed one;
2072
+ preview/publish push there with your git credentials.
2058
2073
  --agents <list> Agents to install skills for (claude,codex,cursor; default: auto-detect).
2059
2074
  --dir <path> Single destination workspace (overrides --agents).
2060
2075
  --env <path> Token env file (default: ~/.genex/env).
@@ -2136,6 +2151,7 @@ function parseArgs(argv) {
2136
2151
  "--colyseus-url",
2137
2152
  "--agents",
2138
2153
  "--name",
2154
+ "--repo",
2139
2155
  "--title",
2140
2156
  "--description",
2141
2157
  "--categories",
@@ -2235,6 +2251,9 @@ function applyValueFlag(options, flag, value) {
2235
2251
  case "--name":
2236
2252
  options.name = value;
2237
2253
  break;
2254
+ case "--repo":
2255
+ options.repo = value;
2256
+ break;
2238
2257
  case "--title":
2239
2258
  options.title = value;
2240
2259
  break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@genex-ai/cli-demo",
3
- "version": "0.26.0",
3
+ "version": "0.27.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": {
@@ -34,9 +34,12 @@ 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
37
+ npm i @genex-ai/multiplayer@^0.8.0
38
38
  ```
39
39
 
40
+ > Pin `@^0.8.0` (not a bare `npm i`): `inputs`/`onHostTick`, auto-reconnect, and the `reconnecting`
41
+ > events this skill relies on landed in 0.8. An older resolve would throw `room.onHostTick is not a function` at runtime.
42
+
40
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).
41
44
 
42
45
  ## Trust model (say it plainly in your game's copy)
@@ -148,6 +151,7 @@ room.on("reconnecting", ({ attempt }) => showOverlay(`Reconnecting… (${attempt
148
151
  room.on("reconnected", () => hideOverlay());
149
152
  room.on("disconnect", (code) => {
150
153
  // Terminal: server restart, revoked session, or the link never came back.
154
+ if (code === 4409) flushSaves(); // replaced by this player's OTHER tab/device — flush now
151
155
  // To play again, read a FRESH token and connect() anew — never reuse the old auth object.
152
156
  showMenu("Connection lost");
153
157
  });
@@ -160,7 +164,9 @@ glide on; don't tear the scene down. A deliberate `room.leave()` never auto-reco
160
164
  **One seat per player (enforced server-side):** joining the same game again — a second tab,
161
165
  another device, or a page reload — instantly evicts the previous session (it gets
162
166
  `disconnect`, code 4409). You never need to handle "the same player twice" and a reload
163
- never leaves a ghost avatar behind.
167
+ never leaves a ghost avatar behind. If the evicted tab was the host and holds unsaved world
168
+ state, flush it in the `disconnect` handler (code 4409, above): that tab is still alive, so an
169
+ async `saveWorldState` completes — otherwise a debounced save in flight is lost.
164
170
 
165
171
  ## Which channel for which data
166
172
 
@@ -187,7 +193,10 @@ fights (many writers). A ball on `objects` glides and has one owner. That's the
187
193
  `{ id, name, state, stateRaw }`: `state` is auto-smoothed (remotes) / live (you); `stateRaw` is
188
194
  the raw latest (hit-tests, discrete values).
189
195
  - `room.objects` — shared objects nobody owns until claimed (a ball, an NPC):
190
- - `claim(id)` — take ownership (last claim wins; call on kick/contact).
196
+ - `claim(id)` — take ownership (last claim wins; call on kick/contact). Claiming is optimistic:
197
+ you own it locally the instant you call it, but if another player claimed the same tick the
198
+ server's last-claim-wins verdict can revoke you — a `set()` you sent before losing the race is
199
+ dropped. For contested objects, keep publishing while `isMine` stays true, not just once.
191
200
  - `set(id, state)` — publish it (only lands while you own it; full flat object each call).
192
201
  - `get(id)` → `{ id, owner, isMine, state, stateRaw }` or `undefined`. `state` is auto-smoothed
193
202
  (or live if `isMine`); `stateRaw` is the raw latest.
@@ -212,6 +221,24 @@ fights (many writers). A ball on `objects` glides and has one owner. That's the
212
221
  - `room.onHostTick(hz, cb)` — run a fixed simulation tick only while you are the host
213
222
  (auto-starts/stops across host migration). Returns a disposer.
214
223
 
224
+ ## Your message budget (every publish is one relay message)
225
+
226
+ Every `me.set`, `objects.set`, `objects.claim`, `send`, and `inputs.send` costs **one relay
227
+ message**, and the relay caps each connection at **~120 messages/second sustained** (drops
228
+ above that — you'll see a console warning "the relay dropped N of your messages"). The
229
+ budget math that matters:
230
+
231
+ - Your own state (`me.set`) at 15 Hz + ONE driven/owned moving object at 15 Hz = 30/s. Fine.
232
+ - The pattern that blows the budget: **republishing IDLE objects every tick.** A host that
233
+ owns several parked vehicles/props must NOT `objects.set` each of them at full tick rate —
234
+ publish an object **when it changed**, plus a low-rate keepalive (~1–2 Hz) so late joiners
235
+ converge. Unchanged pose ⇒ no message.
236
+ - If you see the drop warning, count your sends-per-tick: streams × tick-rate must stay well
237
+ under 120/s with headroom for claims and events.
238
+ - Over budget, the relay spreads the loss across ALL your streams (everything gets choppy at
239
+ once) rather than freezing one — so a single stuttering object is your cue to check the whole
240
+ budget, not just that object. The warning is the signal; don't design at the edge of the cap.
241
+
215
242
  ## The loop you must build (input → local → tick → render)
216
243
 
217
244
  1. **Input mutates a local object only** (`me.x += …`). Never network on keypress.
@@ -259,7 +286,10 @@ details are in [references/realtime-patterns.md](references/realtime-patterns.md
259
286
  ## Host authority (scores, rounds, enemies)
260
287
 
261
288
  One client is the `host`. Let *only* the host write agreed state and simulate shared enemies, so
262
- there's a single source of truth:
289
+ there's a single source of truth. **But: a host is only the authority for objects it OWNS.**
290
+ If another player owns/drives an object (their claim landed), the host renders it from the
291
+ stream like everyone else — a host branch that pins "its" objects to a local pose without
292
+ checking `owner`/`isMine` shows every other player's driving as a frozen object:
263
293
 
264
294
  ```ts
265
295
  if (room.isHost) room.shared.set("round", nextRound); // only the host advances the round
@@ -364,10 +394,12 @@ host-driven saving works as long as ANY account is in the room.
364
394
 
365
395
  ## Checklist
366
396
 
367
- - [ ] `npm i @genex-ai/multiplayer` (≥ 0.8.0 auto-reconnect, `inputs`, `onHostTick`); config wired into the build.
397
+ - [ ] `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.
368
398
  - [ ] `reconnecting`/`reconnected`/`disconnect` render an overlay (don't tear the scene down).
369
399
  - [ ] Numbers rounded (~2 decimals) before `me.set`/`objects.set`.
370
400
  - [ ] Contested (sustained-contact) objects use the host-physics pattern, not claim-on-touch.
401
+ - [ ] Idle/unchanged objects republish at ≤2 Hz keepalive, never every tick (message budget).
402
+ - [ ] Host renders objects OWNED BY OTHERS from the stream (authority follows ownership).
371
403
  - [ ] `connect()` runs AFTER `await waitForPlayer()` (never `waitForAuth()` — guests would
372
404
  hang) and passes `auth: getColyseusAuth()!` (the relay rejects tokenless joins —
373
405
  see `genex-threejs-embed-auth`).
@@ -81,29 +81,53 @@ The `genex controller` controllers are **local-only physics** — each player si
81
81
  OWN rig (self-authoritative, zero latency). Networking them is publish-and-playback, never
82
82
  remote simulation:
83
83
 
84
+ The vendored controllers expose their pose as `currPos` (a `THREE.Vector3`) and `currQuat` (a
85
+ `THREE.Quaternion`) — **not** `.position` / `.quaternion` — plus boolean state getters. Character
86
+ animation is driven by **five booleans**, not a single enum; publish the booleans and let remotes
87
+ reconstruct the animation. There is **no** `rig.animState`, no `remoteAnimator.play(...)`, and no
88
+ vehicle `wheelSpinPhase` getter.
89
+
84
90
  ```ts
85
- // You: after your controller's update, on the fixed tick (~15Hz)
91
+ // You: after your controller's update(), on the fixed tick (~15Hz).
92
+ const p = character.currPos; // THREE.Vector3 (Vehicle/Drone: same getters)
93
+ const q = character.currQuat; // THREE.Quaternion
86
94
  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)
95
+ x: r2(p.x), y: r2(p.y), z: r2(p.z),
96
+ q: [r2(q.x), r2(q.y), r2(q.z), r2(q.w)], // quaternion array — never a scalar yaw
97
+ // character animation = 5 booleans (the CharacterController exposes each as a getter):
98
+ g: character.isOnGround, f: character.isFalling, m: character.isMoving,
99
+ r: character.runActive, j: character.jumpActive,
91
100
  });
92
101
 
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
102
+ // Remote players: a VISUAL-ONLY avatarNO Rapier body, NO controller instance for remotes.
103
+ // Position/rotation from smoothed state; animation from the synced flags via the avatar's own
104
+ // update(flags, dt). The character-controller skill's animations reference owns the flag set.
105
+ const pl = room.players.get(id)!;
106
+ remoteAvatar.group.position.set(pl.state.x, pl.state.y, pl.state.z);
107
+ remoteAvatar.group.quaternion.fromArray(pl.state.q);
108
+ const raw = pl.stateRaw; // discrete flags: read RAW, never smoothed
109
+ remoteAvatar.update(
110
+ { isOnGround: !!raw.g, isFalling: !!raw.f, isMoving: !!raw.m, runActive: !!raw.r, jumpActive: !!raw.j },
111
+ dt,
112
+ );
99
113
  ```
100
114
 
101
115
  What to publish per controller:
102
116
 
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
- procedurallynever sync per-wheel transforms).
106
- - **drone**: `x/y/z`, `q`, rotor throttle if the visual needs it.
117
+ - **character**: `currPos` → `x/y/z`, `currQuat` `q`, and the five booleans above
118
+ (`isOnGround`/`isFalling`/`isMoving`/`runActive`/`jumpActive`). Remotes rebuild the animation
119
+ with `avatar.update(flags, dt)` see the `genex-threejs-character-controller` animations
120
+ reference for the flag set (single source of truth; don't invent a `play(anim)` call).
121
+ - **vehicle**: body `currPos` → `x/y/z` + `currQuat` → `q`. For visible steering, publish the
122
+ front wheel's `car.wheels.get(frontWheelId)?.steerAngle`; spin the wheels on remotes
123
+ procedurally from the body's speed (position delta) — there is no spin-phase getter, and you
124
+ never sync per-wheel transforms.
125
+ - **drone**: `currPos` → `x/y/z`, `currQuat` → `q`, and `drone.hoverThrottle` if the rotor visual
126
+ needs it. Remote rotor spin is procedural, like vehicle wheels.
127
+
128
+ > Remote **visuals** for vehicle wheels / drone rotors are author-built (a spinning mesh you drive
129
+ > from synced speed/throttle) — the controllers build those internally from a live Rapier body,
130
+ > which remotes don't have. Only the body pose + the flags/params above come over the wire.
107
131
 
108
132
  Player-vs-player physical contact (bumping cars) stays approximate at this tier — each
109
133
  client is authoritative over itself, so contacts are cosmetic. If a game's core loop IS