@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.
- package/LICENSE +21 -0
- package/README.md +46 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.js +353 -0
- package/dist/server.js.map +1 -0
- package/dist/tsconfig.build.tsbuildinfo +1 -0
- package/docs/catalog.md +28 -0
- package/docs/character-world.md +506 -0
- package/docs/manifest.md +51 -0
- package/docs/multiplayer-logic.md +394 -0
- package/docs/multiplayer-templates/chrono-orchard.md +123 -0
- package/docs/multiplayer-templates/collect-a-thon.md +126 -0
- package/docs/multiplayer-templates/collections.md +116 -0
- package/docs/multiplayer-templates/hangout.md +184 -0
- package/docs/multiplayer-templates/obby.md +84 -0
- package/docs/multiplayer-templates/physics-bumper.md +153 -0
- package/docs/multiplayer-templates/physics-football.md +133 -0
- package/docs/multiplayer-templates/relic-bearers.md +140 -0
- package/docs/multiplayer-templates/server-motion.md +128 -0
- package/docs/multiplayer-templates/team-control.md +98 -0
- package/docs/multiplayer-templates/turn-arena.md +159 -0
- package/docs/multiplayer-templates/wave-survival.md +141 -0
- package/docs/multiplayer-world.md +231 -0
- package/docs/publishing.md +42 -0
- package/docs/sdk.md +117 -0
- package/docs/world-recipe.md +137 -0
- package/package.json +33 -0
|
@@ -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`.
|