@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,22 +1,35 @@
1
1
  ---
2
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.
3
+ description: Realtime multiplayer for Genex Three.js games with `@genex-ai/multiplayer` — a relay whose SDK auto-smooths remote players AND shared objects (you do NOT write interpolation). Use whenever 2+ players share a world: movement sync, a shared ball/NPC via objects + ownership, host-authoritative scores/waves, shots/emotes, presence. MANDATORY for any multiplayer game load before writing networking code.
4
4
  ---
5
5
 
6
6
  # Genex Three.js Multiplayer
7
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.
8
+ `@genex-ai/multiplayer` is a **relay with a smart client**: whatever you write to `me`,
9
+ `shared`, or an `object` is synced to everyone; everything else stays local. The **server**
10
+ runs no physics and no game logic but it *does* enforce two generic invariants so the naive
11
+ path is the correct one: **exactly one smoother** (the SDK interpolates remote players and
12
+ objects for you) and **exactly one owner/host** (server-arbitrated). You read a remote player
13
+ or a shared object and it is already smooth; you never fight over who simulates the ball.
13
14
 
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.
15
+ **This skill is mandatory for any multiplayer game. Load it before you write a single line of
16
+ networking code.** The mistakes below are what make a multiplayer game stutter — they are the
17
+ whole reason this skill exists.
17
18
 
18
- Read [references/realtime-patterns.md](references/realtime-patterns.md) for the
19
- copy-paste `RemoteInterpolator` + local-prediction helpers and the persistence/config code.
19
+ ## Two rules that decide whether it feels good
20
+
21
+ 1. **Do NOT write your own interpolation/smoothing.** The SDK already smooths remote players
22
+ *and* objects. Read `players.get(id).state` / `objects.get(id).state` and draw them directly.
23
+ Buffering `state` and lerping it yourself stacks a second smoother and adds ~100 ms of lag —
24
+ the #1 cause of a "laggy/stuttery" game here. Drawing `state` **is** the smooth path.
25
+ 2. **Render YOURSELF (and objects you own) from your own local object, live.** Your input
26
+ mutates a local object; you draw yourself from it every frame (zero latency). Never draw
27
+ yourself from `players.get(room.id).state` — that's the network echo, round-trip-delayed.
28
+
29
+ Read [references/realtime-patterns.md](references/realtime-patterns.md) for the complete movement
30
+ example, the shared-object/ball code, rotation, and host usage. Read
31
+ [references/genre-recipes.md](references/genre-recipes.md) for ready-made per-genre setups
32
+ (sports/ball, shooter, co-op with host-simulated enemies) — pick the one matching the game.
20
33
 
21
34
  ## Install
22
35
 
@@ -24,144 +37,224 @@ copy-paste `RemoteInterpolator` + local-prediction helpers and the persistence/c
24
37
  npm i @genex-ai/multiplayer
25
38
  ```
26
39
 
40
+ `objects` and `host` need `@genex-ai/multiplayer` **≥ 0.4.0**.
41
+
27
42
  ## Connect
28
43
 
29
44
  Pick your own per-player state shape (any JSON). `room` is the **project slug**
30
45
  (printed by `genex init`) — same id = same room, different ids are fully isolated.
31
46
 
47
+ **Joining requires a signed-in identity — the relay rejects joins without one.**
48
+ Load the `genex-threejs-embed-auth` skill first (it sets up `initEmbed(...)`),
49
+ then gate `connect()` on `waitForAuth()`:
50
+
32
51
  ```ts
33
52
  import { connect } from "@genex-ai/multiplayer";
53
+ import { waitForAuth, getColyseusAuth } from "@genex-ai/embed-sdk";
34
54
 
35
- type State = { x: number; z: number; yaw: number }; // YOUR per-player state
55
+ type State = { x: number; z: number; q: number[] }; // YOUR per-player state (rotation as quaternion)
36
56
 
57
+ const { user } = await waitForAuth(); // identity gate — rejects if the session is blocked
37
58
  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
59
+ url: GENEX.colyseusUrl, // e.g. "wss://demo-colyseus.glotech.world" — see config wiring below
60
+ room: GENEX.slug, // the project slug — everyone with this id shares a room
61
+ name: user.name, // display name — the server prefers the verified identity's name
62
+ auth: getColyseusAuth()!, // REQUIRED — tokenless joins are rejected. Read fresh each connect; NEVER log it.
41
63
  });
