@genex-ai/cli-demo 0.8.0 → 0.10.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 +13 -12
- package/dist/index.js +410 -154
- package/package.json +10 -11
- package/templates/skills/genex-ai-model/SKILL.md +5 -5
- package/templates/skills/genex-ai-sfx/SKILL.md +1 -1
- package/templates/skills/genex-ai-skybox/SKILL.md +2 -2
- package/templates/skills/genex-ai-texture/SKILL.md +1 -1
- package/templates/skills/genex-threejs-embed-auth/SKILL.md +174 -0
- package/templates/skills/genex-threejs-multiplayer/SKILL.md +193 -100
- package/templates/skills/genex-threejs-multiplayer/references/genre-recipes.md +115 -0
- package/templates/skills/genex-threejs-multiplayer/references/realtime-patterns.md +138 -123
- package/templates/skills/genex-threejs-skill-router/SKILL.md +5 -3
- package/templates/skills/genex-threejs-skill-router/references/routing-map.md +8 -5
|
@@ -1,113 +1,56 @@
|
|
|
1
|
-
# Realtime patterns:
|
|
1
|
+
# Realtime patterns: rendering, objects, host, events, persistence
|
|
2
2
|
|
|
3
|
-
The `@genex-ai/multiplayer`
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
The `@genex-ai/multiplayer` SDK smooths remote players **and shared objects** for you (client-side
|
|
4
|
+
entity interpolation on a shared clock). These are the patterns you build on top — none of them
|
|
5
|
+
re-implement smoothing, because doing so is the mistake that makes games lag.
|
|
6
6
|
|
|
7
|
-
##
|
|
7
|
+
## The golden rule
|
|
8
8
|
|
|
9
|
-
-
|
|
10
|
-
|
|
11
|
-
|
|
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.
|
|
9
|
+
- **Remote players & objects:** draw `players.get(id).state` / `objects.get(id).state` directly.
|
|
10
|
+
Already smoothed.
|
|
11
|
+
- **Yourself & objects you own:** draw from your own local object. Never from the network echo.
|
|
16
12
|
|
|
17
|
-
|
|
13
|
+
If you ever buffer `state` and lerp between snapshots, stop — you're rebuilding smoothing the SDK
|
|
14
|
+
already did, and stacking two smoothers adds visible lag.
|
|
18
15
|
|
|
19
|
-
|
|
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
|
|
16
|
+
## Complete movement example (players)
|
|
89
17
|
|
|
90
18
|
```ts
|
|
91
19
|
import * as THREE from "three";
|
|
92
20
|
import { connect } from "@genex-ai/multiplayer";
|
|
93
21
|
import { GENEX } from "./genex.config";
|
|
94
|
-
import { RemoteInterpolator } from "./interpolation";
|
|
95
22
|
|
|
96
|
-
|
|
23
|
+
// Your published state — EVERY synced field, including discrete ones like hp. `me.set`
|
|
24
|
+
// replaces your state wholesale, so the tick must send all of these every time (below).
|
|
25
|
+
type S = { x: number; z: number; q: number[]; hp: number };
|
|
97
26
|
|
|
98
27
|
const room = await connect<S>({ url: GENEX.colyseusUrl, room: GENEX.slug });
|
|
99
|
-
const interp = new RemoteInterpolator();
|
|
100
28
|
|
|
101
|
-
// --- local player:
|
|
102
|
-
|
|
29
|
+
// --- local player: input mutates this; we render yourself from it (zero latency) ---
|
|
30
|
+
// This ONE object holds everything you sync. Keep hp/ammo/etc. here too — see the warning below.
|
|
31
|
+
const me = { x: 0, z: 0, yaw: 0, hp: 100 };
|
|
103
32
|
addEventListener("keydown", (e) => {
|
|
104
33
|
if (e.key === "ArrowLeft") me.yaw += 0.1;
|
|
105
34
|
if (e.key === "ArrowRight") me.yaw -= 0.1;
|
|
106
35
|
if (e.key === "ArrowUp") { me.x += Math.sin(me.yaw); me.z += Math.cos(me.yaw); }
|
|
107
36
|
});
|
|
108
37
|
|
|
109
|
-
//
|
|
110
|
-
const
|
|
38
|
+
// rotation helper — turn our yaw into the quaternion we publish (and send with shots, below)
|
|
39
|
+
const _q = new THREE.Quaternion();
|
|
40
|
+
const myQuat = (): number[] => _q.setFromAxisAngle(new THREE.Vector3(0, 1, 0), me.yaw).toArray();
|
|
41
|
+
|
|
42
|
+
// --- publish at ~15 Hz (NOT per frame). Send the WHOLE state — a field you omit is deleted. ---
|
|
43
|
+
const tick = setInterval(() => {
|
|
44
|
+
room.me.set({ x: me.x, z: me.z, q: myQuat(), hp: me.hp });
|
|
45
|
+
}, 66);
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
> **`me.set` replaces your state wholesale.** Every tick must include EVERY field — position,
|
|
49
|
+
> rotation, AND discrete values like `hp`/`ammo`. A field you leave out is gone on the wire, so
|
|
50
|
+
> keep one local `me` object with all of them and publish it whole each tick. (Same for
|
|
51
|
+
> `objects.set`.)
|
|
52
|
+
|
|
53
|
+
```ts
|
|
111
54
|
|
|
112
55
|
// --- meshes, one per id ---
|
|
113
56
|
const meshes = new Map<string, THREE.Object3D>();
|
|
@@ -119,84 +62,156 @@ function meshFor(id: string) {
|
|
|
119
62
|
room.on("leave", (id) => {
|
|
120
63
|
const m = meshes.get(id);
|
|
121
64
|
if (m) { scene.remove(m); meshes.delete(id); }
|
|
122
|
-
interp.remove(id);
|
|
123
65
|
});
|
|
124
66
|
|
|
125
67
|
function frame() {
|
|
126
|
-
// yourself: render from local
|
|
127
|
-
meshFor(room.id)
|
|
128
|
-
|
|
68
|
+
// yourself: render from the local object (zero latency)
|
|
69
|
+
const self = meshFor(room.id);
|
|
70
|
+
self.position.set(me.x, 0, me.z);
|
|
71
|
+
self.rotation.y = me.yaw;
|
|
129
72
|
|
|
130
|
-
// everyone else:
|
|
73
|
+
// everyone else: draw state DIRECTLY — it is already smoothed
|
|
131
74
|
for (const [id, p] of room.players) {
|
|
132
75
|
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
76
|
const m = meshFor(id);
|
|
137
|
-
m.position.set(
|
|
138
|
-
m.
|
|
77
|
+
m.position.set(p.state.x ?? 0, 0, p.state.z ?? 0);
|
|
78
|
+
if (p.state.q) m.quaternion.fromArray(p.state.q); // slerped for you by the SDK
|
|
139
79
|
}
|
|
140
80
|
|
|
141
81
|
renderer.render(scene, camera);
|
|
142
82
|
requestAnimationFrame(frame);
|
|
143
83
|
}
|
|
144
84
|
frame();
|
|
145
|
-
|
|
146
|
-
// cleanup if you ever tear down: clearInterval(tick); room.leave();
|
|
147
85
|
```
|
|
148
86
|
|
|
149
|
-
|
|
87
|
+
No interpolation buffer, no render-delay constant, no lerp of remote state — the SDK owns all of it.
|
|
88
|
+
|
|
89
|
+
## A shared object (the ball) — `objects`, not `shared`
|
|
150
90
|
|
|
151
|
-
|
|
152
|
-
|
|
91
|
+
A ball belongs to no player. On `objects`, exactly one client **owns** it at a time (server-
|
|
92
|
+
enforced), the owner simulates it, and everyone else reads it auto-smoothed — on the same
|
|
93
|
+
interpolation as a player. The stream is keyed by the object id, so it stays continuous when
|
|
94
|
+
ownership changes (no snap), and the object survives its owner leaving (reassigned to the host).
|
|
153
95
|
|
|
154
96
|
```ts
|
|
155
|
-
//
|
|
156
|
-
|
|
97
|
+
// ball.ts
|
|
98
|
+
const ball = { x: 0, y: 0.5, z: 0, vx: 0, vy: 0, vz: 0 }; // the owner's local sim
|
|
157
99
|
|
|
158
|
-
//
|
|
159
|
-
|
|
100
|
+
// claim on contact (you kicked it) — NOT every frame
|
|
101
|
+
function kick(dir: THREE.Vector3, power: number) {
|
|
102
|
+
room.objects.claim("ball");
|
|
103
|
+
ball.vx += dir.x * power; ball.vz += dir.z * power;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// in your fixed tick: if you own it, simulate + publish it (flat fields so it smooths)
|
|
107
|
+
function publishBall() {
|
|
108
|
+
const view = room.objects.get("ball");
|
|
109
|
+
if (view?.isMine) {
|
|
110
|
+
stepBallPhysics(ball); // your own integrator or Rapier
|
|
111
|
+
room.objects.set("ball", { x: ball.x, y: ball.y, z: ball.z });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// render: owner draws its local sim (zero latency); everyone else draws the smoothed state
|
|
116
|
+
function drawBall() {
|
|
117
|
+
const view = room.objects.get<{ x: number; y: number; z: number }>("ball");
|
|
118
|
+
if (!view) return;
|
|
119
|
+
ballMesh.position.set(view.state.x ?? 0, view.state.y ?? 0, view.state.z ?? 0);
|
|
120
|
+
}
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Rules that keep it correct:
|
|
124
|
+
|
|
125
|
+
- **Claim on interaction, not continuously.** Whoever last kicked owns and simulates it.
|
|
126
|
+
- **Keep object fields flat** (`x/y/z`, a 4-number quaternion). Nested objects snap instead of glide.
|
|
127
|
+
- **Only the owner's `set` lands** — the relay drops writes from non-owners, so you never get two
|
|
128
|
+
clients fighting over the ball. You don't need to check "am I owner" before drawing, only before
|
|
129
|
+
simulating.
|
|
130
|
+
- **Transient objects** (bullets, pickups): `room.objects.remove(id)` when they expire, so they
|
|
131
|
+
don't pile up.
|
|
132
|
+
|
|
133
|
+
## Host authority (scores, rounds, world)
|
|
134
|
+
|
|
135
|
+
One client is `room.host` (the first joiner, re-elected on leave). Let only the host write agreed
|
|
136
|
+
state, so there's a single source of truth — no "who increments the score" races:
|
|
137
|
+
|
|
138
|
+
```ts
|
|
139
|
+
function updateHud() {
|
|
140
|
+
scoreEl.textContent = String(room.shared.get("score") ?? 0); // everyone reads
|
|
141
|
+
}
|
|
142
|
+
function goal(team: "a" | "b") {
|
|
143
|
+
if (!room.isHost) return; // only the host writes
|
|
144
|
+
const key = `score_${team}`;
|
|
145
|
+
room.shared.set(key, (Number(room.shared.get(key)) || 0) + 1);
|
|
146
|
+
}
|
|
147
|
+
room.on("host", () => {/* host migrated — the new host takes over writing */});
|
|
160
148
|
```
|
|
161
149
|
|
|
162
|
-
|
|
150
|
+
`room.on('shared', …)` fires when a key is **first set**, not on every change — for a live value,
|
|
151
|
+
read `room.shared.get(...)` in your loop (as `updateHud` does) rather than relying on the event.
|
|
152
|
+
|
|
153
|
+
## Custom events (shots, emotes, chat)
|
|
154
|
+
|
|
155
|
+
For one-off actions that aren't continuous state, use `send`. The shooter judges the hit **locally**
|
|
156
|
+
against what it sees (favor-the-shooter) and announces it; the victim applies its own damage.
|
|
163
157
|
|
|
164
|
-
`
|
|
158
|
+
**`send` reaches only OTHER clients — it never echoes back to you.** So apply your own action's
|
|
159
|
+
visible effect *directly* at fire time; use `on(...)` only to render everyone else's:
|
|
165
160
|
|
|
166
161
|
```ts
|
|
167
|
-
|
|
168
|
-
|
|
162
|
+
function fire() {
|
|
163
|
+
const hit = raycastAgainstWhatISee(); // favor-the-shooter: judge locally
|
|
164
|
+
spawnTracer(me.x, me.z, myQuat()); // MY tracer — right here, not via on("shot")
|
|
165
|
+
room.send("shot", { from: room.id, x: me.x, z: me.z, q: myQuat(), hit: hit ?? null });
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
room.on("shot", (m: any) => {
|
|
169
|
+
spawnTracer(m.x, m.z, m.q); // OTHER players' tracers
|
|
170
|
+
if (m.hit === room.id) me.hp -= 10; // I was hit → my next me.set publishes the new hp
|
|
171
|
+
});
|
|
169
172
|
```
|
|
170
173
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
+
Read remote positions for hit-testing from `stateRaw` (raw latest), not `state` (rendered in the
|
|
175
|
+
past). Guard the lookup — a player may have just left:
|
|
176
|
+
|
|
177
|
+
```ts
|
|
178
|
+
const t = room.players.get(id);
|
|
179
|
+
if (!t) return;
|
|
180
|
+
const px = t.stateRaw.x, pz = t.stateRaw.z; // test against the raw latest
|
|
181
|
+
```
|
|
174
182
|
|
|
175
183
|
## Persistence helper
|
|
176
184
|
|
|
177
185
|
```ts
|
|
178
|
-
// persistence.ts
|
|
186
|
+
// persistence.ts — /state requires the embed identity: call only after
|
|
187
|
+
// `await waitForAuth()` has resolved (see the genex-threejs-embed-auth skill).
|
|
188
|
+
import { getEmbedToken } from "@genex-ai/embed-sdk";
|
|
189
|
+
|
|
179
190
|
export async function loadWorld<T>(fallback: T): Promise<T> {
|
|
180
191
|
try {
|
|
181
|
-
const { data } = await fetch(`${GENEX.apiUrl}/api/projects/${GENEX.slug}/state
|
|
192
|
+
const { data } = await fetch(`${GENEX.apiUrl}/api/projects/${GENEX.slug}/state`, {
|
|
193
|
+
headers: { Authorization: `Bearer ${getEmbedToken()}` },
|
|
194
|
+
}).then(r => r.json());
|
|
182
195
|
return (data as T) ?? fallback;
|
|
183
196
|
} catch { return fallback; }
|
|
184
197
|
}
|
|
185
198
|
|
|
186
199
|
let saveTimer: ReturnType<typeof setTimeout> | null = null;
|
|
187
200
|
export function saveWorld(world: unknown) {
|
|
188
|
-
// debounce: at most one PUT
|
|
189
|
-
if (saveTimer) return;
|
|
201
|
+
if (saveTimer) return; // debounce: at most one PUT/sec, from the host only
|
|
190
202
|
saveTimer = setTimeout(() => {
|
|
191
203
|
saveTimer = null;
|
|
192
204
|
fetch(`${GENEX.apiUrl}/api/projects/${GENEX.slug}/state`, {
|
|
193
205
|
method: "PUT",
|
|
194
|
-
headers: {
|
|
206
|
+
headers: {
|
|
207
|
+
"Content-Type": "application/json",
|
|
208
|
+
Authorization: `Bearer ${getEmbedToken()}`,
|
|
209
|
+
},
|
|
195
210
|
body: JSON.stringify(world),
|
|
196
211
|
}).catch(() => {});
|
|
197
212
|
}, 1000);
|
|
198
213
|
}
|
|
199
214
|
```
|
|
200
215
|
|
|
201
|
-
State is one JSON blob per project, max 1 MB, last-write-wins. Save from a single
|
|
202
|
-
|
|
216
|
+
State is one JSON blob per project, max 1 MB, last-write-wins. Save from a single authority
|
|
217
|
+
(`room.isHost`) so concurrent writers don't clobber each other.
|
|
@@ -37,11 +37,13 @@ map, execution order, and acceptance gate.
|
|
|
37
37
|
| eye adaptation, tone mapping, LUT grading, output color | `$genex-threejs-exposure-color-grading` |
|
|
38
38
|
| shared depth/normal/velocity ownership and multi-pass ordering | `$genex-threejs-image-pipeline` |
|
|
39
39
|
| fixed-view diagnostics, seed sweeps, temporal and budget evidence | `$genex-threejs-visual-validation` |
|
|
40
|
-
| realtime multiplayer: movement sync, shared
|
|
40
|
+
| realtime multiplayer: movement sync, a shared ball/NPC, host-run scores/enemies, shots/emotes, persistence | `$genex-threejs-multiplayer` |
|
|
41
41
|
|
|
42
42
|
**Multiplayer is mandatory routing:** if the game has 2+ players sharing a world, loading
|
|
43
|
-
`$genex-threejs-multiplayer` is **required** before any networking code — the
|
|
44
|
-
|
|
43
|
+
`$genex-threejs-multiplayer` is **required** before any networking code — the SDK auto-smooths
|
|
44
|
+
remote players **and shared objects**, and gives you server-enforced object ownership (a ball) and
|
|
45
|
+
a room `host` (scores, enemies). That skill covers the rules that keep it smooth and its per-genre
|
|
46
|
+
recipes (sports/ball, shooter, co-op).
|
|
45
47
|
|
|
46
48
|
## Real (AI-generated) assets — `npx genex` commands
|
|
47
49
|
|
|
@@ -54,8 +54,11 @@ publishing or multiplayer, inspect the project first. Prefer clean boundaries:
|
|
|
54
54
|
|
|
55
55
|
**Multiplayer is mandatory routing.** If the game has 2+ players sharing a world,
|
|
56
56
|
load `$genex-threejs-multiplayer` **before writing any networking code** — it is not
|
|
57
|
-
optional. The `@genex-ai/multiplayer` relay syncs `me`/`shared
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
57
|
+
optional. The `@genex-ai/multiplayer` relay syncs `me`/`shared`/`objects`; the **SDK
|
|
58
|
+
auto-smooths remote players AND shared objects** for you (do not write your own
|
|
59
|
+
interpolation). It gives you server-enforced primitives — `objects` (one owner per ball/
|
|
60
|
+
NPC, claimed on contact) and a room `host` (single writer of scores, single simulator of
|
|
61
|
+
enemies). That skill covers the rules that keep it smooth (draw `state` directly, render
|
|
62
|
+
yourself and objects you own from a local object, quaternion rotation, `stateRaw` for
|
|
63
|
+
hit-tests), the per-genre recipes (sports/ball, shooter, co-op), config wiring, and the
|
|
64
|
+
persistent-world API. Use only the APIs that skill documents — do not invent transport methods.
|