@genex-ai/cli-demo 0.8.0 → 0.11.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.
@@ -1,113 +1,56 @@
1
- # Realtime patterns: interpolation, prediction, persistence
1
+ # Realtime patterns: rendering, objects, host, events, persistence
2
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.
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
- ## Why you need this
7
+ ## The golden rule
8
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.
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
- ## RemoteInterpolator (render-delay interpolation)
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
- 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
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
- type S = { x: number; z: number; yaw: number };
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: prediction. Input mutates this; we render yourself from it. ---
102
- const me = { x: 0, z: 0, yaw: 0 };
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
- // --- publish at ~15 Hz (NOT per frame) ---
110
- const tick = setInterval(() => room.me.set(me), 66);
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 predicted state (zero latency)
127
- meshFor(room.id).position.set(me.x, 0, me.z);
128
- meshFor(room.id).rotation.y = me.yaw;
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: feed the interpolator, render the smoothed sample
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(v.x, v.y, v.z);
138
- m.rotation.y = v.yaw;
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
- ## Custom events (shots, emotes, chat)
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
- 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):
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
- // shooter:
156
- room.send("shot", { from: room.id, x: me.x, z: me.z, yaw: me.yaw });
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
- // everyone else:
159
- room.on("shot", (msg: any) => spawnTracer(msg.x, msg.z, msg.yaw));
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
- ## Shared room state (scores, round, world)
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
- `shared` is for state everyone agrees on, not per-player position:
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
- room.shared.set("round", (Number(room.shared.get("round")) || 0) + 1);
168
- room.on("shared", (key, value) => { if (key === "score") updateScoreboard(value); });
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
- 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
+ Read remote positions for hit-testing from `stateRaw` (raw latest), not `state` (rendered in the
175
+ past). Guard the lookupa 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`).then(r => r.json());
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 per second, from ONE authority client
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: { "Content-Type": "application/json" },
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
- authority (host) so concurrent writers don't clobber each other.
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 state, presence, shots/emotes, persistence | `$genex-threejs-multiplayer` |
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 relay does no
44
- interpolation/prediction, and that skill is how you add them.
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` 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.
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.