42
64
  ```
43
65
 
66
+ ## Which channel for which data
67
+
68
+ **This table is the most important thing in this skill.** Every piece of networked state is one
69
+ of five kinds — put each on its channel and the game just works:
70
+
71
+ | What you're syncing | Channel | Who writes it |
72
+ | --- | --- | --- |
73
+ | Your own avatar (position, rotation, anim) | `me.set` → others read `players` | you (each player their own) |
74
+ | A moving thing nobody owns (**ball**, puck, NPC) | `objects` (claim + set) | the one current **owner** |
75
+ | Slow agreed facts (score, round, wave, seed) | `shared` | the **host** (`isHost`) |
76
+ | One-off actions (shot, emote, hit, chat) | `send` + `on` | whoever did it |
77
+ | Discrete per-player values (hp, ammo, flags) | in `me.set`, read via `stateRaw` | you |
78
+
79
+ Getting the channel right is the whole game. A ball on `shared` stutters (not smoothed) and
80
+ fights (many writers). A ball on `objects` glides and has one owner. That's the difference.
81
+
44
82
  ## API surface (exact — do not invent methods)
45
83
 
46
84
  - `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.
85
+ - `room.me.set(state)` — publish your state, **replaces it wholesale**. Fixed **10–20 Hz tick**,
86
+ never per frame.
87
+ - `room.players` fresh `Map` each read, **includes you** (skip `id === room.id`). Each value is
88
+ `{ id, name, state, stateRaw }`: `state` is auto-smoothed (remotes) / live (you); `stateRaw` is
89
+ the raw latest (hit-tests, discrete values).
90
+ - `room.objects` shared objects nobody owns until claimed (a ball, an NPC):
91
+ - `claim(id)` take ownership (last claim wins; call on kick/contact).
92
+ - `set(id, state)` publish it (only lands while you own it; full flat object each call).
93
+ - `get(id)` `{ id, owner, isMine, state, stateRaw }` or `undefined`. `state` is auto-smoothed
94
+ (or live if `isMine`); `stateRaw` is the raw latest.
95
+ - `release(id)` — give up ownership. `remove(id)` destroy it (for transient bullets/pickups).
96
+ - `ids()` all object ids seen.
97
+ - `room.isHost` / `room.host` you are (or who is) the elected authority. Use to pick the single
98
+ writer of `shared` scores/rounds and the single simulator of host-owned objects. Settles within
99
+ the first patch after connect — read in your loop / react to `on('host')`, not once.
100
+ - `room.shared.get/set/keys` — key/value store (any JSON) for **slow agreed facts only**.
101
+ - `room.on(event, cb)` → unsubscribe fn. Events: `'join'`/`'change'` `(id, state)` (also fire for
102
+ you), `'leave'` `(id)`, `'shared'` `(key, value)`, `'object'` `(id)` (ownership handoff),
103
+ `'host'` `(id)`, and any custom `send` name.
104
+ - `room.send(type, payload)` — fire-and-forget to all **other** clients. **It never echoes to
105
+ you**, so apply your own action's local effect directly (draw your own tracer at fire time),
106
+ not inside `on(...)`. `room.leave()`.
60
107
 
61
108
  ## The loop you must build (input → local → tick → render)
62
109
 
63
110
  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.
111
+ 2. **A fixed tick publishes it:** `setInterval(() => room.me.set(me), 66)` (~15 Hz). If you own an
112
+ object, `objects.set` it in the same tick.
113
+ 3. **Render at your own framerate:** yourself from your *local* object; every other player from
114
+ `players.get(id).state` directly (already smoothed); every object from `objects.get(id).state`.
70
115
  4. **Create-or-reuse one mesh per id**; remove a player's mesh on `'leave'`.
71
116
 
72
- See [references/realtime-patterns.md](references/realtime-patterns.md) for the ready-made
73
- `RemoteInterpolator` helper and a complete movement example.
117
+ ## Rotation: sync a quaternion, not an angle
74
118
 
