@genex-ai/cli-demo 0.7.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/README.md +8 -7
- 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-getting-started/SKILL.md +10 -6
- 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-precipitation-surfaces/SKILL.md +59 -0
- package/templates/skills/genex-threejs-precipitation-surfaces/references/precipitation-surfaces.md +181 -0
- package/templates/skills/genex-threejs-procedural-architecture/SKILL.md +2 -1
- package/templates/skills/genex-threejs-procedural-architecture/references/architecture-systems.md +1 -1
- package/templates/skills/genex-threejs-procedural-fields/SKILL.md +0 -5
- package/templates/skills/genex-threejs-procedural-geometry/SKILL.md +0 -5
- package/templates/skills/genex-threejs-procedural-materials/SKILL.md +2 -17
- package/templates/skills/genex-threejs-procedural-vegetation/SKILL.md +11 -1
- package/templates/skills/genex-threejs-procedural-vfx/SKILL.md +3 -1
- package/templates/skills/genex-threejs-shadow-systems/SKILL.md +0 -5
- package/templates/skills/genex-threejs-skill-router/SKILL.md +10 -5
- package/templates/skills/genex-threejs-skill-router/references/routing-map.md +9 -6
- package/templates/skills/genex-threejs-spectral-ocean/SKILL.md +11 -1
- package/templates/skills/genex-threejs-temporal-surfaces/SKILL.md +3 -1
- package/templates/skills/genex-threejs-water-optics/SKILL.md +14 -2
|
@@ -1,22 +1,35 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: genex-threejs-multiplayer
|
|
3
|
-
description:
|
|
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
|
|
9
|
-
synced to everyone; everything else stays local. The server
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
19
|
-
|
|
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;
|
|
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,
|
|
39
|
-
room: GENEX.slug,
|
|
40
|
-
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
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
- `room.
|
|
53
|
-
|
|
54
|
-
- `
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
- `
|
|
58
|
-
|
|
59
|
-
- `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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
73
|
-
`RemoteInterpolator` helper and a complete movement example.
|
|
117
|
+
## Rotation: sync a quaternion, not an angle
|
|
74
118
|
|
|
75
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
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
|
-
|
|
100
|
-
|
|
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
|
-
##
|
|
150
|
+
## Host authority (scores, rounds, enemies)
|
|
111
151
|
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
118
|
-
|
|
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
|
-
|
|
126
|
-
|
|
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
|
-
|
|
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
|
-
|
|
135
|
-
|
|
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
|
|
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: {
|
|
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
|
|
147
|
-
|
|
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
|
|
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
|
|
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
|
|
156
|
-
- [ ]
|
|
157
|
-
- [ ]
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
-
|
|
166
|
-
|
|
167
|
-
read
|
|
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.
|