@genex-ai/cli-demo 0.25.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/README.md CHANGED
@@ -36,20 +36,20 @@ genex controller <type> # install a tuned character|car|drone controller →
36
36
  browser can't open, it prints the URL to open manually.
37
37
  3. **Saves your token** — writes `GENEX_TOKEN` to `~/.genex/env` (per-user;
38
38
  reused across projects).
39
- 4. **Creates the draft project** — generates a per-project SSH deploy key
40
- (`genex_key`, gitignored automatically), registers its public half with a new
41
- repo via `POST /api/projects`, and stores the project metadata (id, slug,
42
- `sshUrl`, urls) in `./.genex/project.json`. The game shows up in your
43
- dashboard's **My games** immediately.
39
+ 4. **Creates the draft project** — `POST /api/projects` provisions a managed
40
+ Forgejo repo (one per user; no SSH key) and stores the project metadata (id,
41
+ slug, `cloneUrl`, urls) in `./.genex/project.json`. Source pushes to that repo
42
+ over HTTPS with a per-push token minted by the API (see below), so it works
43
+ from any device with a Genex login. The game shows up in your dashboard's
44
+ **My games** immediately.
44
45
 
45
46
  `genex link <slug>` is the recovery counterpart of step 4 for a game that
46
47
  already exists: run it inside a fresh clone of the game's source repo (or any
47
- folder that lost its link) and it re-authorizes if needed, reuses or generates
48
- `genex_key`, registers the public half via `POST /api/projects/:id/deploy-key`
49
- (keys are added, never replaced), and rewrites `./.genex/project.json` — after
50
- which `preview`/`publish` update the **same** live game. It never creates a
51
- project; the dashboard's "Continue building" flow uses it when the original
52
- folder can't be found.
48
+ folder that lost its link) and it re-authorizes if needed and rewrites
49
+ `./.genex/project.json` nothing to register, since source pushes authorize per
50
+ push over HTTPS. After it, `preview`/`publish` update the **same** live game. It
51
+ never creates a project; the dashboard's "Continue building" flow uses it when
52
+ the original folder can't be found.
53
53
 
54
54
  `genex preview` and `genex publish` share a build-aware deploy core: each runs
55
55
  `npm run build` (when the project has a build script), then uploads the built
package/dist/index.js CHANGED
@@ -585,7 +585,7 @@ async function apiFetch(url, init = {}) {
585
585
 
586
586
  // src/lib/project.ts
587
587
  async function createDraftProject(opts) {
588
- const { apiUrl, token, deployKey, colyseusUrl, dashboardUrl, log } = opts;
588
+ const { apiUrl, token, colyseusUrl, dashboardUrl, log } = opts;
589
589
  log.step("Creating your project\u2026");
590
590
  const names = [opts.name, `${opts.name}-${randomSuffix()}`];
591
591
  for (let i = 0; i < names.length; i++) {
@@ -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, deployKey })
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.`);
@@ -610,17 +610,13 @@ async function createDraftProject(opts) {
610
610
  log.warn("Not authorized to create the project (token rejected).");
611
611
  return null;
612
612
  }
613
- if (res.status === 400) {
614
- log.warn("The API rejected the deploy key (must be an OpenSSH public key).");
615
- return null;
616
- }
617
613
  if (!res.ok) {
618
614
  log.warn(`Couldn't create the project (HTTP ${res.status}).`);
619
615
  return null;
620
616
  }
621
617
  const data = await res.json().catch(() => null);
622
618
  const project = data?.project;