75
- ## Smoothness is human-tested you cannot see it
119
+ Send rotation as a 4-number quaternion `q: mesh.quaternion.toArray()`; on the remote do
120
+ `mesh.quaternion.fromArray(p.state.q)`. A scalar `yaw` is lerped linearly, so a heading crossing
121
+ ±π spins the long way. This applies to `objects` state too — keep object fields **flat**
122
+ (top-level numbers + a 4-number quaternion smooth; nested objects snap).
76
123
 
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.
124
+ ## Smoothed vs raw `state` vs `stateRaw`
81
125
 
82
- Your job is bounded:
126
+ `state` is smoothed and rendered slightly in the past (that's what makes motion glide). `stateRaw`
127
+ is the newest value with no smoothing. **Draw** from `state`; **test** against `stateRaw` — hit
128
+ detection, "am I close enough to kick", pickups, and any discrete number (hp, ammo, animation id,
129
+ a 0/1 flag) that must not arrive fractional. This holds for both players and objects.
83
130
 
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.
131
+ ## Shared objects (the ball, the NPC) use `objects`, never `shared`
91
132
 
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.
133
+ A ball belongs to no player. Put it on `objects`: exactly one client owns it at a time (the SDK +
134
+ relay enforce it), the owner simulates it, and everyone else reads it auto-smoothed — on the same
135
+ interpolation as a player. Ownership survives the owner leaving (reassigned to the host).
96
136
 
97
- ### When the human reports a problem, match the symptom (don't guess)
137
+ ```ts
138
+ if (iKickedIt) room.objects.claim("ball"); // become the owner on contact
139
+
140
+ if (room.objects.get("ball")?.isMine) {
141
+ room.objects.set("ball", stepBallPhysics()); // only the owner's writes land
142
+ }
143
+ const ball = room.objects.get("ball");
144
+ if (ball) drawBall(ball.state); // smoothed for everyone, live for the owner
145
+ ```
98
146
 
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.
147
+ **Only claim on interaction, not every frame.** Keep object state flat. Full code and the handoff
148
+ details are in [references/realtime-patterns.md](references/realtime-patterns.md).
109
149
 
110
- ## Config wiring (required the browser can't read `.genex/project.json`)
150
+ ## Host authority (scores, rounds, enemies)
111
151
 
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`:
152
+ One client is the `host`. Let *only* the host write agreed state and simulate shared enemies, so
153
+ there's a single source of truth:
115
154
 
116
155
  ```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;
156
+ if (room.isHost) room.shared.set("round", nextRound); // only the host advances the round
157
+ room.on("host", (id) => {}); // host migrated (someone left)
123
158
  ```
124
159
 
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.)
160
+ For host-simulated NPCs, the host claims and drives each enemy as an `object`; when the host
161
+ leaves, its enemies are reassigned to the new host, which reads their `stateRaw` and keeps
162
+ simulating. See the co-op recipe in [references/genre-recipes.md](references/genre-recipes.md).
163
+
164
+ ## Smoothness is felt, not seen — hand the feel to a human
165
+
166
+ Lag and stutter are *motion over time*. A screenshot is one frozen instant, so **you cannot tell
167
+ from any still capture whether movement feels smooth.** Don't try — it leads to blind tuning.
168
+
169
+ 1. **Trust the SDK's smoothing.** Draw `state` directly; don't add your own.
170
+ 2. **Verify it *runs*:** two clients, distinct meshes, both move, no console errors, each sees the
171
+ other (and the ball, if any). That's all a capture can prove.
172
+ 3. **Then say plainly:** *"Multiplayer smoothness depends on your network and can only be felt by a
173
+ person — open it in two tabs or with a friend and tell me how it feels."* Stop there.
174
+
175
+ ### When the human reports a problem, match the symptom (don't guess)
176
+
177
+ - **"Everyone (including me) feels laggy / stuttery."** You're re-smoothing: you buffered `state`
178
+ and lerped it yourself. **Delete that** — draw `state` directly. Most common mistake.
179
+ - **"My own avatar feels laggy."** You're rendering yourself from the network echo. Render yourself
180
+ from your local object instead.
181
+ - **"Other players spin the wrong way when turning."** You synced a scalar `yaw`. Use a quaternion.
182
+ - **"The ball stutters / jumps / two of it / fights."** It's on `shared`, or you re-smoothed it.
183
+ Put it on `objects` (claim on contact, owner simulates, everyone draws `state`).
184
+ - **"The ball freezes when someone else takes it."** You're re-smoothing an object, or reading the
185
+ wrong owner — just draw `objects.get(id).state`; handoff continuity is handled for you.
186
+ - **"The score/enemies desync or double up."** More than one writer. Gate writes on `room.isHost`.
187
+ - **"Everything lags equally, even idle objects."** You're calling `me.set`/`objects.set` per
188
+ frame — move publishing to the fixed 10–20 Hz tick.
189
+
190
+ ## Config wiring (already done — do NOT hand-write URLs)
191
+
192
+ `genex init` already wrote `src/genex.config.ts` — a static env-reader with
193
+ production URL defaults (local-stack overrides live in `.env.development.local`,
194
+ which applies in dev mode only and is never committed or shipped). Just import
195
+ it; never hardcode a URL or edit the file:
196
+
197
+ ```ts
198
+ import { GENEX } from "./genex.config";
199
+ // GENEX.slug / GENEX.colyseusUrl / GENEX.apiUrl / GENEX.dashboardOrigins
200
+ ```
201
+
202
+ See the `genex-threejs-embed-auth` skill's "Config wiring" section for the full
203
+ file layout.
127
204
 
128
205
  ## Persistent worlds (optional — survives restarts)
129
206
 
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:
207
+ The relay is in-memory: room state is gone when everyone leaves or the server restarts. For a world
208
+ that persists, save/load one JSON blob keyed by the project slug, from **one authority** (the host):
209
+
210
+ Both calls **require the embed identity** (`Authorization: Bearer` with the token from
211
+ `getEmbedToken()`) — there is no anonymous read or save. Call them only after
212
+ `await waitForAuth()` (see the `genex-threejs-embed-auth` skill):
132
213
 
133
214
  ```ts
134
- // load on boot
135
- const { data } = await fetch(`${GENEX.apiUrl}/api/projects/${GENEX.slug}/state`).then(r => r.json());
215
+ import { getEmbedToken } from "@genex-ai/embed-sdk";
216
+
217
+ // load on boot (after waitForAuth() has resolved)
218
+ const { data } = await fetch(`${GENEX.apiUrl}/api/projects/${GENEX.slug}/state`, {
219
+ headers: { Authorization: `Bearer ${getEmbedToken()}` },
220
+ }).then(r => r.json());
136
221
  initWorld(data ?? defaultWorld());
137
222
 
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`, {
223
+ // save from ONE authority the elected host to avoid races; max 1 MB, last-write-wins
224
+ if (room.isHost) fetch(`${GENEX.apiUrl}/api/projects/${GENEX.slug}/state`, {
140
225
  method: "PUT",
141
- headers: { "Content-Type": "application/json" },
226
+ headers: {
227
+ "Content-Type": "application/json",
228
+ Authorization: `Bearer ${getEmbedToken()}`,
229
+ },
142
230
  body: JSON.stringify(world),
143
231
  });
144
232
  ```
145
233
 
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.
234
+ `GET` returns `{ data }` (or `{ data: null }` if never saved). It requires a valid embed
235
+ token for this exact game (401 without one, 403 with another game's token) and is
236
+ size-capped at 1 MB. Don't save every frame — debounce, and let `room.isHost` pick the
237
+ single writer.
148
238
 
149
239
  ## Checklist
150
240
 
151
- - [ ] `npm i @genex-ai/multiplayer`, and config values wired into the client build.
241
+ - [ ] `npm i @genex-ai/multiplayer` (≥ 0.4.0 for objects/host); config wired into the build.
242
+ - [ ] `connect()` runs AFTER `await waitForAuth()` and passes `auth: getColyseusAuth()!`
243
+ (the relay rejects tokenless joins — see `genex-threejs-embed-auth`).
152
244
  - [ ] `room` is the **project slug**.
153
- - [ ] `me.set` on a fixed **10–20 Hz** tick (not per frame); full object each time.
245
+ - [ ] `me.set` on a fixed **10–20 Hz** tick; full object each time.
154
246
  - [ ] 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).
247
+ - [ ] Remote players & objects drawn from `state` **directly** no hand-rolled interpolation.
248
+ - [ ] **You** (and objects you own) render from your local object, not the network echo.
249
+ - [ ] Rotation synced as a quaternion `q`, not a scalar angle.
250
+ - [ ] Hit-tests and discrete values read from `stateRaw`, not `state`.
251
+ - [ ] A ball / shared NPC is on `objects` (claim on contact), never on `shared`.
252
+ - [ ] `shared` scores/rounds and host-simulated enemies are written only by `room.isHost`.
253
+ - [ ] Picked the matching recipe from [references/genre-recipes.md](references/genre-recipes.md).
254
+
255
+ ## Troubleshooting auth
256
+
257
+ - **`connect()` rejects with 401/403** — 401 "auth required"/"invalid token": you joined
258
+ without `auth` or before `waitForAuth()` resolved (or the token expired mid-reconnect
259
+ read `getColyseusAuth()` fresh at every connect). 403 "wrong game": the `room` value
260
+ doesn't match this game's own slug.
@@ -0,0 +1,115 @@
1
+ # Genre recipes
2
+
3
+ Pick the recipe matching the game and follow its **decisions** — which channel carries what, who
4
+ is the authority. The mechanisms are just the SDK calls from
5
+ [realtime-patterns.md](realtime-patterns.md); these recipes only tell you *how to wire them per
6
+ genre*. Don't invent your own netcode — every genre reduces to the five-channel table in SKILL.md.
7
+
8
+ Set the human's expectation up front: this is **casual, favor-the-player** multiplayer (Haxball,
9
+ not Rocket League). There's no server-side simulation or anti-cheat — great for friends over a
10
+ link, not for ranked play. Say that plainly rather than chasing perfect contested physics.
11
+
12
+ ---
13
+
14
+ ## Recipe 1 — Sports / ball (football, hockey, dodgeball)
15
+
16
+ The genre where a single contested object is the whole game.
17
+
18
+ | Thing | Channel | Authority |
19
+ | --- | --- | --- |
20
+ | Each player's avatar | `me.set` / `players` | each player |
21
+ | The ball / puck | `objects` (`"ball"`) | current owner (last kicker) |
22
+ | Score, timer, kickoff | `shared` | `room.isHost` |
23
+ | Goal celebration / whistle | `send` | whoever scored / the host |
24
+
25
+ **Decisions:**
26
+ - **Claim the ball on contact**, not every frame: when your player's collider touches the ball,
27
+ `room.objects.claim("ball")` and apply the kick impulse to your local ball sim.
28
+ - **Only the owner simulates** the ball (`if (objects.get("ball")?.isMine) objects.set("ball", …)`
29
+ in the tick). Everyone draws `objects.get("ball").state`. Non-owner writes are dropped by the
30
+ relay, so two players kicking at once resolve to one owner — no fighting.
31
+ - **Ball state stays flat** (`{x,y,z}` plus a quaternion if it spins). Nested objects don't smooth.
32
+ - **Goals are host-only:** the host detects the ball crossing the line (it reads the ball like
33
+ everyone else) and writes the score to `shared`; everyone reads `shared.get("score_a")` in the HUD.
34
+ - **A player quitting mid-match doesn't kill the ball** — if the owner leaves, the ball is
35
+ reassigned to the host automatically and play continues.
36
+
37
+ **Acceptance feel:** the kicker sees the ball respond the same frame; everyone else sees it glide;
38
+ a contested kick settles on one owner within a snapshot; the owner leaving doesn't freeze the ball.
39
+
40
+ ---
41
+
42
+ ## Recipe 2 — Shooter / arena (top-down or FPS .io)
43
+
44
+ Fast players, instant hits, a live scoreboard. No shared physics object needed for hitscan.
45
+
46
+ | Thing | Channel | Authority |
47
+ | --- | --- | --- |
48
+ | Player pos + rotation + hp | `me.set` / `players` (hp read via `stateRaw`) | each player |
49
+ | A shot being fired | `send("shot", …)` | the shooter |
50
+ | Damage applied | victim's own `me.set` (hp) | the victim |
51
+ | Score / round | `shared` | `room.isHost` |
52
+ | Slow physical projectiles (grenades, rockets) | `objects` (one per projectile, `remove` on expiry) | the thrower |
53
+
54
+ **Decisions:**
55
+ - **The shooter judges the hit locally** ("favor the shooter"): raycast against what *you* see,
56
+ then `send("shot", { hit: targetId })` — and draw your own muzzle flash / tracer **right there**,
57
+ because `send` never echoes back to you. This is how casual browser shooters feel responsive; a
58
+ high-ping victim occasionally "dies behind a wall", which is inherent to server-less shooters —
59
+ set that expectation, don't try to fix it with more smoothing.
60
+ - **The victim applies its own damage** on receiving the shot (`if (m.hit === room.id) me.hp -= …`)
61
+ — each player owns their own hp, so there's no write conflict.
62
+ - **Hit-tests read `stateRaw`, not `state`** — you want the raw latest position, not the
63
+ render-delayed smoothed one. Same for reading remote hp (a discrete value; `state` would lerp it
64
+ to fractions).
65
+ - **Hitscan uses `send`, not objects** — a shot is an event, not continuous state. Reserve
66
+ `objects` for a *slow, visible* projectile the players can dodge (a rocket): claim on spawn,
67
+ `objects.set` its arc, `objects.remove` on impact.
68
+ - **Scoreboard is host-only:** the host tallies kills into `shared`; everyone renders it.
69
+
70
+ **Acceptance feel:** movement is smooth for 4–8 players; hits register on what you aimed at; the
71
+ scoreboard updates for everyone; one player on a throttled/backgrounded tab doesn't drag others.
72
+
73
+ ---
74
+
75
+ ## Recipe 3 — Co-op vs enemies (horde, tower defense, dungeon)
76
+
77
+ Players cooperate against AI the game itself controls. The trick: **one client runs the enemy AI**,
78
+ and it's the host, so it survives players joining and leaving.
79
+
80
+ | Thing | Channel | Authority |
81
+ | --- | --- | --- |
82
+ | Each player's avatar | `me.set` / `players` | each player |
83
+ | Each enemy / NPC | `objects` (`"enemy:<n>"`), one per enemy | the **host** |
84
+ | Wave number, shared score, boss hp | `shared` | `room.isHost` |
85
+ | Spawn flashes, hit sparks | `send` | the host / whoever hit |
86
+
87
+ **Decisions:**
88
+ - **The host owns and simulates the enemies.** On spawn, the host `claim`s each enemy object and,
89
+ in its tick, runs the AI and `objects.set`s each one. Non-host clients never simulate enemies —
90
+ they just draw `objects.get("enemy:n").state` (smoothed) and read `stateRaw` for hit-tests.
91
+ - **One object per enemy** (flat `{x,y,z,hp}`) so each smooths independently. For a big horde keep
92
+ the count modest (≈8–16 active); it's casual, not a bullet-hell server.
93
+ - **Host migration keeps the game alive:** if the host leaves, its enemies are reassigned to the
94
+ new host automatically. The new host reads each enemy's `stateRaw` and continues the AI from
95
+ there — no wave restart. React to `on("host")` if the new host needs to (re)seed spawns.
96
+ - **Players still deal damage favor-the-player**: a **non-host** player `send`s "I hit enemy:3" and
97
+ the host (owner of enemy:3) applies the damage to its enemy sim and publishes the new hp. But
98
+ `send` never echoes to the sender — so when the **host itself** shoots an enemy it owns, it must
99
+ apply that damage to its local enemy sim **directly**, not via `send` (which wouldn't come back).
100
+ Rule of thumb: if `objects.get("enemy:3")?.isMine`, apply the hit locally; otherwise `send` it.
101
+ Enemy death: the host `objects.remove("enemy:3")`.
102
+ - **Waves/score are host-only** in `shared`; late joiners read the current wave on connect.
103
+
104
+ **Acceptance feel:** enemies move smoothly for everyone; killing the host's tab mid-wave promotes a
105
+ new host and the enemies keep going within a second or two (no freeze, no duplicates); a late joiner
106
+ sees the correct wave and enemy positions.
107
+
108
+ ---
109
+
110
+ ## Not sure which? Start from the table
111
+
112
+ Whatever the genre, ask per thing: *is it one player's own state* (`me.set`) *· a moving thing
113
+ nobody owns* (`objects`) *· a slow agreed fact* (`shared`, host-written) *· or a one-off event*
114
+ (`send`)? Answer that for each moving/shared piece and the netcode is done. If you're inventing a
115
+ sixth mechanism, you've taken a wrong turn — re-read the channel table in SKILL.md.