@helix3/helix-mcp 0.2.2-helix3.14

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.
@@ -0,0 +1,394 @@
1
+ # HELIX Instant — Multiplayer Logic (the declarative DSL) reference
2
+
3
+ This is the **reference** for HELIX's server-authoritative multiplayer game logic. You declare game logic as **data** in
4
+ your world's `helix.json` under the `multiplayer` block; a platform-owned room interprets it every tick. **You write no
5
+ server code.** Pair this with a **template** from `multiplayer-world.md` (the hub) — the template shows a complete working
6
+ world; this doc is the grammar each template links into. New to multiplayer? Read `multiplayer-world.md` first.
7
+
8
+ > This reference is lookup-shaped — skim to the section you need. Every construct here is what the platform actually
9
+ > interprets (validated at publish). If you write a verb/op/event not listed here, publish rejects it with a did-you-mean.
10
+
11
+ ## Mental model — read once
12
+
13
+ 1. **The server is authoritative and platform-owned.** A generic room holds the shared state and runs your rules. Clients
14
+ send only their own input (position, ability casts, declared actions); the server makes every authoritative write.
15
+ 2. **You declare state + rules; the room runs them at a fixed 20 Hz tick.** Each tick: ingest client input → fire lifecycle
16
+ + timer events → integrate entity motion → recompute zones → **evaluate your rules in declared order** → drain the
17
+ cascade (effects that fired secondary events) → broadcast the diffed state to clients.
18
+ 3. **Rules are `when` (an event) → optional `if` (a condition) → `then` (effects).** Effects mutate declared state, which
19
+ syncs to every client automatically.
20
+ 4. **Clients render off the synced state.** Players are replicated for you (see the hangout template); your declared
21
+ `roomVars` / per-player `vars` / `entities` are read from `room.state`.
22
+ 5. **It must work single-player.** Guests/solo still run the world; gate any "needs 2 players" logic explicitly.
23
+
24
+ ---
25
+
26
+ ## 1. The `multiplayer` block
27
+
28
+ Lives in `public/helix.json` (manifest `helixVersion: "0.3"`, with `maxPlayers > 1` and the `multiplayer` permission — see
29
+ `multiplayer-world.md`). Every sub-key is optional; declare only what your world uses.
30
+
31
+ ```jsonc
32
+ "multiplayer": {
33
+ "authoritative": true, // keep true (the only model)
34
+ "minPlayers": 1, // default 1; the world MUST work solo
35
+ "interestRadius": 30, // advisory meters (reserved); optional
36
+ "uploadHz": 10, // client→server rate: 10 (default) | 20 (fast physics; then maxPlayers ≤ 12)
37
+ "state": { "roomVars": {…}, "playerVars": {…} }, // §2 declared state
38
+ "entities": { "<kind>": {…} }, // §9 server- or client-hosted objects
39
+ "zones": [ {…} ], // §10 spatial volumes
40
+ "timers": { "<name>": {…} }, // §11 countdowns
41
+ "states": { "initial": "…", "phases": […] }, // §12 state machine
42
+ "events": { "<name>": {…} }, // §13 server→client broadcasts
43
+ "actions": { "<name>": {…} }, // §14 client→server intents
44
+ "rules": [ { "when": …, "if": …, "then": […] } ] // §3 behavior
45
+ }
46
+ ```
47
+
48
+ ---
49
+
50
+ ## 2. Declared state (`state.roomVars`, `state.playerVars`)
51
+
52
+ `roomVars` exist once per room; `playerVars` are realized per connected player. Each entry is a typed var. Entities have
53
+ their own `vars` (§9). A var declaration is a discriminated union on `type`:
54
+
55
+ | `type` | Fields | Notes |
56
+ |---|---|---|
57
+ | `number` | `default` (req), `min?`, `max?`, `integer?` | `add`/`set` clamp to `[min,max]` if declared |
58
+ | `string` | `default` (req), `maxLen?` (≤1024), `enum?` (≤64 values) | client-written strings are length-clamped + enum-checked |
59
+ | `boolean` | `default` (req) | |
60
+ | `vec3` | `default` (req): `[x,y,z]` | positions/points |
61
+ | `ref` | `of: "player" \| "entity:<kind>"`, `default?: null` | holds a member id; unset reads as `""` |
62
+ | `list` | `of: <elem>`, `maxLen` (req, ≤256) | bounded array; starts empty (§5) |
63
+ | `counterMap` | `keys: string[]` (≤64) | string→number map; all keys start `0` (§5) |
64
+
65
+ **List element (`of`)** is `"number" | "string" | "boolean"`, or `{ type:"ref", of:… }`, or a flat **record**:
66
+ `{ type:"record", fields: { <name>: <recordField> } }` (≤8 fields). Record fields are scalar only — `number`/`string`/
67
+ `boolean`/`vec3` (each with optional `default`) or `ref` (no nesting; no list-of-list).
68
+
69
+ ```jsonc
70
+ "state": {
71
+ "roomVars": { "score": {"type":"number","default":0}, "winner": {"type":"ref","of":"player"} },
72
+ "playerVars": { "team": {"type":"string","default":"none","enum":["red","blue","none"]},
73
+ "hand": {"type":"list","maxLen":8,"of":{"type":"record","fields":{"card":{"type":"string"},"power":{"type":"number","default":0}}}} }
74
+ }
75
+ ```
76
+
77
+ **Reserved built-ins (read-only — don't redeclare):** players expose `position` (vec3), `connected` (bool), `active`
78
+ (bool); the room exposes `phase` (string). Read them like any var (`self.position`, `room.phase`).
79
+
80
+ ---
81
+
82
+ ## 3. Rules — `when` / `if` / `then`
83
+
84
+ ```jsonc
85
+ { "when": { "on": "<event>", … }, "if": <expr>, "then": [ <effect>, … ] }
86
+ ```
87
+ - `when` selects the trigger (§4). `if` is an optional boolean condition (§6); omitted = always. `then` is an ordered,
88
+ non-empty effect list (§8).
89
+ - **Order matters:** rules evaluate in declared order; effects within a `then` apply in order; later rules see earlier
90
+ rules' writes (within the same tick).
91
+ - **Cascade:** effects that fire secondary events (`transitionTo`→`stateEnter`/`stateExit`, `spawnEntity`→`entitySpawn`,
92
+ `destroyEntity`→`entityDestroy`, `takeover`/`requestOwnership`→`ownershipChanged`) queue and drain **breadth-first
93
+ after** the primary rules, bounded by `maxCascadeDepth` (16). Publish rejects cascades that could cycle. (`varReached`
94
+ is separate — it is edge-checked once at the **end** of the rule phase, not part of the cascade drain.)
95
+ - Caps: `rules` ≤128, effects per `then` ≤16, `if` depth ≤8 / ≤64 nodes.
96
+
97
+ ---
98
+
99
+ ## 4. Events (`when.on`)
100
+
101
+ | `on` | Params | Binds | Fires |
102
+ |---|---|---|---|
103
+ | `tick` | `everyN?` | — (room) | every N ticks (default 1) |
104
+ | `playerJoin` / `playerLeave` | — | `self` = the player | once on join / on leave (after grace) |
105
+ | `playerDisconnect` / `playerReconnect` | — | `self` | enters / resumes reconnection grace |
106
+ | `zoneEnter` / `zoneExit` | `zone` | `self` = member; `source` = carrier (attached zones) | edge: once on cross in/out |
107
+ | `zoneInside` | `zone` | `self`; `source` = carrier (attached zones) | every tick while inside |
108
+ | `playerContact` | `radius` | `self`, `other` | once per pair entering `radius` (the tag primitive) |
109
+ | `action` | `name` | `self` = actor | a client sent a declared action (args pre-validated); read `action.args.<x>` |
110
+ | `stateEnter` / `stateExit` | `phase` | — (room) | on `transitionTo` in/out of a phase (cascade) |
111
+ | `timerElapsed` | `timer` | `self` = the timer's key (if keyed) | a timer deadline passed |
112
+ | `entitySpawn` / `entityDestroy` | `kind` | `self` = the entity | once on spawn / destroy |
113
+ | `ownershipChanged` | `kind` | `self` = new owner, `source` = entity | on a successful ownership transfer |
114
+ | `varReached` | `scope`("room"\|"self"\|"entity"), `kind?`, `var`, `cmp`, `value` | `self` = the member | edge: a watched var crosses a threshold (false→true) |
115
+
116
+ `value` for `varReached` is a literal, or `{var:"…"}`, or `{ref:<ref>,var:"…"}` (a dynamic threshold).
117
+
118
+ ---
119
+
120
+ ## 5–8 are the value/effect language. §5 collections appear inline in §6/§8.
121
+
122
+ ## 6. Expressions (`if` conditions and value operands)
123
+
124
+ A `HelixExpr` is one of:
125
+
126
+ **Literals & reads**
127
+ - `42`, `"red"`, `true` — literals · `{"vec3":[x,y,z]}` — vec3 literal
128
+ - `{"var":"room.score"}` / `{"var":"self.team"}` / `{"var":"action.args.amount"}` — scalar read
129
+ - `{"ref":<ref>,"var":"score"}` — read another member's var (or a record field), via a ref (§7)
130
+
131
+ **Arithmetic** `{"op":"+"|"-"|"*"|"/","a":<expr>,"b":<expr>}` → number
132
+ **Comparison** `{"op":"=="|"!="|"<"|"<="|">"|">=","a":…,"b":…}` → boolean. `==`/`!=` take same-typed scalars (no vec3); the ordering ops `<`/`<=`/`>`/`>=` require **numbers**.
133
+ **Logical** `{"op":"and"|"or","of":[…]}` · `{"op":"not","of":<expr>}` → boolean
134
+ **Distance** `{"op":"distance","a":<vec3>,"b":<vec3>}` → number
135
+
136
+ **Leaf generators** `{"op":"playerCount"}` (active = connected, not eliminated) · `{"op":"random"}` (0..1) · `{"op":"now"}` (epoch seconds) ·
137
+ `{"op":"timeInState"}` (s in phase) · `{"op":"randomPoint","min":[…],"max":[…]}` → vec3
138
+
139
+ **Aggregate** over a population → number:
140
+ ```jsonc
141
+ { "op":"aggregate", "scope":"players"|"zone:<id>"|"entities:<kind>",
142
+ "agg":"count"|"sum"|"min"|"max"|"avg", "field":"<numberVar>", "where":<expr>, "as":"p" }
143
+ ```
144
+ `count` needs no `field`; `where`/`as` are an optional filter (bind each candidate to `as`).
145
+
146
+ **Timers** `{"op":"timerRemaining","timer":"round","key":<ref>?}` → seconds left (0 if inactive).
147
+
148
+ **Collections** (operate on a `list`/`counterMap` var):
149
+ - `{"op":"listLength","list":<lvalue>}` → number
150
+ - `{"op":"listAt","list":<lvalue>,"index":<expr>,"field":"<f>"?}` → element (or record field); out-of-range → typed zero
151
+ - `{"op":"count","map":<lvalue>,"key":"<k>"}` → counterMap value
152
+ - `{"op":"listCount","list":<lvalue>,"as":"e","where":<expr>}` → number matching (O(maxLen))
153
+ - `{"op":"listIndexOf","list":<lvalue>,"as":"e","where":<expr>}` → index or -1
154
+
155
+ **Ownership / load**
156
+ - `{"op":"controlledBy","entity":<ref>,"by":<ref>}` → boolean
157
+ - `{"op":"hostLoad","of":<ref>}` → number of entities that player controls
158
+ - `{"op":"sameRef","a":<ref>,"b":<ref>}` → boolean (identity; false if either unset)
159
+
160
+ ---
161
+
162
+ ## 7. Refs (`HelixRef`)
163
+
164
+ A ref resolves to a live member (player or entity). Reading through a dangling/unset ref yields the typed zero (`0`/`""`/
165
+ `false`); effects on a dangling ref are counted no-ops (not errors).
166
+
167
+ - `"self"` / `"other"` / `"source"` — bound by the event (§4)
168
+ - `{"var":"room.winner"}` — deref a `ref`-typed var
169
+ - `{"op":"nearestPlayer","from":<vec3>}` / `{"op":"nearestEntity","from":<vec3>,"kind":"<k>"?}` → nearest member
170
+ - `{"op":"controllerOf","entity":<ref>}` → the player controlling it (`""` = server/unowned)
171
+ - `{"op":"leastLoadedPlayer","where":<expr>?,"as":"p"?}` → connected player controlling the fewest entities
172
+ - `{"op":"listAt","list":<lvalue>,"index":<expr>}` → a ref from a list-of-ref
173
+ - `{"op":"aggregate","scope":…,"agg":"argmax"|"argmin","field":"<f>","where":?,"as":?}` → the member with the max/min field
174
+
175
+ **Lvalues (effect targets):** `"room.<var>"`, `"self.<var>"`, or `{"ref":<ref>,"var":"<var>"}` (write through a ref — the
176
+ pattern for cross-player writes, subject to the firewall in §17).
177
+
178
+ ---
179
+
180
+ ## 8. Effects (`then`)
181
+
182
+ **State** `{"do":"add","target":<lv>,"by":<expr>}` · `{"do":"set","target":<lv>,"to":<expr>}` ·
183
+ `{"do":"setRef","target":<lv>,"to":<ref>|null}`
184
+ **Movement** `{"do":"teleport"|"respawn","player":<ref>,"to":<vec3>}` (server-authoritative; re-anchors the move gate)
185
+ **Loops**
186
+ - `{"do":"forEachPlayer","as":"p","where":<expr>?,"includeEliminated":false?,"then":[…]}`
187
+ - `{"do":"forEachEntity","kind":"<k>","as":"e","where":?,"then":[…]}`
188
+ - `{"do":"forEachInList","list":<lv>,"as":"e","where":?,"then":[…]}` (no nesting; don't structurally mutate the list you iterate)
189
+
190
+ **Broadcast** `{"do":"broadcast","event":"<name>","to":"all"|<ref>|{"team":"<playerVar>=<value>"},"payload":{…}}` (§13)
191
+ **State machine** `{"do":"transitionTo","phase":"<p>"}` · `{"do":"startTimer","timer":"<t>","seconds":<expr>,"key":<ref>?}` ·
192
+ `{"do":"cancelTimer","timer":"<t>","key":<ref>?}`
193
+ **Entities** `{"do":"spawnEntity","kind":"<k>","at":<vec3>,"vars":{…}?,"bind":"spawned"?}` ·
194
+ `{"do":"destroyEntity","entity":<ref>}`
195
+ **Ownership** `{"do":"requestOwnership"|"takeover","entity":<ref>,"to":<ref>|null}` (§9: `requestOwnership` needs `transferPolicy` ∈ {`request`,`takeover`}; `takeover` needs `transferPolicy:"takeover"`)
196
+ **Collections**
197
+ - `{"do":"append","target":<lv>,"value":<expr|ref|record>}` · `{"do":"clear","target":<lv>}`
198
+ - `{"do":"addCount","target":<lv>,"key":"<k>","by":<expr>}`
199
+ - `{"do":"removeAt","target":<lv>,"index":<expr>}` · `{"do":"removeWhere","target":<lv>,"as":"e","where":<expr>}`
200
+ - `{"do":"setField","target":<lv>?,"index":<expr>?,"as":"e"?,"field":"<f>","to":<expr|ref>}` (by index, or on a `forEachInList` element)
201
+
202
+ **Turns & elimination** `{"do":"advanceTurn","order":<lv list-of-ref>,"index":<lv number>}` (skips inactive, wraps) ·
203
+ `{"do":"eliminate","player":<ref>}` / `{"do":"revive","player":<ref>}` (eliminated = `active:false`, still connected/spectating)
204
+
205
+ ---
206
+
207
+ ## 9. Entities
208
+
209
+ Server- or client-hosted objects beyond players. Declare per kind under `entities`. Spawn with the `spawnEntity` effect.
210
+
211
+ ```jsonc
212
+ "entities": {
213
+ "coin": { "vars": { "value": {"type":"number","default":1} } }, // server-authoritative (default)
214
+ "pet": { "authority":"owner", "maxSpeed":12, "ownerLifecycle":"migrateToServer",
215
+ "motion": {"type":"seek","target":"owner","speed":6} }
216
+ }
217
+ ```
218
+
219
+ | Field | Values | Meaning |
220
+ |---|---|---|
221
+ | `vars` | var decls (§2) | per-entity state |
222
+ | `authority` | `"server"` (default) \| `"owner"` | who simulates it (see below) |
223
+ | `maxSpeed` | number | **required for `owner`**; m/s plausibility gate on uploads |
224
+ | `motion` | `static`\|`linear`\|`orbit`\|`seek`\|`waypoints` | server-deterministic kinematics |
225
+ | `transferPolicy` | `"fixed"` (default) \| `"request"` \| `"takeover"` | gates `requestOwnership`/`takeover` |
226
+ | `ownerLifecycle` | `despawnWithOwner` (default) \| `persist` \| `migrateToServer` \| `hostMigrate` | what happens when the host leaves |
227
+ | `shared` | boolean | game-owned, host-migrated (needs `authority:"owner"` + `ownerLifecycle:"hostMigrate"`) |
228
+ | `idleTimeout` | seconds | a hostless shared entity despawns after this |
229
+ | `zone` | attached zone | an entity-attached spatial volume (§10) |
230
+ | `physics` | physics block | opt-in client-simulated **dynamic rigid body** (colliding) — needs `authority:"owner"`; see "Networked physics" below |
231
+ | `rejoinOnRelease` | `nearestPoint` (default) \| `timeIndex` \| `seekBack` | for a HYBRID (physics + non-static `motion`): how it re-bases its server path when released |
232
+
233
+ **Motion** types: `{"type":"linear","velocity":[…]}`, `{"type":"orbit","center":[…],"radius":r,"speed":rad/s}` (circle in the XZ plane),
234
+ `{"type":"seek","target":"<refVarName>","speed":s}` (homes toward the member held in that ref var),
235
+ `{"type":"waypoints","points":[[…],…],"speed":s,"loop":true?}`.
236
+
237
+ ### Authority — **declare `motion` first**
238
+ - **`authority:"server"` (default):** deterministic, cheat-proof, never freezes. Use server `motion` (straight-line `seek`,
239
+ `waypoints`, `orbit`) for anything the server can compute. **This is the default; reach for it first.**
240
+ - **`authority:"owner"`:** a client simulates the entity and uploads its transform; the server sanity-validates + relays.
241
+ This is the **escape hatch** for behavior the server can't express (real pathfinding, client physics, complex AI) — it is
242
+ spoofable by design. Requires `maxSpeed`. Single-owner (`ownerLifecycle`) or `shared:true` (re-elects a host on leave).
243
+
244
+ ### Client side — render + host entities with `EntityScene`
245
+ Entities reach the client through `EntityScene` (from `@helix/humanoid-character`). You give it a `build(kind,id)` per kind;
246
+ it interpolates server/remote entities and, for entities you own (`controller === sessionId`), runs your `motion` fn and
247
+ uploads. **Never call `room.uploadEntity` directly** — register through `EntityScene`.
248
+
249
+ ```ts
250
+ const scene = new EntityScene({
251
+ room,
252
+ build: (kind, id) => ({ object3d: makeMesh(kind), onUpdate: (e, dt) => {/* render off e.vars */} }),
253
+ maxSpeed: { pet: 12 }, // MUST mirror the DSL maxSpeed (reconcile clamp reads this)
254
+ motion: { pet: (e, dt, ctx) => ctx.seekNearest(6, 1) }, // only for entities YOU host
255
+ });
256
+ // each frame: scene.update(dt)
257
+ ```
258
+
259
+ ### Networked physics — colliding dynamic bodies
260
+ Add a `physics` block to an `authority:"owner"` kind to make it a **client-simulated dynamic rigid body** (a ball, a
261
+ puck, a car) that collides with world geometry, players, and other physics bodies. Absent ⇒ today's kinematic
262
+ position-only path. The owner runs the Rapier sim locally + uploads pos/velocity/orientation; remotes reproduce it.
263
+
264
+ ```jsonc
265
+ "ball": { "authority":"owner", "maxSpeed":18, "transferPolicy":"takeover",
266
+ "physics": {
267
+ "bodyType":"dynamic",
268
+ "shape": {"type":"sphere","radius":0.6}, // or {"type":"box","halfExtents":[x,y,z]} / {"type":"capsule","halfHeight":h,"radius":r}
269
+ "mass":1, "restitution":0.6, "friction":0.6, // optional too: linearDamping, angularDamping, maxAngularSpeed, collidesWith:["<kind>"]
270
+ "claimOnContact":true, "revertOnRest":true } }
271
+ ```
272
+
273
+ Field defaults: `mass` 1, `restitution` 0.2, `friction` 0.5, `linearDamping` 0, `angularDamping` 0.05,
274
+ `maxAngularSpeed` derived from `maxSpeed`. `revertOnRest` defaults **off** for a `fixed` body, **on** for a `shared` one.
275
+
276
+ **The arbitration matrix — each flag is opt-in and composes per kind:**
277
+ - **Base (no flags)** — `physics` + `authority:"owner"` + `transferPolicy:"fixed"` (the default): ONE client hosts +
278
+ simulates the body for its whole life and it **never transfers**; remotes see a kinematic proxy at the owner's stream
279
+ (collision response is favor-owner). Pair with `shared:true` + `ownerLifecycle:"hostMigrate"` for a **game-owned**
280
+ body the server re-elects a host for on leave (a host-migrated physics object, still no per-contact handoff).
281
+ - **+ `claimOnContact:true`** — authority-transfer: the server hands control to whoever touches the body (usually with
282
+ `revertOnRest:true` to release it to the server at rest). **Requires `transferPolicy:"takeover"`.** For ONE contested,
283
+ un-owned object (ball/puck) — the `physics-football` template.
284
+ - **+ `dualSimOnContact:true`** — dual-sim: each player keeps their OWN body; on contact both clients simulate both and
285
+ reconcile favor-local. **Requires `transferPolicy:"fixed"`.** For player-driven bodies (bumper cars/derby) — the
286
+ `physics-bumper` template.
287
+
288
+ Cross-field rules (publish enforces): a `physics` block **requires `authority:"owner"`**; `claimOnContact` ⇒
289
+ `transferPolicy:"takeover"`; `dualSimOnContact` ⇒ `transferPolicy:"fixed"`; `collidesWith` names declared kinds. A
290
+ **hybrid** (physics + a non-static `motion`) re-bases its server path on release via `rejoinOnRelease`.
291
+
292
+ **Client:** physics kinds run through `EntityScene` too — its `build` returns a `DynamicBody` (set as `physics` on the
293
+ handle) created against one shared `SharedPhysicsWorld` you `step(dt)` each frame; for dual-sim you also bind a
294
+ `DualSimController`. Mirror `maxSpeed` (and the shape) between the DSL and the client. The templates show the full
295
+ wiring. Fast/twitchy physics may opt the room up to `uploadHz:20` (§1) — which caps `maxPlayers` at 12. Cap:
296
+ **`physicsKinds` ≤ 8** per world.
297
+
298
+ ---
299
+
300
+ ## 10. Zones
301
+
302
+ Static volumes (`zones: []`) or entity-attached (`entities.<k>.zone`). Drive `zoneEnter`/`zoneExit`/`zoneInside`.
303
+
304
+ ```jsonc
305
+ "zones": [ { "id":"base", "shape":"box", "center":[0,0,0], "size":[10,4,10],
306
+ "tracks":"players", "requireDwell":0, "debounce":0 } ]
307
+ ```
308
+ `shape`: `"box"` (`center` + full `size` `[x,y,z]` — full dimensions, **not** half-extents; note this differs from a `physics.shape` box, which uses `halfExtents`) or `"sphere"` (`center`+`radius`). `tracks`: `"players"` (default) or
309
+ `"entity:<kind>"`. `requireDwell` (s before a fresh enter fires) / `debounce` (s an exit suppresses re-enter) optional.
310
+ Attached zones add `offset` and omit `id`. Caps: `zones` ≤64, extent ≤10000.
311
+
312
+ ## 11. Timers
313
+
314
+ ```jsonc
315
+ "timers": { "round": {}, "cooldown": { "keyed":"player" } }
316
+ ```
317
+ Room-scoped by default; `keyed:"player"` / `keyed:"entity:<kind>"` makes per-member instances (`startTimer`/`timerElapsed`
318
+ then need/bind a `key`). Arm with `startTimer` (`seconds` floors at 0.05). Read `timerRemaining`. Caps: ≤32.
319
+
320
+ ## 12. State machine
321
+
322
+ ```jsonc
323
+ "states": { "initial":"lobby", "phases":["lobby","play","results"],
324
+ "joinPolicy": { "play": { "joinable":false, "onLateJoin":[ /* spectate setup */ ] } } }
325
+ ```
326
+ `room.phase` is the current phase; change it with `transitionTo` (fires `stateExit`→`stateEnter`). `joinPolicy.<phase>`
327
+ controls late-join: `joinable:false` runs `onLateJoin` instead of the normal `playerJoin` rules (spectate-until-next-round).
328
+ Caps: phases ≤32, cascade depth ≤16.
329
+
330
+ ## 13. Broadcast events (server → client)
331
+
332
+ ```jsonc
333
+ "events": { "scored": { "payload": { "by": {"type":"ref","of":"player"}, "points": {"type":"number"} } } }
334
+ ```
335
+ Fire with the `broadcast` effect; clients receive via `room.onMessage("scored", cb)`. Payload field types: scalars, `ref`,
336
+ or a **collection snapshot** (`{type:"list",of:…}` / `{type:"counterMap",keys:…}` mirroring a declared var — e.g. a
337
+ leaderboard). `to`: `"all"`, a ref, or `{team:"<playerVar>=<value>"}` (matched stringified — `<value>` is compared as a string). Caps: events ≤64, fields ≤16.
338
+
339
+ ## 14. Actions (client → server)
340
+
341
+ ```jsonc
342
+ "actions": { "claimHit": { "args": { "target": {"type":"ref","of":"player"} } } }
343
+ ```
344
+ Clients call `room.sendAction("claimHit", { target: id })`; the server validates args (types/bounds/liveness) then fires
345
+ `{when:{on:"action",name:"claimHit"}}` with `self` = the actor and `action.args.<name>` readable. Arg types: scalars or
346
+ `ref`. Caps: actions ≤64, args ≤16. **Actions are the cheat-resistant input path** (validated server-side) — prefer them
347
+ over owner-entity tricks for anything that affects other players.
348
+
349
+ ---
350
+
351
+ ## 15. Consuming declared state on the client
352
+
353
+ The SDK gives you a `HelixRoom` (`Helix.multiplayer.joinRoom()`; see `multiplayer-world.md`). Read declared state off
354
+ `room.state`; you never write it directly — you `sendState` (your own avatar), `sendAction`, `sendAbility`, or host entities
355
+ via `EntityScene`.
356
+
357
+ - `room.state.roomVars["score"]`, `room.state.players[id].vars["team"]`, `room.state.entities[id]` (`.vars`/`.position`/`.controller`).
358
+ - `room.onStateChange(cb)` · `room.onAdd("entities", cb)` / `onRemove` · `room.onMessage("<event>", cb)` (broadcasts).
359
+ - `room.sendAction(name, args)` · `room.sendAbility(id, active)` · `room.sendState(input)` (your avatar, ~10 Hz, throttled for you).
360
+
361
+ **Footguns (every template handles these — copy them):** `room.state.players`/`entities` are live Colyseus maps mutated in
362
+ place → **copy values you cache** (don't alias). **Skip your own `sessionId`** when spawning remotes. **Guard
363
+ `roomVars`/`vars` until the first patch** (empty on frame 0). **Mirror an entity's `maxSpeed`** between the DSL and the
364
+ `EntityScene` options. Let `EntityScene`/the SDK handle source-time (`posT`) interpolation.
365
+
366
+ ---
367
+
368
+ ## 16. Limits — stay inside the caps (publish enforces them)
369
+
370
+ The validator rejects an over-cap config with a precise message. Key caps: `roomVars`/`playerVars` 64 · `rules` 128 ·
371
+ effects/rule 16 · `if` depth 8 / nodes 64 · `entityKinds` 32 · `entitiesPerKind` 256 · `entityVars` 64 · `zones` 64 ·
372
+ `timers` 32 · `phases` 32 · `events`/`actions` 64 · `listMaxLen` 256 · `counterKeys` 64 · `recordFields` 8 ·
373
+ `stringMaxLen` 1024 · `physicsKinds` 8 (§9) · `enum` values 64 · `joinPolicy` effects 16. Var **names** are identifiers
374
+ ≤32 chars. Also: `uploadHz:20` requires `maxPlayers ≤ 12`, and the platform caps **concurrent players per room** at 24
375
+ (extras spill into a fresh instance) regardless of `maxPlayers`. There is also a **per-tick evaluation budget**
376
+ (`tickNodeBudget` 100000) computed statically from your rules × loop fan-out × cascade depth — a too-large `forEach`
377
+ or deeply-nested expensive ops (`aggregate`/`nearest*`) fails publish. Simplify or spread work across ticks.
378
+
379
+ ## 17. Security — the cross-player write firewall
380
+
381
+ Clients are not trusted. `owner`-authority entity uploads are sanity-validated, not cheat-proof. Publish **statically blocks**
382
+ owner-entity logic from any cross-player **write OR read** — no `set`/`add`/`teleport`/`respawn`/`destroyEntity`/collection
383
+ mutation/ownership verb/keyed timer/`broadcast` aimed at another member, and no dereferencing a client-supplied ref to
384
+ *read* another member's var — that's a publish error, not a runtime no-op. For anything that affects *other*
385
+ players, use a declared **`action`** (server-validated) — e.g. the `claimHit` pattern: validate distance + cooldown in the
386
+ rule, then write to the target via `{ref: action.args.target, var: "health"}`.
387
+
388
+ ## 18. Where Tier-2 stops (use the escape hatch or a different layer)
389
+
390
+ Out of scope for the declarative DSL: **cheat-proof collision/raycast/hitscan** and **navmesh pathfinding / autonomous AI**
391
+ (straight-line `seek` is in; *navigating around obstacles* and *deciding* are not — run them on an `owner` entity
392
+ client-side). **Cross-session persistence** (saved currency/progression/inventory across sessions) is a separate storage
393
+ layer, not here — worlds reset per room. **Networked physics** (colliding rigid bodies) IS supported — see §9 "Networked
394
+ physics — colliding dynamic bodies." Nested collections / free-form JSON are intentionally unsupported (flat records only).
@@ -0,0 +1,123 @@
1
+ # Multiplayer template — `chrono-orchard`
2
+
3
+ **What it is.** Plant crops; they **ripen over real time**; harvest a mature one for points (worth more in good
4
+ weather); water to hurry them along. A `season` timer flips the weather on a random cadence. **Capability: the TIME
5
+ axis** — the `{op:now}` wall-clock, **dynamic timer durations**, growth-over-time, and cooldowns. Use this for
6
+ farming, idle/incremental, day-cycle, anything where elapsed real time matters. Lifted from the verified
7
+ `multiplayer-chrono-orchard` world.
8
+
9
+ > A character world (presence) **plus** time logic. Grammar: `read_doc({ name: "multiplayer-logic" })` §6
10
+ > (`{op:now}`), §11 (timers), §9 (entities).
11
+
12
+ ## 1. DSL used
13
+
14
+ - **`{op:now}`** (§6) — epoch **seconds**. Stamp `plantedAt: {op:now}` when a crop spawns; compute age as
15
+ `now - plantedAt`; gate the harvest on `age >= 15`; implement a cooldown as `now - lastWaterAt >= 3`.
16
+ - **Dynamic timers** (§11) — `startTimer`'s `seconds` is an **expression** (`8 + random*8`), and the `season`
17
+ timer **re-arms itself** on `timerElapsed` for a recurring, jittered cadence.
18
+ - **Entities** (§9) — `crop` (`plantedAt`, `growth`) with an attached harvest zone; a `tick everyN:5` rule ages
19
+ every crop (`set growth = now - plantedAt`).
20
+ - **Broadcast events** (§13) — `weatherChanged` / `watered`.
21
+ - **Actions** (§14) — `plant` (spawns a crop stamped with `now`); `water` (cooldown-gated; backdates `plantedAt`
22
+ to accelerate growth).
23
+
24
+ ## 2. The manifest — `public/helix.json`
25
+
26
+ ```json
27
+ {
28
+ "helixVersion": "0.3",
29
+ "title": "Chrono-Orchard",
30
+ "slug": "chrono-orchard",
31
+ "entry": "index.html",
32
+ "maxPlayers": 8,
33
+ "permissions": ["auth.profile", "multiplayer"],
34
+ "multiplayer": {
35
+ "authoritative": true,
36
+ "state": {
37
+ "roomVars": { "reward": { "type": "number", "default": 1 } },
38
+ "playerVars": { "score": { "type": "number", "default": 0 }, "lastWaterAt": { "type": "number", "default": 0 } }
39
+ },
40
+ "entities": {
41
+ "crop": {
42
+ "vars": { "plantedAt": { "type": "number", "default": 0 }, "growth": { "type": "number", "default": 0 } },
43
+ "zone": { "shape": "sphere", "radius": 1.2 }
44
+ }
45
+ },
46
+ "states": { "initial": "day", "phases": ["day"], "joinPolicy": { "day": { "joinable": true } } },
47
+ "timers": { "season": {} },
48
+ "actions": { "plant": {}, "water": {} },
49
+ "events": {
50
+ "weatherChanged": { "payload": { "reward": { "type": "number" } } },
51
+ "watered": { "payload": {} }
52
+ },
53
+ "rules": [
54
+ { "when": { "on": "action", "name": "plant" }, "then": [{ "do": "spawnEntity", "kind": "crop", "at": { "var": "self.position" }, "vars": { "plantedAt": { "op": "now" } } }] },
55
+ { "when": { "on": "tick", "everyN": 5 }, "then": [{ "do": "forEachEntity", "kind": "crop", "as": "c", "then": [{ "do": "set", "target": { "ref": "c", "var": "growth" }, "to": { "op": "-", "a": { "op": "now" }, "b": { "ref": "c", "var": "plantedAt" } } }] }] },
56
+ {
57
+ "when": { "on": "zoneEnter", "zone": "crop" },
58
+ "if": { "op": ">=", "a": { "op": "-", "a": { "op": "now" }, "b": { "ref": "source", "var": "plantedAt" } }, "b": 15 },
59
+ "then": [{ "do": "add", "target": "self.score", "by": { "var": "room.reward" } }, { "do": "destroyEntity", "entity": "source" }]
60
+ },
61
+ {
62
+ "when": { "on": "action", "name": "water" },
63
+ "if": { "op": ">=", "a": { "op": "-", "a": { "op": "now" }, "b": { "var": "self.lastWaterAt" } }, "b": 3 },
64
+ "then": [
65
+ { "do": "set", "target": "self.lastWaterAt", "to": { "op": "now" } },
66
+ { "do": "forEachEntity", "kind": "crop", "as": "c", "then": [{ "do": "set", "target": { "ref": "c", "var": "plantedAt" }, "to": { "op": "-", "a": { "ref": "c", "var": "plantedAt" }, "b": 5 } }] },
67
+ { "do": "broadcast", "event": "watered", "to": "all", "payload": {} }
68
+ ]
69
+ },
70
+ { "when": { "on": "stateEnter", "phase": "day" }, "then": [{ "do": "startTimer", "timer": "season", "seconds": { "op": "+", "a": 8, "b": { "op": "*", "a": { "op": "random" }, "b": 8 } } }] },
71
+ {
72
+ "when": { "on": "timerElapsed", "timer": "season" },
73
+ "then": [
74
+ { "do": "set", "target": "room.reward", "to": { "op": "-", "a": 3, "b": { "var": "room.reward" } } },
75
+ { "do": "startTimer", "timer": "season", "seconds": { "op": "+", "a": 8, "b": { "op": "*", "a": { "op": "random" }, "b": 8 } } },
76
+ { "do": "broadcast", "event": "weatherChanged", "to": "all", "payload": { "reward": { "var": "room.reward" } } }
77
+ ]
78
+ }
79
+ ]
80
+ },
81
+ "supportsMobile": true,
82
+ "contentRating": "everyone",
83
+ "systems": { "humanoid-character": "^0.2" }
84
+ }
85
+ ```
86
+
87
+ `{op:now}` is the same clock on every rule, so `now - plantedAt` is a true elapsed-seconds age. `water` backdates
88
+ `plantedAt` (subtracts 5 s) to *fast-forward* growth — a neat trick. `startTimer.seconds` and the cooldown
89
+ thresholds are all plain expressions. `season` re-arms itself, so the weather toggles forever on a jittered timer.
90
+
91
+ ## 3. The client — `src/main.ts` (delta from `hangout`)
92
+
93
+ Presence + a crop render-proxy (server entities, like `collect-a-thon`) **scaled by `growth`**, plus the two
94
+ actions and the weather banner:
95
+
96
+ ```ts
97
+ const crops = new Map<string, { mesh: THREE.Object3D; state: EntityState }>();
98
+ if (room) {
99
+ room.onAdd('entities', (e, id) => { const m = makeSprout(); scene.add(m); crops.set(id, { mesh: m, state: e }); });
100
+ room.onRemove('entities', (_e, id) => { const c = crops.get(id); if (c) { scene.remove(c.mesh); crops.delete(id); } });
101
+ }
102
+
103
+ // frame loop (inside `if (room)`): grow each crop visually from its synced `growth` (clamped to a ripe size at ~15s)
104
+ for (const [, c] of crops) {
105
+ if (c.state.position) c.mesh.position.set(c.state.position.x, c.state.position.y, c.state.position.z);
106
+ const g = Math.min(1, Number((c.state.vars as Record<string, unknown>)?.growth ?? 0) / 15);
107
+ c.mesh.scale.setScalar(0.2 + 0.8 * g); // sprout → ripe
108
+ }
109
+
110
+ addEventListener('keydown', (e) => {
111
+ if (e.key === 'e') room.sendAction('plant');
112
+ else if (e.key === 'q') room.sendAction('water'); // server enforces the 3s cooldown
113
+ });
114
+ room.onMessage('weatherChanged', (m) => showBanner(Number(m.reward) > 1 ? '☀ Bumper crop! (reward up)' : '☁ Lean season'));
115
+ room.onMessage('watered', () => showBanner('💧 Watered — crops sped up'));
116
+ ```
117
+
118
+ **Footguns:** time is **server** time (`{op:now}`) — never compute elapsed from a client clock; read each crop's
119
+ live `growth` per frame; the `water` cooldown is server-enforced (the client just sends the intent).
120
+
121
+ ## 4. Build, validate, publish
122
+
123
+ `npm install` → `helix install` → `npm run build` → `validate_world` on `dist/` → `whoami` → `publish_world`.