623
- if (!project || !data?.sshUrl) {
619
+ if (!project || !project.cloneUrl) {
624
620
  log.warn("Project created, but the API response was unexpected.");
625
621
  return null;
626
622
  }
@@ -630,7 +626,7 @@ async function createDraftProject(opts) {
630
626
  return {
631
627
  id: project.id,
632
628
  slug: project.slug,
633
- sshUrl: data.sshUrl,
629
+ cloneUrl: project.cloneUrl,
634
630
  apiUrl,
635
631
  colyseusUrl,
636
632
  playUrl: project.playUrl ?? void 0,
@@ -650,57 +646,6 @@ function randomSuffix() {
650
646
  // src/lib/ssh.ts
651
647
  import fs4 from "fs/promises";
652
648
  import path4 from "path";
653
- import { spawn as spawn2 } from "child_process";
654
- var KEY_NAME = "genex_key";
655
- async function generateSshKeypair(dir, log) {
656
- const keyPath = path4.join(dir, KEY_NAME);
657
- const pubPath = `${keyPath}.pub`;
658
- try {
659
- const existing = (await fs4.readFile(pubPath, "utf8")).trim();
660
- if (existing) {
661
- log.dim(`Reusing existing deploy key (${KEY_NAME}).`);
662
- return { publicKey: existing };
663
- }
664
- } catch {
665
- }
666
- log.step("Generating a deploy key\u2026");
667
- const ok = await runSshKeygen(keyPath, log);
668
- if (!ok) return null;
669
- try {
670
- const pub = (await fs4.readFile(pubPath, "utf8")).trim();
671
- if (!pub) {
672
- log.warn("ssh-keygen produced no public key.");
673
- return null;
674
- }
675
- await fs4.chmod(keyPath, 384).catch(() => {
676
- });
677
- return { publicKey: pub };
678
- } catch (err) {
679
- log.warn(`Couldn't read the generated public key: ${String(err)}`);
680
- return null;
681
- }
682
- }
683
- function runSshKeygen(keyPath, log) {
684
- return new Promise((resolve) => {
685
- let child;
686
- try {
687
- child = spawn2(
688
- "ssh-keygen",
689
- ["-t", "ed25519", "-f", keyPath, "-N", "", "-C", "genex-agent"],
690
- { stdio: "ignore" }
691
- );
692
- } catch {
693
- log.warn("ssh-keygen not found \u2014 install OpenSSH (ssh-keygen) and re-run.");
694
- resolve(false);
695
- return;
696
- }
697
- child.on("error", () => {
698
- log.warn("ssh-keygen not found \u2014 install OpenSSH (ssh-keygen) and re-run.");
699
- resolve(false);
700
- });
701
- child.on("close", (code2) => resolve(code2 === 0));
702
- });
703
- }
704
649
  async function writeGitignore(dir, log) {
705
650
  const file = path4.join(dir, ".gitignore");
706
651
  let content = "";
@@ -709,11 +654,11 @@ async function writeGitignore(dir, log) {
709
654
  } catch {
710
655
  }
711
656
  const present = new Set(content.split("\n").map((l) => l.trim()));
712
- const toAdd = [KEY_NAME, `${KEY_NAME}.pub`, ".genex/"].filter((e) => !present.has(e));
657
+ const toAdd = [".genex/", ".env", ".env.*", "!.env.example"].filter((e) => !present.has(e));
713
658
  if (toAdd.length === 0) return;
714
659
  let next = content;
715
660
  if (next.length > 0 && !next.endsWith("\n")) next += "\n";
716
- if (!content.trim()) next += "# genex (deploy key + local metadata \u2014 never publish)\n";
661
+ if (!content.trim()) next += "# genex local metadata + secrets \u2014 never publish\n";
717
662
  next += toAdd.join("\n") + "\n";
718
663
  await fs4.writeFile(file, next);
719
664
  log.dim(`Updated .gitignore (${toAdd.join(", ")}).`);
@@ -726,7 +671,7 @@ import path6 from "path";
726
671
  // src/lib/env.ts
727
672
  import fs5 from "fs/promises";
728
673
  import path5 from "path";
729
- import { spawn as spawn3 } from "child_process";
674
+ import { spawn as spawn2 } from "child_process";
730
675
  async function writeEnvVar(envPath, key, value) {
731
676
  let content = "";
732
677
  let existed = false;
@@ -766,7 +711,7 @@ async function restrictFilePermissions(filePath) {
766
711
  if (!user) return;
767
712
  await new Promise((resolve) => {
768
713
  try {
769
- const child = spawn3(
714
+ const child = spawn2(
770
715
  "icacls",
771
716
  [filePath, "/inheritance:r", "/grant:r", `${user}:F`],
772
717
  { stdio: "ignore" }
@@ -971,16 +916,7 @@ async function runInit(opts) {
971
916
  const { path: tokenPath } = await writeUserToken(token, opts.envPath);
972
917
  log.success(`Saved your token to ${c.cyan(tokenPath)} (${ENV_TOKEN_KEY}).`);
973
918
  log.plain("");
974
- const key = await generateSshKeypair(process.cwd(), log);
975
919
  await writeGitignore(process.cwd(), log);
976
- if (!key) {
977
- log.warn(
978
- "Skipping project creation \u2014 no deploy key. Install ssh-keygen (OpenSSH) and re-run `genex init`."
979
- );
980
- log.plain("");
981
- log.success("Workspace ready (no project created yet).");
982
- return;
983
- }
984
920
  const apiUrl = getApiUrl(opts.apiUrl);
985
921
  const colyseusUrl = getColyseusUrl(opts.colyseusUrl);
986
922
  const projectName = opts.name?.trim() || path8.basename(process.cwd());
@@ -988,16 +924,20 @@ async function runInit(opts) {
988
924
  apiUrl,
989
925
  token,
990
926
  name: projectName,
991
- deployKey: key.publicKey,
927
+ repoUrl: opts.repo?.trim() || void 0,
992
928
  colyseusUrl,
993
929
  dashboardUrl: authBaseUrl,
994
930
  log
995
931
  });
996
- if (meta) {
997
- const { path: metaPath } = await writeProject(meta);
998
- log.dim(` saved ${c.cyan(metaPath)}`);
999
- 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;
1000
937
  }
938
+ const { path: metaPath } = await writeProject(meta);
939
+ log.dim(` saved ${c.cyan(metaPath)}`);
940
+ await writeGameConfigFiles(meta, log);
1001
941
  log.plain("");
1002
942
  log.success("All set. \u{1F680}");
1003
943
  }
@@ -1041,23 +981,11 @@ async function runLink(opts) {
1041
981
  process.exitCode = 1;
1042
982
  return;
1043
983
  }
1044
- const key = await generateSshKeypair(process.cwd(), log);
1045
- if (!key) {
1046
- log.error("No deploy key \u2014 install ssh-keygen (OpenSSH) and re-run `genex link`.");
1047
- process.exitCode = 1;
1048
- return;
1049
- }
1050
984
  await writeGitignore(process.cwd(), log);
1051
- log.step("Registering this folder's deploy key\u2026");
1052
- const linked = await registerDeployKey(apiUrl, token, project.id, key.publicKey, log);
1053
- if (!linked) {
1054
- process.exitCode = 1;
1055
- return;
1056
- }
1057
985
  const meta = {
1058
986
  id: project.id,
1059
987
  slug: project.slug,
1060
- sshUrl: linked.sshUrl,
988
+ cloneUrl: project.cloneUrl ?? "",
1061
989
  apiUrl,
1062
990
  colyseusUrl: getColyseusUrl(opts.colyseusUrl),
1063
991
  playUrl: project.playUrl ?? void 0,
@@ -1143,40 +1071,9 @@ async function listOwnSlugs(apiUrl, token, log) {
1143
1071
  } catch {
1144
1072
  }
1145
1073
  }
1146
- async function registerDeployKey(apiUrl, token, projectId, deployKey, log) {
1147
- let res;
1148
- try {
1149
- res = await apiFetch(`${apiUrl}/api/projects/${projectId}/deploy-key`, {
1150
- method: "POST",
1151
- headers: {
1152
- "Content-Type": "application/json",
1153
- Authorization: `Bearer ${token}`
1154
- },
1155
- body: JSON.stringify({ deployKey })
1156
- });
1157
- } catch (err) {
1158
- log.error(`Couldn't reach the API at ${apiUrl}.`);
1159
- log.dim(` ${String(err)}`);
1160
- return null;
1161
- }
1162
- if (res.status === 429) {
1163
- log.error("Rate limited \u2014 too many link attempts. Try again in a bit.");
1164
- return null;
1165
- }
1166
- if (!res.ok) {
1167
- log.error(`Couldn't register the deploy key (HTTP ${res.status}).`);
1168
- return null;
1169
- }
1170
- const data = await res.json().catch(() => null);
1171
- if (!data?.sshUrl) {
1172
- log.error("Unexpected API response while registering the deploy key.");
1173
- return null;
1174
- }
1175
- return { sshUrl: data.sshUrl };
1176
- }
1177
1074
 
1178
1075
  // src/lib/deploy.ts
1179
- import { spawn as spawn4 } from "child_process";
1076
+ import { spawn as spawn3 } from "child_process";
1180
1077
  import crypto3 from "crypto";
1181
1078
  import fs9 from "fs/promises";
1182
1079
  import os2 from "os";
@@ -1185,7 +1082,7 @@ function run(cmd, args, env) {
1185
1082
  return new Promise((resolve) => {
1186
1083
  let child;
1187
1084
  try {
1188
- child = spawn4(cmd, args, { env: env ? { ...process.env, ...env } : process.env });
1085
+ child = spawn3(cmd, args, { env: env ? { ...process.env, ...env } : process.env });
1189
1086
  } catch {
1190
1087
  resolve({ code: -1, out: "", err: `${cmd} not found` });
1191
1088
  return;
@@ -1199,6 +1096,9 @@ function run(cmd, args, env) {
1199
1096
  });
1200
1097
  }
1201
1098
  var EXCLUDE_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git", ".genex", "dist"]);
1099
+ function isSecretEnvFile(name) {
1100
+ return name === ".env" || name.startsWith(".env.") && name !== ".env.example";
1101
+ }
1202
1102
  async function deployGame(ctx, opts, log) {
1203
1103
  const cwd = process.cwd();
1204
1104
  if (!opts.noBuild && await hasBuildScript(cwd)) {
@@ -1217,10 +1117,6 @@ async function deployGame(ctx, opts, log) {
1217
1117
  const rel = path10.relative(cwd, siteDir) || ".";
1218
1118
  if (siteDir === cwd) await writeGitignore(cwd, log);
1219
1119
  const files = await collectFiles(siteDir);
1220
- if (files.some((f) => /(^|\/)genex_key(\.pub)?$/.test(f.relPath))) {
1221
- log.error(`Refusing to deploy: ${KEY_NAME} is in ${rel}. Add it to .gitignore and retry.`);
1222
- return false;
1223
- }
1224
1120
  if (files.length === 0) {
1225
1121
  log.warn("Nothing to deploy \u2014 the build produced no files.");
1226
1122
  return false;
@@ -1253,12 +1149,7 @@ async function deployGame(ctx, opts, log) {
1253
1149
  log.error("Couldn't upload your game \u2014 please try again.");
1254
1150
  return false;
1255
1151
  }
1256
- const keyPath = path10.resolve(cwd, KEY_NAME);
1257
- if (!await fileExists(keyPath)) {
1258
- log.error(`No deploy key (${KEY_NAME}) here \u2014 run \`genex init\` in this folder first.`);
1259
- return false;
1260
- }
1261
- if (!await pushSource(cwd, ctx.sshUrl, keyPath, log)) return false;
1152
+ if (!await pushSource(cwd, ctx, log)) return false;
1262
1153
  log.step("Publishing\u2026");
1263
1154
  if (!await callPublish(ctx, commit, opts, log)) return false;
1264
1155
  const index = files.find((f) => f.relPath === "index.html");
@@ -1280,7 +1171,7 @@ async function collectFiles(root) {
1280
1171
  const relPath = prefix ? `${prefix}/${e.name}` : e.name;
1281
1172
  if (e.isDirectory()) {
1282
1173
  if (!EXCLUDE_DIRS.has(e.name)) await walk2(path10.join(dir, e.name), relPath);
1283
- } else if (e.isFile() && e.name !== KEY_NAME && e.name !== `${KEY_NAME}.pub`) {
1174
+ } else if (e.isFile() && !isSecretEnvFile(e.name)) {
1284
1175
  out.push({ relPath, bytes: await fs9.readFile(path10.join(dir, e.name)) });
1285
1176
  }
1286
1177
  }
@@ -1371,8 +1262,11 @@ async function callPublish(ctx, commit, opts, log) {
1371
1262
  }
1372
1263
  return true;
1373
1264
  }
1374
- async function pushSource(cwd, sshUrl, keyPath, log) {
1265
+ async function pushSource(cwd, ctx, log) {
1375
1266
  const EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
1267
+ const target = await fetchPushUrl(ctx, log);
1268
+ if (!target) return false;
1269
+ const { pushUrl, managed } = target;
1376
1270
  const failed = () => {
1377
1271
  log.error("Couldn't save your game's source \u2014 please try again.");
1378
1272
  return false;
@@ -1389,29 +1283,27 @@ async function pushSource(cwd, sshUrl, keyPath, log) {
1389
1283
  if ((await run("git", ["init", "-q"], base)).code !== 0) return failed();
1390
1284
  await fs9.writeFile(
1391
1285
  path10.join(gitDir, "info", "exclude"),
1392
- ["node_modules/", "dist/", ".git/", KEY_NAME, `${KEY_NAME}.pub`, ".genex/", ""].join("\n")
1286
+ // .env* are secrets never publish them; `!` keeps the non-secret template.
1287
+ ["node_modules/", "dist/", ".git/", ".genex/", ".env", ".env.*", "!.env.example", ""].join("\n")
1393
1288
  );
1394
1289
  const env = { ...base, GIT_WORK_TREE: cwd, GIT_INDEX_FILE: path10.join(gitDir, "index-source") };
1395
1290
  await run("git", ["add", "-A"], env);
1396
- const tracked = await run("git", ["ls-files"], env);
1397
- if (/(^|\/)genex_key(\.pub)?$/m.test(tracked.out)) {
1398
- log.error(`Refusing to publish: ${KEY_NAME} is in your project. Add it to .gitignore and retry.`);
1399
- return false;
1400
- }
1401
- const tree = tracked.out.trim() ? (await run("git", ["write-tree"], env)).out.trim() : "";
1291
+ const tree = (await run("git", ["ls-files"], env)).out.trim() ? (await run("git", ["write-tree"], env)).out.trim() : "";
1402
1292
  if (!tree || tree === EMPTY_TREE) {
1403
1293
  log.error("Nothing to publish \u2014 the project has no files.");
1404
1294
  return false;
1405
1295
  }
1406
1296
  const commit = (await run("git", ["commit-tree", tree, "-m", "source"], { ...base, ...ident })).out.trim();
1407
1297
  await run("git", ["update-ref", "refs/heads/main", commit], base);
1408
- const push = await run("git", ["push", "-q", sshUrl, "+refs/heads/main:main"], {
1409
- ...base,
1410
- // Quote the key path: git splits GIT_SSH_COMMAND shell-like, so a folder with a
1411
- // space would otherwise break the `-i` argument.
1412
- GIT_SSH_COMMAND: `ssh -i "${keyPath}" -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new`
1413
- });
1414
- return push.code === 0 ? true : failed();
1298
+ const push = await run("git", ["push", "-q", pushUrl, "+refs/heads/main:main"], base);
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();
1415
1307
  } catch {
1416
1308
  return failed();
1417
1309
  } finally {
@@ -1419,17 +1311,35 @@ async function pushSource(cwd, sshUrl, keyPath, log) {
1419
1311
  });
1420
1312
  }
1421
1313
  }
1422
- async function isDir2(p) {
1314
+ async function fetchPushUrl(ctx, log) {
1315
+ let res;
1423
1316
  try {
1424
- return (await fs9.stat(p)).isDirectory();
1425
- } catch {
1426
- return false;
1317
+ res = await apiFetch(`${ctx.apiUrl}/api/projects/${ctx.projectId}/push-token`, {
1318
+ method: "POST",
1319
+ headers: { Authorization: `Bearer ${ctx.token}` }
1320
+ });
1321
+ } catch (err) {
1322
+ log.error(`Couldn't reach the API to authorize the source push: ${String(err)}`);
1323
+ return null;
1427
1324
  }
1325
+ if (res.status === 401) {
1326
+ log.error("Not authorized \u2014 your token may have expired. Re-run `genex init`.");
1327
+ return null;
1328
+ }
1329
+ if (!res.ok) {
1330
+ log.error(`Couldn't authorize the source push (HTTP ${res.status}).`);
1331
+ return null;
1332
+ }
1333
+ const data = await res.json().catch(() => null);
1334
+ if (!data?.pushUrl) {
1335
+ log.error("The API didn't return a push URL.");
1336
+ return null;
1337
+ }
1338
+ return { pushUrl: data.pushUrl, managed: data.managed !== false };
1428
1339
  }
1429
- async function fileExists(p) {
1340
+ async function isDir2(p) {
1430
1341
  try {
1431
- await fs9.access(p);
1432
- return true;
1342
+ return (await fs9.stat(p)).isDirectory();
1433
1343
  } catch {
1434
1344
  return false;
1435
1345
  }
@@ -1571,7 +1481,7 @@ async function runPublish(opts) {
1571
1481
  advisoryNudges(log, detections);
1572
1482
  if (!opts.noPush) {
1573
1483
  const ok = await deployGame(
1574
- { projectId: meta.id, sshUrl: meta.sshUrl, apiUrl, token },
1484
+ { projectId: meta.id, apiUrl, token },
1575
1485
  {
1576
1486
  noBuild: opts.noBuild,
1577
1487
  matchmaking: detections.matchmaking,
@@ -1643,7 +1553,7 @@ async function runPreview(opts) {
1643
1553
  advisoryNudges(log, detections);
1644
1554
  const apiUrl = getApiUrl(meta.apiUrl);
1645
1555
  const ok = await deployGame(
1646
- { projectId: meta.id, sshUrl: meta.sshUrl, apiUrl, token },
1556
+ { projectId: meta.id, apiUrl, token },
1647
1557
  // Detections reach the server on every preview too: matchmaking so a draft's
1648
1558
  // declared preset doesn't silently run the default (null clears a removed
1649
1559
  // config), embedSdkVersion/multiplayer so the dashboard's Publish button can
@@ -2158,6 +2068,8 @@ ${c.bold("Options for the generators (`model` `skybox` `sfx` `texture`)")}
2158
2068
  ${c.bold("Options for `init`")}
2159
2069
  <name> Project name (positional; default: current directory name).
2160
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.
2161
2073
  --agents <list> Agents to install skills for (claude,codex,cursor; default: auto-detect).
2162
2074
  --dir <path> Single destination workspace (overrides --agents).
2163
2075
  --env <path> Token env file (default: ~/.genex/env).
@@ -2239,6 +2151,7 @@ function parseArgs(argv) {
2239
2151
  "--colyseus-url",
2240
2152
  "--agents",
2241
2153
  "--name",
2154
+ "--repo",
2242
2155
  "--title",
2243
2156
  "--description",
2244
2157
  "--categories",
@@ -2338,6 +2251,9 @@ function applyValueFlag(options, flag, value) {
2338
2251
  case "--name":
2339
2252
  options.name = value;
2340
2253
  break;
2254
+ case "--repo":
2255
+ options.repo = value;
2256
+ break;
2341
2257
  case "--title":
2342
2258
  options.title = value;
2343
2259
  break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@genex-ai/cli-demo",
3
- "version": "0.25.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": {
@@ -92,8 +92,8 @@ refreshes the genex-owned ones.
92
92
 
93
93
  ## Reconnecting an existing game (`genex link`)
94
94
 
95
- Each game's connection to its live page is folder-local (`.genex/project.json`
96
- + the `genex_key` deploy key). If that folder is gone — deleted, or the game
95
+ Each game's connection to its live page is folder-local (`.genex/project.json`).
96
+ If that folder is gone — deleted, or the game
97
97
  was built on another machine — **don't run `init` to "recover" it**: that
98
98
  creates a brand-new game at a new URL. Instead, clone the game's source repo
99
99
  and re-link the clone to the same live game:
@@ -104,8 +104,9 @@ npm install
104
104
  npx @genex-ai/cli-demo@latest link <slug> # slug = the name in the play URL
105
105
  ```
106
106
 
107
- `link` never creates a project: it signs in if needed (the browser opens once),
108
- registers a fresh deploy key for this folder, and rewrites the local link.
107
+ `link` never creates a project: it signs in if needed (the browser opens once)
108
+ and rewrites the local link (source pushes authorize over HTTPS, so there's no
109
+ key to set up — it works from any machine).
109
110
  After it, `npx genex preview` / `npx genex publish` update the **same live
110
111
  game** — plays, likes, and comments stay. It also fixes a folder whose
111
112
  authorization went stale ("Not authorized" from `preview`/`publish`).
@@ -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