@genex-ai/cli-demo 0.4.0 → 0.6.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 +68 -25
- package/package.json +1 -1
- package/templates/skills/genex-ai-model/SKILL.md +26 -3
- package/templates/skills/genex-ai-sfx/SKILL.md +3 -2
- package/templates/skills/genex-ai-skybox/SKILL.md +3 -2
- package/templates/skills/genex-ai-texture/SKILL.md +3 -2
- package/templates/skills/genex-threejs-multiplayer/SKILL.md +167 -0
- package/templates/skills/genex-threejs-multiplayer/references/realtime-patterns.md +202 -0
- package/templates/skills/genex-threejs-skill-router/SKILL.md +19 -0
- package/templates/skills/genex-threejs-skill-router/references/routing-map.md +8 -0
package/dist/index.js
CHANGED
|
@@ -816,6 +816,9 @@ async function deployGame(sshUrl, opts, log) {
|
|
|
816
816
|
});
|
|
817
817
|
if (push.code === 0) {
|
|
818
818
|
log.success("Pushed.");
|
|
819
|
+
if (opts.playUrl) {
|
|
820
|
+
await waitUntilLive(opts.playUrl, await buildFingerprint(siteDir), opts.liveTimeoutMs ?? 12e4, log);
|
|
821
|
+
}
|
|
819
822
|
return true;
|
|
820
823
|
}
|
|
821
824
|
log.warn("git push failed \u2014 your live game was NOT updated.");
|
|
@@ -850,6 +853,41 @@ async function fileExists(p) {
|
|
|
850
853
|
return false;
|
|
851
854
|
}
|
|
852
855
|
}
|
|
856
|
+
var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
857
|
+
async function buildFingerprint(siteDir) {
|
|
858
|
+
let html;
|
|
859
|
+
try {
|
|
860
|
+
html = await fs6.readFile(path7.join(siteDir, "index.html"), "utf8");
|
|
861
|
+
} catch {
|
|
862
|
+
return null;
|
|
863
|
+
}
|
|
864
|
+
const m = html.match(/[A-Za-z0-9_]+-[A-Za-z0-9_-]{8}\.(?:js|css)/);
|
|
865
|
+
return m ? m[0] : null;
|
|
866
|
+
}
|
|
867
|
+
async function waitUntilLive(playUrl, fingerprint, timeoutMs, log) {
|
|
868
|
+
if (!fingerprint) {
|
|
869
|
+
log.dim(` ${playUrl}`);
|
|
870
|
+
log.dim(" (GitHub Pages rebuilds ~30\u201390s after a push.)");
|
|
871
|
+
return;
|
|
872
|
+
}
|
|
873
|
+
log.step("Waiting for GitHub Pages to serve the new build\u2026");
|
|
874
|
+
const deadline = Date.now() + timeoutMs;
|
|
875
|
+
let n = 0;
|
|
876
|
+
while (Date.now() < deadline) {
|
|
877
|
+
try {
|
|
878
|
+
const res = await fetch(`${playUrl}?_genexlive=${n++}`, { cache: "no-store" });
|
|
879
|
+
if (res.ok && (await res.text()).includes(fingerprint)) {
|
|
880
|
+
log.success(`Live. \u{1F310} ${playUrl}`);
|
|
881
|
+
return;
|
|
882
|
+
}
|
|
883
|
+
} catch {
|
|
884
|
+
}
|
|
885
|
+
await sleep(4e3);
|
|
886
|
+
}
|
|
887
|
+
log.warn("Still publishing \u2014 GitHub Pages can take a bit longer.");
|
|
888
|
+
log.dim(` ${playUrl}`);
|
|
889
|
+
log.dim(" (Open it in ~30\u201360s; the new build is on its way.)");
|
|
890
|
+
}
|
|
853
891
|
async function warnIfAbsolutePaths(siteDir, log) {
|
|
854
892
|
let html;
|
|
855
893
|
try {
|
|
@@ -884,7 +922,7 @@ async function runPublish(opts) {
|
|
|
884
922
|
if (opts.noPush) {
|
|
885
923
|
log.info("Skipping build + git push (--no-push).");
|
|
886
924
|
} else {
|
|
887
|
-
pushed = await deployGame(meta.sshUrl, { noBuild: opts.noBuild }, log);
|
|
925
|
+
pushed = await deployGame(meta.sshUrl, { noBuild: opts.noBuild, playUrl: meta.playUrl }, log);
|
|
888
926
|
}
|
|
889
927
|
log.step("Listing it in the gallery\u2026");
|
|
890
928
|
let res;
|
|
@@ -918,7 +956,6 @@ async function runPublish(opts) {
|
|
|
918
956
|
}
|
|
919
957
|
if (meta.playUrl) {
|
|
920
958
|
log.dim(` play: ${meta.playUrl}`);
|
|
921
|
-
if (pushed === true) log.dim(" (GitHub Pages rebuilds ~30\u201390s after a push.)");
|
|
922
959
|
}
|
|
923
960
|
}
|
|
924
961
|
|
|
@@ -933,18 +970,18 @@ async function runPreview(opts) {
|
|
|
933
970
|
process.exitCode = 1;
|
|
934
971
|
return;
|
|
935
972
|
}
|
|
936
|
-
const pushed = await deployGame(
|
|
973
|
+
const pushed = await deployGame(
|
|
974
|
+
meta.sshUrl,
|
|
975
|
+
{ noBuild: opts.noBuild, playUrl: meta.playUrl },
|
|
976
|
+
log
|
|
977
|
+
);
|
|
937
978
|
log.plain("");
|
|
938
979
|
if (!pushed) {
|
|
939
980
|
log.warn("Preview not updated \u2014 see the error above.");
|
|
940
981
|
process.exitCode = 1;
|
|
941
982
|
return;
|
|
942
983
|
}
|
|
943
|
-
log.success("Preview deployed
|
|
944
|
-
if (meta.playUrl) {
|
|
945
|
-
log.dim(` ${meta.playUrl}`);
|
|
946
|
-
log.dim(" (GitHub Pages rebuilds ~30\u201390s after a push; it stays an unlisted draft until you publish.)");
|
|
947
|
-
}
|
|
984
|
+
log.success("Preview deployed \u2014 unlisted draft (run `genex publish` to list it).");
|
|
948
985
|
}
|
|
949
986
|
|
|
950
987
|
// src/commands/generate.ts
|
|
@@ -967,17 +1004,21 @@ async function downloadToFile(url, dest, headers) {
|
|
|
967
1004
|
}
|
|
968
1005
|
|
|
969
1006
|
// src/commands/generate.ts
|
|
1007
|
+
var PUBLIC_PREFIX = "public/";
|
|
970
1008
|
var KIND_DIR = {
|
|
971
|
-
model: "assets/models",
|
|
972
|
-
skybox: "assets/skybox",
|
|
973
|
-
sfx: "assets/sfx",
|
|
974
|
-
texture: "assets/textures"
|
|
1009
|
+
model: "public/assets/models",
|
|
1010
|
+
skybox: "public/assets/skybox",
|
|
1011
|
+
sfx: "public/assets/sfx",
|
|
1012
|
+
texture: "public/assets/textures"
|
|
975
1013
|
};
|
|
976
|
-
|
|
1014
|
+
function runtimePath(rel) {
|
|
1015
|
+
return `./${rel.replace(PUBLIC_PREFIX, "")}`;
|
|
1016
|
+
}
|
|
1017
|
+
var sleep2 = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
977
1018
|
function fileDest(kind, slug, f) {
|
|
978
1019
|
if (kind === "texture") {
|
|
979
1020
|
const name = f.role.replace(/^texture-/, "");
|
|
980
|
-
return `assets/textures/${slug}/${name}.${f.ext}`;
|
|
1021
|
+
return `public/assets/textures/${slug}/${name}.${f.ext}`;
|
|
981
1022
|
}
|
|
982
1023
|
return `${KIND_DIR[kind]}/${slug}.${f.ext}`;
|
|
983
1024
|
}
|
|
@@ -1082,19 +1123,21 @@ async function poll(apiUrl, token, id, onProgress) {
|
|
|
1082
1123
|
}
|
|
1083
1124
|
} catch {
|
|
1084
1125
|
}
|
|
1085
|
-
await
|
|
1126
|
+
await sleep2(3e3);
|
|
1086
1127
|
}
|
|
1087
1128
|
return null;
|
|
1088
1129
|
}
|
|
1089
1130
|
function printHint(kind, written, log) {
|
|
1090
|
-
const
|
|
1131
|
+
const load = runtimePath(written[0] ?? "");
|
|
1091
1132
|
const hint = {
|
|
1092
|
-
model: `Load with GLTFLoader and add to the scene \u2014 see the genex-ai-model skill. Use the relative path "
|
|
1093
|
-
skybox: `Load as an equirectangular texture \u2192 scene.background + scene.environment \u2014 see genex-ai-skybox. Path "
|
|
1094
|
-
sfx: `Load with AudioLoader into a THREE.PositionalAudio (camera needs an AudioListener) \u2014 see genex-ai-sfx. Path "
|
|
1095
|
-
texture: `Load with TextureLoader, set RepeatWrapping, build a MeshStandardMaterial \u2014 see genex-ai-texture. Path "
|
|
1133
|
+
model: `Load with GLTFLoader and add to the scene \u2014 see the genex-ai-model skill. Use the relative path "${load}".`,
|
|
1134
|
+
skybox: `Load as an equirectangular texture \u2192 scene.background + scene.environment \u2014 see genex-ai-skybox. Path "${load}".`,
|
|
1135
|
+
sfx: `Load with AudioLoader into a THREE.PositionalAudio (camera needs an AudioListener) \u2014 see genex-ai-sfx. Path "${load}".`,
|
|
1136
|
+
texture: `Load with TextureLoader, set RepeatWrapping, build a MeshStandardMaterial \u2014 see genex-ai-texture. Path "${load}".`
|
|
1096
1137
|
};
|
|
1097
|
-
log.success(
|
|
1138
|
+
log.success(
|
|
1139
|
+
"Done. Asset saved into public/assets (so Vite ships it; load it as ./assets/\u2026 \u2014 `genex publish` commits and ships it with the game)."
|
|
1140
|
+
);
|
|
1098
1141
|
log.dim(` ${hint[kind]}`);
|
|
1099
1142
|
}
|
|
1100
1143
|
|
|
@@ -1117,10 +1160,10 @@ ${c.bold("Usage")}
|
|
|
1117
1160
|
genex init [<name>] [options] Scaffold + authorize + create the draft project.
|
|
1118
1161
|
genex preview [options] Build + push to the hosted draft URL (unlisted).
|
|
1119
1162
|
genex publish [options] Build + push, then list the game in the gallery.
|
|
1120
|
-
genex model "<prompt>" [options] Generate a 3D model (GLB) into
|
|
1121
|
-
genex skybox "<prompt>" [options] Generate a skybox (equirect) into
|
|
1122
|
-
genex sfx "<prompt>" [options] Generate a sound effect (mp3) into
|
|
1123
|
-
genex texture "<prompt>" [options] Generate a PBR texture into
|
|
1163
|
+
genex model "<prompt>" [options] Generate a 3D model (GLB) into public/assets/models.
|
|
1164
|
+
genex skybox "<prompt>" [options] Generate a skybox (equirect) into public/assets/skybox.
|
|
1165
|
+
genex sfx "<prompt>" [options] Generate a sound effect (mp3) into public/assets/sfx.
|
|
1166
|
+
genex texture "<prompt>" [options] Generate a PBR texture into public/assets/textures.
|
|
1124
1167
|
|
|
1125
1168
|
${c.bold("Options for the generators (`model` `skybox` `sfx` `texture`)")}
|
|
1126
1169
|
--terrain (texture) seamless tiling surface for terrain/ground.
|
package/package.json
CHANGED
|
@@ -24,11 +24,33 @@ Write a specific prompt — "weathered wooden barrel with rusted iron bands" bea
|
|
|
24
24
|
"barrel". The command blocks until the mesh is ready (up to ~a minute), then saves:
|
|
25
25
|
|
|
26
26
|
```
|
|
27
|
-
assets/models/<slug>.glb
|
|
27
|
+
public/assets/models/<slug>.glb
|
|
28
28
|
```
|
|
29
29
|
|
|
30
30
|
`<slug>` is derived from the prompt (e.g. `weathered-wooden-barrel.glb`). The file
|
|
31
|
-
|
|
31
|
+
lives under `public/` so Vite ships it in the build (load it as `./assets/models/...` —
|
|
32
|
+
the `public/` prefix is stripped when served). It is **committed by `npx genex publish`**,
|
|
33
|
+
so it ships inside your published game.
|
|
34
|
+
|
|
35
|
+
## Hero models must face forward
|
|
36
|
+
|
|
37
|
+
For the model the player controls (a ship, car, character — the "hero"), the mesh should
|
|
38
|
+
**face forward**: its front pointing down the direction of travel (conventionally `+Z`,
|
|
39
|
+
i.e. "looking forward"). A hero whose nose points sideways reads as broken at a glance.
|
|
40
|
+
|
|
41
|
+
- **In the prompt:** when it's the player's craft, ask for it facing forward — e.g.
|
|
42
|
+
`"...game-ready, facing forward, front toward +Z"`.
|
|
43
|
+
- **On load (fallback):** if the imported GLB still isn't forward, rotate it once so its
|
|
44
|
+
nose aligns with travel. Wrap the model in a parent `Object3D` and apply the correction
|
|
45
|
+
to the child, so your movement code can rotate the parent cleanly:
|
|
46
|
+
```ts
|
|
47
|
+
const hero = new THREE.Object3D();
|
|
48
|
+
gltf.scene.rotation.y = Math.PI / 2; // one-time facing correction — tune per model
|
|
49
|
+
hero.add(gltf.scene);
|
|
50
|
+
scene.add(hero); // move/rotate `hero`, not gltf.scene
|
|
51
|
+
```
|
|
52
|
+
- Orientation **is** visible in a still — confirm the hero faces forward in your
|
|
53
|
+
self-check screenshot before moving on.
|
|
32
54
|
|
|
33
55
|
## Load it into the scene
|
|
34
56
|
|
|
@@ -55,7 +77,8 @@ To place many copies, `model.clone()` per instance. For animated GLBs, drive
|
|
|
55
77
|
(`/assets/...`) — GitHub Pages serves the game at a subpath.
|
|
56
78
|
- Set `base: "./"` in `vite.config.ts` before `npm run build` (see the scaffold
|
|
57
79
|
prompt's publish step).
|
|
58
|
-
-
|
|
80
|
+
- Keep generated assets under `public/assets/` — Vite only ships files under `public/`
|
|
81
|
+
(or files you `import`); `npx genex publish` commits and pushes that folder with the game.
|
|
59
82
|
|
|
60
83
|
## Options
|
|
61
84
|
|
|
@@ -18,7 +18,7 @@ npx genex sfx "punchy laser zap, short and dry" --duration 2
|
|
|
18
18
|
Blocks until ready, then saves:
|
|
19
19
|
|
|
20
20
|
```
|
|
21
|
-
assets/sfx/<slug>.mp3
|
|
21
|
+
public/assets/sfx/<slug>.mp3
|
|
22
22
|
```
|
|
23
23
|
|
|
24
24
|
Committed by `npx genex publish`, so it ships with your game.
|
|
@@ -55,7 +55,8 @@ Reuse one loaded `buffer` across many plays; create a fresh `Audio`/`PositionalA
|
|
|
55
55
|
|
|
56
56
|
## Publish checklist
|
|
57
57
|
|
|
58
|
-
- Relative path `./assets/sfx/...`; `base: "./"` in `vite.config.ts`;
|
|
58
|
+
- Relative path `./assets/sfx/...`; `base: "./"` in `vite.config.ts`; files live under
|
|
59
|
+
`public/assets/` so Vite ships them; `npx genex publish` commits and pushes them.
|
|
59
60
|
|
|
60
61
|
## Troubleshooting
|
|
61
62
|
|
|
@@ -26,7 +26,7 @@ npx genex skybox "<prompt>"
|
|
|
26
26
|
Blocks until ready, then saves:
|
|
27
27
|
|
|
28
28
|
```
|
|
29
|
-
assets/skybox/<slug>.jpg
|
|
29
|
+
public/assets/skybox/<slug>.jpg
|
|
30
30
|
```
|
|
31
31
|
|
|
32
32
|
The JPG is **committed by `npx genex publish`**, so it ships with your game.
|
|
@@ -63,7 +63,8 @@ texture.dispose; // (dispose the PMREM source later if you stop using it)
|
|
|
63
63
|
|
|
64
64
|
- Use the **relative** path `./assets/skybox/...` (GitHub Pages serves at a subpath).
|
|
65
65
|
- `base: "./"` in `vite.config.ts` before `npm run build`.
|
|
66
|
-
-
|
|
66
|
+
- Generated files live under `public/assets/` so Vite ships them; `npx genex publish`
|
|
67
|
+
commits and pushes that folder with the game.
|
|
67
68
|
|
|
68
69
|
## Troubleshooting
|
|
69
70
|
|
|
@@ -27,7 +27,7 @@ npx genex texture "lush green grass" --terrain # seamless tiling for ground/t
|
|
|
27
27
|
Blocks until ready, then saves:
|
|
28
28
|
|
|
29
29
|
```
|
|
30
|
-
assets/textures/<slug>/basecolor.<ext>
|
|
30
|
+
public/assets/textures/<slug>/basecolor.<ext>
|
|
31
31
|
```
|
|
32
32
|
|
|
33
33
|
Committed by `npx genex publish`, so it ships with your game.
|
|
@@ -67,7 +67,8 @@ scene.add(ground);
|
|
|
67
67
|
|
|
68
68
|
## Publish checklist
|
|
69
69
|
|
|
70
|
-
- Relative path `./assets/textures/...`; `base: "./"` in `vite.config.ts`;
|
|
70
|
+
- Relative path `./assets/textures/...`; `base: "./"` in `vite.config.ts`; files live under
|
|
71
|
+
`public/assets/` so Vite ships them; `npx genex publish` commits and pushes them.
|
|
71
72
|
|
|
72
73
|
## Troubleshooting
|
|
73
74
|
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: genex-threejs-multiplayer
|
|
3
|
+
description: Implement realtime multiplayer for Genex Three.js games with `@genex-ai/multiplayer`. Use whenever 2+ players share a world — movement sync, shared scores/rounds, shots/emotes, presence, and persistent worlds. MANDATORY whenever a game has multiplayer: load this before writing any networking code.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Genex Three.js Multiplayer
|
|
7
|
+
|
|
8
|
+
`@genex-ai/multiplayer` is a **relay**: whatever you write to `me` or `shared` is
|
|
9
|
+
synced to everyone; everything else stays local. The server runs **no physics, no
|
|
10
|
+
prediction, and no interpolation** — you run your own game logic and add your own
|
|
11
|
+
smoothing. This skill covers the API, the smoothing you must add yourself
|
|
12
|
+
(interpolation + prediction), config wiring, and persistent worlds.
|
|
13
|
+
|
|
14
|
+
**This skill is mandatory for any multiplayer game.** Load it before you write a
|
|
15
|
+
single line of networking code. Naïvely snapping every remote player to their last
|
|
16
|
+
raw network position looks terrible — interpolation/prediction are not optional polish.
|
|
17
|
+
|
|
18
|
+
Read [references/realtime-patterns.md](references/realtime-patterns.md) for the
|
|
19
|
+
copy-paste `RemoteInterpolator` + local-prediction helpers and the persistence/config code.
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npm i @genex-ai/multiplayer
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Connect
|
|
28
|
+
|
|
29
|
+
Pick your own per-player state shape (any JSON). `room` is the **project slug**
|
|
30
|
+
(printed by `genex init`) — same id = same room, different ids are fully isolated.
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
import { connect } from "@genex-ai/multiplayer";
|
|
34
|
+
|
|
35
|
+
type State = { x: number; z: number; yaw: number }; // YOUR per-player state
|
|
36
|
+
|
|
37
|
+
const room = await connect<State>({
|
|
38
|
+
url: GENEX.colyseusUrl, // e.g. "wss://demo-colyseus.glotech.world" — see config wiring below
|
|
39
|
+
room: GENEX.slug, // the project slug — everyone with this id shares a room
|
|
40
|
+
name: "ada", // optional display name
|
|
41
|
+
});
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## API surface (exact — do not invent methods)
|
|
45
|
+
|
|
46
|
+
- `room.id` — your own session id.
|
|
47
|
+
- `room.me.set(state)` — publish your state. **Replaces it wholesale** (send the full
|
|
48
|
+
object, not a partial). Call on a **fixed 10–20 Hz tick**, never per render frame —
|
|
49
|
+
one `set` = one network message.
|
|
50
|
+
- `room.players` — a **fresh `Map` each read**, and it **includes you**. Skip yourself
|
|
51
|
+
with `if (id === room.id) continue;`. Each value is `{ id, name, state }`.
|
|
52
|
+
- `room.shared.get(key)` / `room.shared.set(key, value)` / `room.shared.keys()` —
|
|
53
|
+
a key/value store (any JSON) synced to everyone. Use for world state, scores, round.
|
|
54
|
+
- `room.on(event, cb)` → returns an **unsubscribe** function. Events: `'join'` /
|
|
55
|
+
`'change'` `(id, state)` (both also fire for **you** on connect — filter `id === room.id`),
|
|
56
|
+
`'leave'` `(id)`, `'shared'` `(key, value)`, and any custom event name from `send`.
|
|
57
|
+
- `room.send(type, payload)` — fire-and-forget to all **other** clients, not stored in
|
|
58
|
+
state. Use for shots, emotes, chat, pings.
|
|
59
|
+
- `room.leave()` — leave the room.
|
|
60
|
+
|
|
61
|
+
## The loop you must build (input → local → tick → render)
|
|
62
|
+
|
|
63
|
+
1. **Input mutates a local object only** (`me.x += …`). Never network on keypress.
|
|
64
|
+
2. **A fixed tick publishes it:** `setInterval(() => room.me.set(me), 66)` (~15 Hz).
|
|
65
|
+
3. **Render at your own framerate**, drawing:
|
|
66
|
+
- **yourself** from your *local predicted* state (zero latency — never from the
|
|
67
|
+
echoed server copy of you), and
|
|
68
|
+
- **every other player** through an **interpolator** (render ~100 ms in the past and
|
|
69
|
+
lerp between their last two snapshots) — never snap to raw network state.
|
|
70
|
+
4. **Create-or-reuse one mesh per id**; remove a player's mesh on `'leave'`.
|
|
71
|
+
|
|
72
|
+
See [references/realtime-patterns.md](references/realtime-patterns.md) for the ready-made
|
|
73
|
+
`RemoteInterpolator` helper and a complete movement example.
|
|
74
|
+
|
|
75
|
+
## Smoothness is human-tested — you cannot see it
|
|
76
|
+
|
|
77
|
+
Lag, stutter, and jitter are *motion over time*. A screenshot is one frozen instant, so
|
|
78
|
+
**you cannot tell from a screenshot (or any still capture) whether remote movement feels
|
|
79
|
+
smooth.** Don't try. Trying to "verify smoothness" from captures leads to blind parameter
|
|
80
|
+
tuning, regressions, and reverts.
|
|
81
|
+
|
|
82
|
+
Your job is bounded:
|
|
83
|
+
|
|
84
|
+
1. **Use the reference `RemoteInterpolator` as written.** Render-delay + lerp between the
|
|
85
|
+
last two snapshots, with a clamp (no extrapolation). Don't hand-roll your own smoother.
|
|
86
|
+
2. **Verify it *runs*, not that it *feels good*:** two clients, two distinct meshes, both
|
|
87
|
+
move, no console errors, each player sees the other. That's all a capture can prove.
|
|
88
|
+
3. **Then hand the feel off to the human.** Say plainly: *"Multiplayer smoothness depends on
|
|
89
|
+
your network and can only be felt by a person — open it in two tabs or with a friend and
|
|
90
|
+
tell me how it feels."* Stop there; don't keep tuning on speculation.
|
|
91
|
+
|
|
92
|
+
**Do NOT add velocity extrapolation, "keep gliding," dead-reckoning, or prediction for
|
|
93
|
+
*remote* players** until a human reports a specific symptom. The reference deliberately
|
|
94
|
+
clamps instead of extrapolating — extrapolation overshoots on turns and looks worse, which
|
|
95
|
+
is exactly the trap to avoid.
|
|
96
|
+
|
|
97
|
+
### When the human reports a problem, match the symptom (don't guess)
|
|
98
|
+
|
|
99
|
+
- **"My own plane feels laggy."** You're rendering yourself from the echoed server copy.
|
|
100
|
+
Render *yourself* from your local predicted object instead (zero latency).
|
|
101
|
+
- **"Other players teleport / stutter / move in steps."** Snapping to raw state, or
|
|
102
|
+
`RENDER_DELAY_MS` too low for the jitter. Confirm remotes go through the interpolator,
|
|
103
|
+
then raise `RENDER_DELAY_MS` (e.g. 100 → 150).
|
|
104
|
+
- **"Other players rubber-band / float past corners / overshoot."** That's extrapolation or
|
|
105
|
+
raw network round-trip latency — *more smoothing won't fix it.* Remove any extrapolation
|
|
106
|
+
and accept the small honest delay; true RTT is not a client-side bug.
|
|
107
|
+
- **"Everyone lags equally, including stationary objects."** Likely `me.set` per frame
|
|
108
|
+
flooding the relay — move publishing to the fixed 10–20 Hz tick.
|
|
109
|
+
|
|
110
|
+
## Config wiring (required — the browser can't read `.genex/project.json`)
|
|
111
|
+
|
|
112
|
+
The game runs in the browser; `.genex/project.json` (which holds `slug`, `colyseusUrl`,
|
|
113
|
+
`apiUrl`) is gitignored and not bundled. Surface those values to the client explicitly —
|
|
114
|
+
e.g. a tiny committed `src/genex.config.ts`:
|
|
115
|
+
|
|
116
|
+
```ts
|
|
117
|
+
// src/genex.config.ts — values from `.genex/project.json` (printed by `genex init`)
|
|
118
|
+
export const GENEX = {
|
|
119
|
+
slug: "my-game-slug",
|
|
120
|
+
colyseusUrl: "wss://demo-colyseus.glotech.world",
|
|
121
|
+
apiUrl: "https://demo-api.glotech.world",
|
|
122
|
+
} as const;
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Read `.genex/project.json` once and fill these in. (A Vite `define` / `.env` with
|
|
126
|
+
`VITE_` vars works too — the point is the values must end up in the built JS.)
|
|
127
|
+
|
|
128
|
+
## Persistent worlds (optional — survives restarts)
|
|
129
|
+
|
|
130
|
+
The relay is in-memory: room state is gone when everyone leaves or the server restarts.
|
|
131
|
+
For a world that persists, save/load one JSON blob keyed by the project slug:
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
// load on boot
|
|
135
|
+
const { data } = await fetch(`${GENEX.apiUrl}/api/projects/${GENEX.slug}/state`).then(r => r.json());
|
|
136
|
+
initWorld(data ?? defaultWorld());
|
|
137
|
+
|
|
138
|
+
// save from ONE authority (e.g. the host client) to avoid races; max 1 MB, last-write-wins
|
|
139
|
+
fetch(`${GENEX.apiUrl}/api/projects/${GENEX.slug}/state`, {
|
|
140
|
+
method: "PUT",
|
|
141
|
+
headers: { "Content-Type": "application/json" },
|
|
142
|
+
body: JSON.stringify(world),
|
|
143
|
+
});
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
`GET` returns `{ data }` (or `{ data: null }` if never saved). It's public (no auth) and
|
|
147
|
+
size-capped at 1 MB. Don't save every frame — debounce, and elect a single writer.
|
|
148
|
+
|
|
149
|
+
## Checklist
|
|
150
|
+
|
|
151
|
+
- [ ] `npm i @genex-ai/multiplayer`, and config values wired into the client build.
|
|
152
|
+
- [ ] `room` is the **project slug**.
|
|
153
|
+
- [ ] `me.set` on a fixed **10–20 Hz** tick (not per frame); full object each time.
|
|
154
|
+
- [ ] Skip yourself in `room.players` (`id === room.id`).
|
|
155
|
+
- [ ] Remote players go through interpolation; **you** render from local predicted state.
|
|
156
|
+
- [ ] Create-or-reuse a mesh per id; remove it on `'leave'`.
|
|
157
|
+
- [ ] If the world should persist, save from one authority via the state API.
|
|
158
|
+
|
|
159
|
+
## Troubleshooting
|
|
160
|
+
|
|
161
|
+
- **Other players stutter / teleport** — you're snapping to raw network state. Use the
|
|
162
|
+
`RemoteInterpolator` (render-delay + lerp).
|
|
163
|
+
- **My own movement feels laggy** — you're rendering yourself from the echoed server
|
|
164
|
+
state. Render yourself from your local predicted object instead.
|
|
165
|
+
- **Too much traffic / desync** — you're calling `me.set` per frame. Move it to the tick.
|
|
166
|
+
- **Players never appear** — `room.players` is empty until the first state patch lands;
|
|
167
|
+
read it in the render loop, and remember it includes you (filter your own id).
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
# Realtime patterns: interpolation, prediction, persistence
|
|
2
|
+
|
|
3
|
+
The `@genex-ai/multiplayer` relay gives you the **latest** state of each player and
|
|
4
|
+
nothing else — no timestamps, no smoothing. These are the pieces you add on top so the
|
|
5
|
+
game feels good. All code is plain TypeScript; nothing here is provided by the package.
|
|
6
|
+
|
|
7
|
+
## Why you need this
|
|
8
|
+
|
|
9
|
+
- You publish `me.set` at ~15 Hz but render at 60 fps. If you snap each remote player to
|
|
10
|
+
their last received position, they move in visible 15 Hz steps and jump on packet
|
|
11
|
+
jitter. **Fix: interpolation** — render each remote player slightly in the past and
|
|
12
|
+
lerp between their two surrounding snapshots.
|
|
13
|
+
- Your *own* avatar would feel laggy if you waited for the server to echo your position
|
|
14
|
+
back. **Fix: prediction** — apply input locally and render yourself from that
|
|
15
|
+
immediately; the tick still publishes it for everyone else.
|
|
16
|
+
|
|
17
|
+
## RemoteInterpolator (render-delay interpolation)
|
|
18
|
+
|
|
19
|
+
Buffers timestamped snapshots per remote player and samples a smoothed value ~100 ms in
|
|
20
|
+
the past. Stamp snapshots with the local clock on arrival (we don't get server time).
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
// interpolation.ts
|
|
24
|
+
const RENDER_DELAY_MS = 100; // render remotes this far in the past; raise if jittery
|
|
25
|
+
const BUFFER_MS = 1000; // keep ~1s of history
|
|
26
|
+
|
|
27
|
+
type Vec = { x: number; y: number; z: number; yaw: number };
|
|
28
|
+
type Snap = { t: number; v: Vec };
|
|
29
|
+
|
|
30
|
+
function lerp(a: number, b: number, k: number) {
|
|
31
|
+
return a + (b - a) * k;
|
|
32
|
+
}
|
|
33
|
+
// shortest-arc angle lerp (radians) — avoids spinning the wrong way across ±π
|
|
34
|
+
function lerpAngle(a: number, b: number, k: number) {
|
|
35
|
+
let d = (b - a) % (Math.PI * 2);
|
|
36
|
+
if (d > Math.PI) d -= Math.PI * 2;
|
|
37
|
+
if (d < -Math.PI) d += Math.PI * 2;
|
|
38
|
+
return a + d * k;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export class RemoteInterpolator {
|
|
42
|
+
private buffers = new Map<string, Snap[]>();
|
|
43
|
+
|
|
44
|
+
/** Call whenever you read a remote player's latest state (e.g. each frame, or on 'change'). */
|
|
45
|
+
push(id: string, v: Vec, now = performance.now()) {
|
|
46
|
+
let buf = this.buffers.get(id);
|
|
47
|
+
if (!buf) this.buffers.set(id, (buf = []));
|
|
48
|
+
const last = buf[buf.length - 1];
|
|
49
|
+
// de-dupe identical repeats (players is re-read every frame)
|
|
50
|
+
if (last && last.v.x === v.x && last.v.z === v.z && last.v.yaw === v.yaw && last.v.y === v.y) return;
|
|
51
|
+
buf.push({ t: now, v });
|
|
52
|
+
const cutoff = now - BUFFER_MS;
|
|
53
|
+
while (buf.length > 2 && buf[0].t < cutoff) buf.shift();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Smoothed value to render this frame, or null if we have nothing yet. */
|
|
57
|
+
sample(id: string, now = performance.now()): Vec | null {
|
|
58
|
+
const buf = this.buffers.get(id);
|
|
59
|
+
if (!buf || buf.length === 0) return null;
|
|
60
|
+
const target = now - RENDER_DELAY_MS;
|
|
61
|
+
if (buf.length === 1 || target <= buf[0].t) return buf[0].v;
|
|
62
|
+
if (target >= buf[buf.length - 1].t) return buf[buf.length - 1].v; // clamp (no extrapolation)
|
|
63
|
+
for (let i = 0; i < buf.length - 1; i++) {
|
|
64
|
+
const a = buf[i], b = buf[i + 1];
|
|
65
|
+
if (target >= a.t && target <= b.t) {
|
|
66
|
+
const k = (target - a.t) / (b.t - a.t || 1);
|
|
67
|
+
return {
|
|
68
|
+
x: lerp(a.v.x, b.v.x, k),
|
|
69
|
+
y: lerp(a.v.y, b.v.y, k),
|
|
70
|
+
z: lerp(a.v.z, b.v.z, k),
|
|
71
|
+
yaw: lerpAngle(a.v.yaw, b.v.yaw, k),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return buf[buf.length - 1].v;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
remove(id: string) {
|
|
79
|
+
this.buffers.delete(id);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
**Tuning:** `RENDER_DELAY_MS` ≈ one tick interval + jitter (100 ms is safe for a 15 Hz
|
|
85
|
+
tick). Lower = more responsive but more stutter on jitter. For sudden teleports (respawn,
|
|
86
|
+
warp) clear that player's buffer and snap, so you don't lerp across the whole map.
|
|
87
|
+
|
|
88
|
+
## Complete movement example
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
import * as THREE from "three";
|
|
92
|
+
import { connect } from "@genex-ai/multiplayer";
|
|
93
|
+
import { GENEX } from "./genex.config";
|
|
94
|
+
import { RemoteInterpolator } from "./interpolation";
|
|
95
|
+
|
|
96
|
+
type S = { x: number; z: number; yaw: number };
|
|
97
|
+
|
|
98
|
+
const room = await connect<S>({ url: GENEX.colyseusUrl, room: GENEX.slug });
|
|
99
|
+
const interp = new RemoteInterpolator();
|
|
100
|
+
|
|
101
|
+
// --- local player: prediction. Input mutates this; we render yourself from it. ---
|
|
102
|
+
const me = { x: 0, z: 0, yaw: 0 };
|
|
103
|
+
addEventListener("keydown", (e) => {
|
|
104
|
+
if (e.key === "ArrowLeft") me.yaw += 0.1;
|
|
105
|
+
if (e.key === "ArrowRight") me.yaw -= 0.1;
|
|
106
|
+
if (e.key === "ArrowUp") { me.x += Math.sin(me.yaw); me.z += Math.cos(me.yaw); }
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// --- publish at ~15 Hz (NOT per frame) ---
|
|
110
|
+
const tick = setInterval(() => room.me.set(me), 66);
|
|
111
|
+
|
|
112
|
+
// --- meshes, one per id ---
|
|
113
|
+
const meshes = new Map<string, THREE.Object3D>();
|
|
114
|
+
function meshFor(id: string) {
|
|
115
|
+
let m = meshes.get(id);
|
|
116
|
+
if (!m) { m = new THREE.Mesh(boxGeo, boxMat); scene.add(m); meshes.set(id, m); }
|
|
117
|
+
return m;
|
|
118
|
+
}
|
|
119
|
+
room.on("leave", (id) => {
|
|
120
|
+
const m = meshes.get(id);
|
|
121
|
+
if (m) { scene.remove(m); meshes.delete(id); }
|
|
122
|
+
interp.remove(id);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
function frame() {
|
|
126
|
+
// yourself: render from local predicted state (zero latency)
|
|
127
|
+
meshFor(room.id).position.set(me.x, 0, me.z);
|
|
128
|
+
meshFor(room.id).rotation.y = me.yaw;
|
|
129
|
+
|
|
130
|
+
// everyone else: feed the interpolator, render the smoothed sample
|
|
131
|
+
for (const [id, p] of room.players) {
|
|
132
|
+
if (id === room.id) continue;
|
|
133
|
+
interp.push(id, { x: p.state.x ?? 0, y: 0, z: p.state.z ?? 0, yaw: p.state.yaw ?? 0 });
|
|
134
|
+
const v = interp.sample(id);
|
|
135
|
+
if (!v) continue;
|
|
136
|
+
const m = meshFor(id);
|
|
137
|
+
m.position.set(v.x, v.y, v.z);
|
|
138
|
+
m.rotation.y = v.yaw;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
renderer.render(scene, camera);
|
|
142
|
+
requestAnimationFrame(frame);
|
|
143
|
+
}
|
|
144
|
+
frame();
|
|
145
|
+
|
|
146
|
+
// cleanup if you ever tear down: clearInterval(tick); room.leave();
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
## Custom events (shots, emotes, chat)
|
|
150
|
+
|
|
151
|
+
For one-off actions that aren't part of continuous state, use `send` — it doesn't belong
|
|
152
|
+
in `me.set` (which is your *current* state, not events):
|
|
153
|
+
|
|
154
|
+
```ts
|
|
155
|
+
// shooter:
|
|
156
|
+
room.send("shot", { from: room.id, x: me.x, z: me.z, yaw: me.yaw });
|
|
157
|
+
|
|
158
|
+
// everyone else:
|
|
159
|
+
room.on("shot", (msg: any) => spawnTracer(msg.x, msg.z, msg.yaw));
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
## Shared room state (scores, round, world)
|
|
163
|
+
|
|
164
|
+
`shared` is for state everyone agrees on, not per-player position:
|
|
165
|
+
|
|
166
|
+
```ts
|
|
167
|
+
room.shared.set("round", (Number(room.shared.get("round")) || 0) + 1);
|
|
168
|
+
room.on("shared", (key, value) => { if (key === "score") updateScoreboard(value); });
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Any client can write any key (demo relay has no auth). For authority (who may change the
|
|
172
|
+
score / advance the round), pick one client — e.g. the first/lowest session id present —
|
|
173
|
+
and let only it write.
|
|
174
|
+
|
|
175
|
+
## Persistence helper
|
|
176
|
+
|
|
177
|
+
```ts
|
|
178
|
+
// persistence.ts
|
|
179
|
+
export async function loadWorld<T>(fallback: T): Promise<T> {
|
|
180
|
+
try {
|
|
181
|
+
const { data } = await fetch(`${GENEX.apiUrl}/api/projects/${GENEX.slug}/state`).then(r => r.json());
|
|
182
|
+
return (data as T) ?? fallback;
|
|
183
|
+
} catch { return fallback; }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
let saveTimer: ReturnType<typeof setTimeout> | null = null;
|
|
187
|
+
export function saveWorld(world: unknown) {
|
|
188
|
+
// debounce: at most one PUT per second, from ONE authority client
|
|
189
|
+
if (saveTimer) return;
|
|
190
|
+
saveTimer = setTimeout(() => {
|
|
191
|
+
saveTimer = null;
|
|
192
|
+
fetch(`${GENEX.apiUrl}/api/projects/${GENEX.slug}/state`, {
|
|
193
|
+
method: "PUT",
|
|
194
|
+
headers: { "Content-Type": "application/json" },
|
|
195
|
+
body: JSON.stringify(world),
|
|
196
|
+
}).catch(() => {});
|
|
197
|
+
}, 1000);
|
|
198
|
+
}
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
State is one JSON blob per project, max 1 MB, last-write-wins. Save from a single
|
|
202
|
+
authority (host) so concurrent writers don't clobber each other.
|
|
@@ -36,6 +36,11 @@ map, execution order, and acceptance gate.
|
|
|
36
36
|
| exposure, tone mapping, color grading, LUTs | `$genex-threejs-exposure-color-grading` |
|
|
37
37
|
| render-target ownership, pass ordering, depth/normal/history signals | `$genex-threejs-image-pipeline` |
|
|
38
38
|
| fixed-view captures, seed sweeps, browser and GPU evidence | `$genex-threejs-visual-validation` |
|
|
39
|
+
| realtime multiplayer: movement sync, shared state, presence, shots/emotes, persistence | `$genex-threejs-multiplayer` |
|
|
40
|
+
|
|
41
|
+
**Multiplayer is mandatory routing:** if the game has 2+ players sharing a world, loading
|
|
42
|
+
`$genex-threejs-multiplayer` is **required** before any networking code — the relay does no
|
|
43
|
+
interpolation/prediction, and that skill is how you add them.
|
|
39
44
|
|
|
40
45
|
## Real (AI-generated) assets — `npx genex` commands
|
|
41
46
|
|
|
@@ -58,6 +63,20 @@ Prefer the **procedural** skills above for abstract/parametric/animated systems
|
|
|
58
63
|
**`npx genex` generators** for concrete, describable, photoreal assets. They complement
|
|
59
64
|
each other.
|
|
60
65
|
|
|
66
|
+
**Generate a core asset set by default — don't wait to be asked.** For any game that
|
|
67
|
+
needs concrete objects or surfaces, decide a small core set from the concept and start
|
|
68
|
+
generating it **up front, in parallel** (each `npx genex` is an independent ~1-minute
|
|
69
|
+
job — launch them concurrently in the background, then scaffold the scene while they run
|
|
70
|
+
and wire each asset in as it lands, with a procedural placeholder as fallback until then):
|
|
71
|
+
|
|
72
|
+
- the **hero model** the player controls or chases (`npx genex model`),
|
|
73
|
+
- one key **texture** for the ground/main surface (`npx genex texture --terrain` for ground),
|
|
74
|
+
- a **skybox** when the scene is outdoors (`npx genex skybox`),
|
|
75
|
+
- a **sfx** or two for the core action/feedback (`npx genex sfx`).
|
|
76
|
+
|
|
77
|
+
Skip generation only for purely abstract/geometric games. Keep the set small and
|
|
78
|
+
concept-driven — a richer first build beats a grey-box one.
|
|
79
|
+
|
|
61
80
|
## Routing rules
|
|
62
81
|
|
|
63
82
|
- Start from the playable game target: player verb, scene scale, camera distance,
|
|
@@ -51,3 +51,11 @@ publishing or multiplayer, inspect the project first. Prefer clean boundaries:
|
|
|
51
51
|
- serializable player/session state;
|
|
52
52
|
- explicit asset loading paths;
|
|
53
53
|
- one clear start function for local preview and hosted launch.
|
|
54
|
+
|
|
55
|
+
**Multiplayer is mandatory routing.** If the game has 2+ players sharing a world,
|
|
56
|
+
load `$genex-threejs-multiplayer` **before writing any networking code** — it is not
|
|
57
|
+
optional. The `@genex-ai/multiplayer` relay syncs `me`/`shared` but does **no**
|
|
58
|
+
physics, prediction, or interpolation; that skill is how you add interpolation for
|
|
59
|
+
remote players, prediction for the local player, config wiring, and (for save-state
|
|
60
|
+
games) the persistent-world API. Use only the APIs that skill documents — do not
|
|
61
|
+
invent transport methods.
|