@genex-ai/cli-demo 0.18.0 → 0.21.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 +128 -10
- package/package.json +1 -1
- package/templates/controllers/shared/NETWORKING.md +11 -0
- package/templates/skills/genex-ai-model/SKILL.md +24 -16
- package/templates/skills/genex-explore/SKILL.md +69 -0
- package/templates/skills/genex-threejs-camera-direction/SKILL.md +12 -0
- package/templates/skills/genex-threejs-embed-auth/SKILL.md +18 -0
- package/templates/skills/genex-threejs-game-feel/SKILL.md +4 -0
- package/templates/skills/genex-threejs-multiplayer/SKILL.md +68 -4
- package/templates/skills/genex-threejs-multiplayer/references/host-physics.md +111 -0
- package/templates/skills/genex-threejs-skill-router/references/routing-map.md +5 -1
- package/templates/skills/genex-threejs-visual-validation/SKILL.md +9 -1
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
|
|
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,
|
|
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
|
-
//
|
|
1158
|
-
//
|
|
1159
|
-
|
|
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
|
-
{
|
|
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) {
|
|
@@ -1388,6 +1399,9 @@ async function runPublish(opts) {
|
|
|
1388
1399
|
if (opts.description) body.description = opts.description;
|
|
1389
1400
|
if (opts.regenerateCover) body.regenerateCover = true;
|
|
1390
1401
|
if (opts.categories?.length) body.categories = opts.categories;
|
|
1402
|
+
if (opts.sourceRepoUrl) body.sourceRepoUrl = opts.sourceRepoUrl;
|
|
1403
|
+
if (opts.sourceAuthor) body.sourceAuthor = opts.sourceAuthor;
|
|
1404
|
+
if (opts.license) body.license = opts.license;
|
|
1391
1405
|
if (detections.embedSdkVersion) body.embedSdkVersion = detections.embedSdkVersion;
|
|
1392
1406
|
body.multiplayer = detections.multiplayer;
|
|
1393
1407
|
body.matchmaking = detections.matchmaking ?? null;
|
|
@@ -1434,9 +1448,16 @@ async function runPreview(opts) {
|
|
|
1434
1448
|
const apiUrl = getApiUrl(meta.apiUrl);
|
|
1435
1449
|
const ok = await deployGame(
|
|
1436
1450
|
{ projectId: meta.id, sshUrl: meta.sshUrl, apiUrl, token },
|
|
1437
|
-
//
|
|
1438
|
-
// preset
|
|
1439
|
-
|
|
1451
|
+
// Detections reach the server on every preview too: matchmaking so a draft's
|
|
1452
|
+
// declared preset doesn't silently run the default (null clears a removed
|
|
1453
|
+
// config), embedSdkVersion/multiplayer so the dashboard's Publish button can
|
|
1454
|
+
// list the draft without wrongly treating it as a pre-embed-auth bundle.
|
|
1455
|
+
{
|
|
1456
|
+
noBuild: opts.noBuild,
|
|
1457
|
+
matchmaking: detections.matchmaking,
|
|
1458
|
+
embedSdkVersion: detections.embedSdkVersion,
|
|
1459
|
+
multiplayer: detections.multiplayer
|
|
1460
|
+
},
|
|
1440
1461
|
log
|
|
1441
1462
|
);
|
|
1442
1463
|
if (!ok) {
|
|
@@ -1888,6 +1909,72 @@ async function exists2(p) {
|
|
|
1888
1909
|
}
|
|
1889
1910
|
}
|
|
1890
1911
|
|
|
1912
|
+
// src/commands/explore.ts
|
|
1913
|
+
async function runExplore(opts) {
|
|
1914
|
+
const log = createLogger({ quiet: opts.quiet });
|
|
1915
|
+
const query = opts.query?.trim();
|
|
1916
|
+
if (!query) {
|
|
1917
|
+
log.error('Missing query. Usage: genex explore "<what you need>"');
|
|
1918
|
+
process.exitCode = 1;
|
|
1919
|
+
return;
|
|
1920
|
+
}
|
|
1921
|
+
const apiUrl = getApiUrl(opts.apiUrl);
|
|
1922
|
+
let res;
|
|
1923
|
+
try {
|
|
1924
|
+
res = await fetch(`${apiUrl}/api/gallery/search-corpus?curated=1`);
|
|
1925
|
+
} catch {
|
|
1926
|
+
log.error("Couldn't reach Genex \u2014 please try again.");
|
|
1927
|
+
process.exitCode = 1;
|
|
1928
|
+
return;
|
|
1929
|
+
}
|
|
1930
|
+
if (!res.ok) {
|
|
1931
|
+
log.error(`explore: API returned ${res.status} \u2014 please try again.`);
|
|
1932
|
+
process.exitCode = 1;
|
|
1933
|
+
return;
|
|
1934
|
+
}
|
|
1935
|
+
const { items } = await res.json();
|
|
1936
|
+
const ranked = rank(items, query).slice(0, 5);
|
|
1937
|
+
if (opts.json) {
|
|
1938
|
+
console.log(JSON.stringify(ranked, null, 2));
|
|
1939
|
+
return;
|
|
1940
|
+
}
|
|
1941
|
+
if (ranked.length === 0) {
|
|
1942
|
+
log.plain(
|
|
1943
|
+
`No curated projects matched "${query}". Try a broader term (terrain, grass, vehicle, water, building, dungeon, fish, shader).`
|
|
1944
|
+
);
|
|
1945
|
+
return;
|
|
1946
|
+
}
|
|
1947
|
+
for (const [i, p] of ranked.entries()) {
|
|
1948
|
+
const meta = [
|
|
1949
|
+
p.categories.join(", "),
|
|
1950
|
+
p.license ?? "",
|
|
1951
|
+
p.sourceAuthor ? `originally by ${p.sourceAuthor}` : ""
|
|
1952
|
+
].filter(Boolean).join(" \xB7 ");
|
|
1953
|
+
log.plain(`${c.bold(`${i + 1}. ${p.title}`)} \u2014 ${meta}`);
|
|
1954
|
+
if (p.description) log.plain(` ${p.description.slice(0, 160)}`);
|
|
1955
|
+
if (p.playUrl) log.plain(` play: ${p.playUrl}`);
|
|
1956
|
+
if (p.cloneUrl) log.plain(` clone: ${p.cloneUrl} (editable source on main)`);
|
|
1957
|
+
if (p.sourceRepoUrl) log.plain(` upstream: ${p.sourceRepoUrl}`);
|
|
1958
|
+
}
|
|
1959
|
+
log.plain("");
|
|
1960
|
+
log.plain(
|
|
1961
|
+
`To use one: clone it as a starting template, or borrow parts into your current game \u2014 the genex-explore skill has the exact steps. Keep the credits.`
|
|
1962
|
+
);
|
|
1963
|
+
}
|
|
1964
|
+
function rank(items, query) {
|
|
1965
|
+
const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
|
|
1966
|
+
return items.map((p) => {
|
|
1967
|
+
const hay = `${p.title} ${p.description} ${p.categories.join(" ")}`.toLowerCase();
|
|
1968
|
+
let score = 0;
|
|
1969
|
+
for (const t of terms) {
|
|
1970
|
+
if (p.title.toLowerCase().includes(t)) score += 3;
|
|
1971
|
+
if (p.categories.some((cat) => cat.includes(t))) score += 2;
|
|
1972
|
+
if (hay.includes(t)) score += 1;
|
|
1973
|
+
}
|
|
1974
|
+
return { p, score };
|
|
1975
|
+
}).filter((x) => x.score > 0).sort((a, b) => b.score - a.score || b.p.playsCount - a.p.playsCount).map((x) => x.p);
|
|
1976
|
+
}
|
|
1977
|
+
|
|
1891
1978
|
// src/index.ts
|
|
1892
1979
|
var GEN_KINDS = /* @__PURE__ */ new Set(["model", "skybox", "sfx", "texture"]);
|
|
1893
1980
|
function getVersion() {
|
|
@@ -1916,6 +2003,8 @@ ${c.bold("Usage")}
|
|
|
1916
2003
|
genex texture "<prompt>" [options] Generate a PBR texture into public/assets/textures.
|
|
1917
2004
|
genex controller <type> [--force] Install a physics controller (character|car|drone)
|
|
1918
2005
|
into src/controllers (+ assets into public/assets).
|
|
2006
|
+
genex explore "<query>" [options] Search the curated community gallery \u2014 proven
|
|
2007
|
+
Three.js systems you can clone or borrow parts from.
|
|
1919
2008
|
|
|
1920
2009
|
${c.bold("Options for the generators (`model` `skybox` `sfx` `texture`)")}
|
|
1921
2010
|
--terrain (texture) seamless tiling surface for terrain/ground.
|
|
@@ -1954,9 +2043,17 @@ ${c.bold("Options for `preview` / `publish`")}
|
|
|
1954
2043
|
--categories <list> (publish) 1-3 gallery categories, comma-separated.
|
|
1955
2044
|
Valid: games, assets, physics, terrain, lighting, vfx.
|
|
1956
2045
|
--regenerate-cover (publish) Re-mint the disc cover (concept changed).
|
|
2046
|
+
--source-repo-url <url> (publish) Upstream repo this game is a port of \u2014
|
|
2047
|
+
shown as "Ported from \u2026" credit on the game page.
|
|
2048
|
+
--source-author <name> (publish) Upstream author to credit.
|
|
2049
|
+
--license <label> (publish) Upstream license label (e.g. MIT).
|
|
1957
2050
|
--api-url <url> (publish) Override the API base URL.
|
|
1958
2051
|
--env <path> Token env file (default: ~/.genex/env).
|
|
1959
2052
|
|
|
2053
|
+
${c.bold("Options for `explore`")}
|
|
2054
|
+
--json Print the top matches as JSON (for tools/agents).
|
|
2055
|
+
--api-url <url> Override the API base URL.
|
|
2056
|
+
|
|
1960
2057
|
${c.bold("Global")}
|
|
1961
2058
|
--quiet Reduce output.
|
|
1962
2059
|
-h, --help Show this help.
|
|
@@ -1981,6 +2078,7 @@ ${c.bold("Examples")}
|
|
|
1981
2078
|
genex sfx "punchy laser zap" --duration 2
|
|
1982
2079
|
genex texture "mossy cracked cobblestone" --terrain
|
|
1983
2080
|
genex controller character
|
|
2081
|
+
genex explore "grass"
|
|
1984
2082
|
`;
|
|
1985
2083
|
function parseArgs(argv) {
|
|
1986
2084
|
const parsed = {
|
|
@@ -2001,7 +2099,10 @@ function parseArgs(argv) {
|
|
|
2001
2099
|
"--description",
|
|
2002
2100
|
"--categories",
|
|
2003
2101
|
"--timeout",
|
|
2004
|
-
"--duration"
|
|
2102
|
+
"--duration",
|
|
2103
|
+
"--source-repo-url",
|
|
2104
|
+
"--source-author",
|
|
2105
|
+
"--license"
|
|
2005
2106
|
]);
|
|
2006
2107
|
let i = 0;
|
|
2007
2108
|
while (i < argv.length) {
|
|
@@ -2040,6 +2141,9 @@ function parseArgs(argv) {
|
|
|
2040
2141
|
case "--quiet":
|
|
2041
2142
|
parsed.options.quiet = true;
|
|
2042
2143
|
break;
|
|
2144
|
+
case "--json":
|
|
2145
|
+
parsed.options.json = true;
|
|
2146
|
+
break;
|
|
2043
2147
|
default: {
|
|
2044
2148
|
if (needsValue.has(arg)) {
|
|
2045
2149
|
const value = argv[i++];
|
|
@@ -2055,6 +2159,8 @@ function parseArgs(argv) {
|
|
|
2055
2159
|
parsed.command = arg;
|
|
2056
2160
|
} else if (parsed.options.name === void 0) {
|
|
2057
2161
|
parsed.options.name = arg;
|
|
2162
|
+
} else if (parsed.command === "explore") {
|
|
2163
|
+
parsed.options.name = `${parsed.options.name} ${arg}`;
|
|
2058
2164
|
} else {
|
|
2059
2165
|
parsed.error = `Unexpected argument: ${arg}`;
|
|
2060
2166
|
return parsed;
|
|
@@ -2097,6 +2203,15 @@ function applyValueFlag(options, flag, value) {
|
|
|
2097
2203
|
case "--categories":
|
|
2098
2204
|
options.categories = value.split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
|
|
2099
2205
|
break;
|
|
2206
|
+
case "--source-repo-url":
|
|
2207
|
+
options.sourceRepoUrl = value;
|
|
2208
|
+
break;
|
|
2209
|
+
case "--source-author":
|
|
2210
|
+
options.sourceAuthor = value;
|
|
2211
|
+
break;
|
|
2212
|
+
case "--license":
|
|
2213
|
+
options.license = value;
|
|
2214
|
+
break;
|
|
2100
2215
|
case "--timeout": {
|
|
2101
2216
|
const n = Number(value);
|
|
2102
2217
|
if (!Number.isFinite(n) || n <= 0) {
|
|
@@ -2162,6 +2277,9 @@ async function main() {
|
|
|
2162
2277
|
case "publish":
|
|
2163
2278
|
await runPublish(parsed.options);
|
|
2164
2279
|
break;
|
|
2280
|
+
case "explore":
|
|
2281
|
+
await runExplore({ ...parsed.options, query: parsed.options.name });
|
|
2282
|
+
break;
|
|
2165
2283
|
default:
|
|
2166
2284
|
log.error(`Unknown command: ${parsed.command}`);
|
|
2167
2285
|
log.plain(`Run ${c.cyan("genex --help")} for usage.`);
|
package/package.json
CHANGED
|
@@ -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.
|
|
@@ -32,25 +32,33 @@ lives under `public/` so Vite ships it in the build (load it as `./assets/models
|
|
|
32
32
|
the `public/` prefix is stripped when served). It is **committed by `npx genex publish`**,
|
|
33
33
|
so it ships inside your published game.
|
|
34
34
|
|
|
35
|
-
##
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
- **
|
|
44
|
-
|
|
45
|
-
|
|
35
|
+
## Models that point somewhere must face forward
|
|
36
|
+
|
|
37
|
+
Any model with a "front" the game aims — the hero the player controls, an **NPC that
|
|
38
|
+
walks toward or looks at players**, a turret, a vehicle — should face **forward**: its
|
|
39
|
+
front toward `+Z` (glTF's "looking forward"), so yaw/`lookAt` code points it correctly.
|
|
40
|
+
A hero whose nose points sideways — or an enemy that chases you while staring off to the
|
|
41
|
+
side — reads as broken at a glance.
|
|
42
|
+
|
|
43
|
+
- **In the prompt:** ask for it — e.g. `"...game-ready, facing forward, front toward +Z"`.
|
|
44
|
+
Treat this as a hint, not a guarantee: generated meshes regularly come back rotated
|
|
45
|
+
anyway, so the check below is never optional.
|
|
46
|
+
- **On load (always check — hero AND NPC):** if the GLB isn't `+Z`-forward, rotate it
|
|
47
|
+
once. Wrap the model in a parent `Object3D` and put the correction on the child, so
|
|
48
|
+
game code rotates the parent cleanly:
|
|
46
49
|
```ts
|
|
47
|
-
const
|
|
50
|
+
const rig = new THREE.Object3D();
|
|
48
51
|
gltf.scene.rotation.y = Math.PI / 2; // one-time facing correction — tune per model
|
|
49
|
-
|
|
50
|
-
scene.add(
|
|
52
|
+
rig.add(gltf.scene);
|
|
53
|
+
scene.add(rig); // move/rotate/lookAt `rig`, not gltf.scene
|
|
51
54
|
```
|
|
52
|
-
|
|
53
|
-
|
|
55
|
+
Clones inherit the fix only if you clone the corrected child (or the whole rig) —
|
|
56
|
+
never the raw `gltf.scene`.
|
|
57
|
+
- **Verify it, don't assume it.** Orientation **is** visible in a still — in your
|
|
58
|
+
self-check screenshot confirm the hero faces its travel direction AND that NPCs driven
|
|
59
|
+
by chase/aim code face their target (an enemy rotated 90° from its victim is this
|
|
60
|
+
pipeline's most common visible bug). If you can't capture real gameplay (a draft's
|
|
61
|
+
sign-in gate is up), say so plainly instead of skipping the check silently.
|
|
54
62
|
|
|
55
63
|
## Load it into the scene
|
|
56
64
|
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: genex-explore
|
|
3
|
+
description: Search the curated Genex community gallery with `npx genex explore` before hand-writing hard visual or physics systems. Use when the game needs terrain, grass, water, vehicles, buildings, dungeons, flocking/boids, or advanced shader work — proven open-source Three.js implementations you can clone as a starting point or borrow parts from, credits included.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Genex Explore — proven building blocks from the community gallery
|
|
7
|
+
|
|
8
|
+
Some systems are notoriously hard to get right from scratch: believable grass,
|
|
9
|
+
vehicle physics, procedural buildings and dungeons, fish/boid flocking, water,
|
|
10
|
+
advanced shader effects. The curated community gallery holds **faithful ports of
|
|
11
|
+
proven open-source Three.js projects** — playable, cloneable, and licensed for
|
|
12
|
+
reuse. Search it before hand-writing one of those systems.
|
|
13
|
+
|
|
14
|
+
## When to use
|
|
15
|
+
|
|
16
|
+
Before building a hard visual/physics system by hand, run:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npx genex explore "<what you need>"
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Examples: `npx genex explore "grass"`, `npx genex explore "vehicle physics"`,
|
|
23
|
+
`npx genex explore "procedural building"`. No sign-in needed — this works even
|
|
24
|
+
before `genex init`.
|
|
25
|
+
|
|
26
|
+
Each result prints everything you need to act on it:
|
|
27
|
+
|
|
28
|
+
- `play:` — open it in the browser to judge whether it fits.
|
|
29
|
+
- `clone:` — the public repo with **editable source on `main`**.
|
|
30
|
+
- `upstream:` — the original source repo, with its author and license.
|
|
31
|
+
|
|
32
|
+
Add `--json` for machine-readable output.
|
|
33
|
+
|
|
34
|
+
## How to integrate a result
|
|
35
|
+
|
|
36
|
+
**As a NEW game (start from the whole project):**
|
|
37
|
+
|
|
38
|
+
1. `git clone <clone URL from the output> <name>` — pick a short one-word name.
|
|
39
|
+
2. `cd <name>`, then `npx @genex-ai/cli-demo@latest init <name>` — choose
|
|
40
|
+
"Ignore files and continue" if it warns the folder isn't empty; never use
|
|
41
|
+
`--force`. This creates your own project; the original is untouched.
|
|
42
|
+
3. `npm install`, keep `base: './'` in `vite.config`, then build your changes
|
|
43
|
+
and ship with `npx genex preview`.
|
|
44
|
+
|
|
45
|
+
**To BORROW parts into the current project:**
|
|
46
|
+
|
|
47
|
+
1. Clone the result somewhere temporary, separate from your project
|
|
48
|
+
(e.g. `/tmp/genex-explore-src`).
|
|
49
|
+
2. Study how it implements the part you need, then bring **just that** over —
|
|
50
|
+
real code and assets, adapted to your file paths. Don't wholesale-overwrite
|
|
51
|
+
your game.
|
|
52
|
+
3. Reused assets must live under `public/assets/` to ship. New assets are
|
|
53
|
+
generated as usual with `$genex-ai-model`, `$genex-ai-skybox`,
|
|
54
|
+
`$genex-ai-sfx`, or `$genex-ai-texture`.
|
|
55
|
+
|
|
56
|
+
## Credits rule (non-negotiable)
|
|
57
|
+
|
|
58
|
+
Every curated result carries an upstream link, author, and license — that
|
|
59
|
+
credit is part of the deal that makes these projects reusable. Whatever you
|
|
60
|
+
build from one:
|
|
61
|
+
|
|
62
|
+
- keep the attribution block in the README (upstream source repo URL, author,
|
|
63
|
+
license) exactly as the port carries it;
|
|
64
|
+
- keep any in-game credits screens or notices in place;
|
|
65
|
+
- carry the credit into anything you publish or remix from it.
|
|
66
|
+
|
|
67
|
+
If nothing matches your query, try a broader term (terrain, grass, vehicle,
|
|
68
|
+
water, building, dungeon, fish, shader) — or build it with the regular
|
|
69
|
+
`genex-threejs-*` skills instead.
|
|
@@ -33,6 +33,18 @@ Read [references/camera-rigs.md](references/camera-rigs.md)
|
|
|
33
33
|
for exact chase/side/orbit rigs, projection values, transition
|
|
34
34
|
rules, floating-origin shot, pointer controls, and implementation limits.
|
|
35
35
|
|
|
36
|
+
## Aiming and pointer lock
|
|
37
|
+
|
|
38
|
+
Camera-aimed action — a third-person shooter reticle, first-person look —
|
|
39
|
+
wants **pointer lock**, not drag-orbit: request it on canvas click
|
|
40
|
+
(`canvas.requestPointerLock()`), drive yaw/pitch from `mousemove` deltas while
|
|
41
|
+
locked, and treat lock loss (Esc) as aim-paused — show a small "click to aim"
|
|
42
|
+
hint whenever unlocked. Keep drag-orbit for non-combat cameras (exploration,
|
|
43
|
+
building, spectating). Pointer lock works everywhere a Genex game runs:
|
|
44
|
+
standalone the game is the top-level page, and the platform's game frame
|
|
45
|
+
already grants the pointer-lock permission — no setup needed. Re-sync
|
|
46
|
+
yaw/pitch from the camera whenever lock is acquired (rule below).
|
|
47
|
+
|
|
36
48
|
## Non-negotiable rules
|
|
37
49
|
|
|
38
50
|
- Use subject dimensions to derive offsets; do not tune one fixed distance for
|
|
@@ -260,6 +260,24 @@ instead. Don't code around any of this: no `?`/`#` URL params of yours will
|
|
|
260
260
|
be affected, and `isEmbedded()` / the return-trip handling are internal SDK
|
|
261
261
|
concerns.
|
|
262
262
|
|
|
263
|
+
### Validating a draft (read before self-testing)
|
|
264
|
+
|
|
265
|
+
An unpublished draft shows the sign-in gate to any browser that isn't signed
|
|
266
|
+
in as the owner — **including your own test browser** (Playwright, headless
|
|
267
|
+
Chrome). The game still boots behind the overlay: console logs, DOM snapshots,
|
|
268
|
+
and key events all work — but every screenshot shows the gate, not the game,
|
|
269
|
+
and a gate capture is NOT visual evidence.
|
|
270
|
+
|
|
271
|
+
- Validate what the gate can't hide: a clean console, the canvas booting, the
|
|
272
|
+
HUD present in a DOM snapshot, controls registering.
|
|
273
|
+
- Do NOT work around the gate: don't dig through the SDK's internals for
|
|
274
|
+
undocumented URL fragments, and don't drive the user's own signed-in
|
|
275
|
+
browser.
|
|
276
|
+
- For the visual pass on a draft, hand it off plainly: "open your draft page
|
|
277
|
+
and tell me what you see" — the owner's view is the real check. Once the
|
|
278
|
+
game is **published**, any fresh browser gets in as a guest, so your own
|
|
279
|
+
test browser works again for full visual validation.
|
|
280
|
+
|
|
263
281
|
## Checklist
|
|
264
282
|
|
|
265
283
|
- [ ] `initGameSentry({ slug: GENEX.slug })` is the very first call in `main.ts`.
|
|
@@ -33,6 +33,10 @@ no matter how good it looks.
|
|
|
33
33
|
itself, or apply them only to hand-rolled movement.)
|
|
34
34
|
- If an action can't fire (cooldown, no ammo), say so instantly — a click, a
|
|
35
35
|
dimmed icon — silence reads as broken input.
|
|
36
|
+
- Camera-aimed shooting wants **pointer lock** (click to aim, Esc releases —
|
|
37
|
+
`$genex-threejs-camera-direction` has the rig rules); firing at a reticle
|
|
38
|
+
with an unlocked drag-to-turn camera feels imprecise no matter how tight
|
|
39
|
+
the numbers are.
|
|
36
40
|
|
|
37
41
|
## Movement: snappy beats realistic
|
|
38
42
|
|
|
@@ -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.
|
|
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(...)`. `
|
|
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.
|
|
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.
|
|
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.
|
|
@@ -32,7 +32,15 @@ this is the whole acceptance gate, and it is also the minimum for every game:
|
|
|
32
32
|
2. Press each documented control once (keys, pointer); assert a **visible
|
|
33
33
|
response** to every one — the player moves, the camera turns, the button
|
|
34
34
|
fires.
|
|
35
|
-
3. Capture one screenshot of live gameplay
|
|
35
|
+
3. Capture one screenshot of live gameplay — of the **game**, not a sign-in
|
|
36
|
+
gate or loading screen. A capture of the SDK's "Sign in to play" overlay is
|
|
37
|
+
NOT gameplay evidence; if a draft's gate blocks the view, say so plainly
|
|
38
|
+
(see the embed-auth skill's "Validating a draft" note) instead of passing
|
|
39
|
+
the capture off as validation.
|
|
40
|
+
4. In that screenshot, check oriented models: the hero faces its travel
|
|
41
|
+
direction, and NPCs driven by chase/aim code face their target. A model
|
|
42
|
+
rotated 90° reads as broken — `$genex-ai-model` has the one-time facing
|
|
43
|
+
fix.
|
|
36
44
|
|
|
37
45
|
Everything deeper (baselines, seed sweeps, mosaics, budgets) belongs to
|
|
38
46
|
visual-system work — the sequence above.
|