@hypersoniclabs/helix-mcp 0.2.4 → 0.2.12
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 +81 -11
- package/dist/continuumCanary.d.ts +17 -0
- package/dist/continuumCanary.js +17 -0
- package/dist/continuumCanary.js.map +1 -0
- package/dist/server.d.ts +14 -1
- package/dist/server.js +4058 -162
- package/dist/server.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/dist/vehicleTools.d.ts +86 -0
- package/dist/vehicleTools.js +229 -0
- package/dist/vehicleTools.js.map +1 -0
- package/docs/avatar-face.md +115 -0
- package/docs/bridge.md +98 -0
- package/docs/bring-your-world.md +117 -0
- package/docs/catalog.md +69 -1
- package/docs/character-animation.md +442 -0
- package/docs/character-attachments.md +166 -0
- package/docs/character-world.md +785 -130
- package/docs/continuum.md +153 -0
- package/docs/items.md +73 -0
- package/docs/lighting-world.md +667 -0
- package/docs/locomotion-clip-spec.md +294 -0
- package/docs/manifest.md +31 -6
- package/docs/multiplayer-logic.md +460 -17
- package/docs/multiplayer-templates/chrono-orchard.md +36 -22
- package/docs/multiplayer-templates/collect-a-thon.md +28 -38
- package/docs/multiplayer-templates/collections.md +24 -24
- package/docs/multiplayer-templates/hangout.md +124 -111
- package/docs/multiplayer-templates/npc-wave.md +310 -0
- package/docs/multiplayer-templates/obby.md +13 -16
- package/docs/multiplayer-templates/persistent-progress.md +218 -0
- package/docs/multiplayer-templates/physics-bumper.md +27 -9
- package/docs/multiplayer-templates/physics-football.md +22 -8
- package/docs/multiplayer-templates/relic-bearers.md +12 -15
- package/docs/multiplayer-templates/server-motion.md +16 -19
- package/docs/multiplayer-templates/shooter-range.md +275 -0
- package/docs/multiplayer-templates/team-control.md +28 -15
- package/docs/multiplayer-templates/turn-arena.md +31 -22
- package/docs/multiplayer-templates/voice-radio.md +166 -0
- package/docs/multiplayer-templates/wave-survival.md +7 -8
- package/docs/multiplayer-templates/world-shop.md +240 -0
- package/docs/multiplayer-world.md +222 -129
- package/docs/npc-world.md +623 -0
- package/docs/publishing.md +108 -28
- package/docs/purchases.md +223 -0
- package/docs/scene-performance.md +64 -0
- package/docs/screenshots.md +140 -0
- package/docs/sdk.md +324 -5
- package/docs/shooter-worlds.md +537 -0
- package/docs/terrain.md +173 -0
- package/docs/upgrades.md +324 -0
- package/docs/vehicles.md +727 -0
- package/docs/world-inspect.md +156 -0
- package/docs/world-look.md +241 -0
- package/docs/world-recipe.md +65 -6
- package/package.json +15 -4
- package/skills/README.md +91 -0
- package/skills/helix-assets/SKILL.md +491 -0
- package/skills/helix-assets/references/asset-sources.md +143 -0
- package/skills/helix-assets/references/vault-api.md +105 -0
- package/skills/helix-avatar-qa/SKILL.md +85 -0
- package/skills/helix-avatars/SKILL.md +206 -0
- package/skills/helix-avatars/references/contract.md +166 -0
- package/skills/helix-avatars/references/dynamics.md +367 -0
- package/skills/helix-avatars/references/face.md +50 -0
- package/skills/helix-avatars/references/publish.md +76 -0
- package/skills/helix-avatars/references/qa.md +251 -0
- package/skills/helix-avatars/references/rigging.md +88 -0
- package/skills/helix-avatars/references/source-generated.md +190 -0
- package/skills/helix-avatars/references/source-model.md +90 -0
- package/skills/helix-avatars/references/source-rigid.md +90 -0
- package/skills/helix-avatars/references/source-vrm.md +61 -0
- package/skills/helix-gauntlet/SKILL.md +128 -0
- package/skills/helix-multiplayer/SKILL.md +150 -0
- package/skills/helix-multiplayer/references/dsl-capability-map.md +107 -0
- package/skills/helix-vehicles/SKILL.md +218 -0
- package/skills/helix-vehicles/references/addons.md +212 -0
- package/skills/helix-vehicles/references/appearance.md +339 -0
- package/skills/helix-vehicles/references/audio-import.md +138 -0
- package/skills/helix-vehicles/references/audio.md +580 -0
- package/skills/helix-vehicles/references/cabin.md +225 -0
- package/skills/helix-vehicles/references/host-manifest.md +174 -0
- package/skills/helix-vehicles/references/physics-from-specs.md +463 -0
- package/skills/helix-vehicles/references/publish.md +214 -0
- package/skills/helix-vehicles/references/qa.md +177 -0
- package/skills/helix-vehicles/references/reference-package.json +3481 -0
- package/skills/helix-vehicles/references/reference-package.md +69 -0
- package/skills/helix-vehicles/references/source-beamng.md +167 -0
- package/skills/helix-vehicles/references/source-concept.md +38 -0
- package/skills/helix-vehicles/references/source-model.md +100 -0
- package/skills/helix-vehicles/references/source-scratch.md +60 -0
- package/skills/helix-world-build/SKILL.md +376 -0
- package/skills/helix-world-build/references/config-gates.md +104 -0
- package/skills/helix-world-director/SKILL.md +210 -0
- package/skills/helix-world-qa/SKILL.md +371 -0
- package/skills/helix-world-qa/references/perf-budgets.md +240 -0
- package/skills/helix-world-qa/references/perf-handle.md +125 -0
- package/skills/helix-world-qa/references/visual-scorecard.md +85 -0
|
@@ -41,6 +41,8 @@ Lives in `public/helix.json` (manifest `helixVersion: "0.3"`, with `maxPlayers >
|
|
|
41
41
|
"states": { "initial": "…", "phases": […] }, // §12 state machine
|
|
42
42
|
"events": { "<name>": {…} }, // §13 server→client broadcasts
|
|
43
43
|
"actions": { "<name>": {…} }, // §14 client→server intents
|
|
44
|
+
"counters": { "<name>": {} }, // §18 world-global atomic totals
|
|
45
|
+
"leaderboards": { "<name>": {…} }, // §18 keep-best rankings
|
|
44
46
|
"rules": [ { "when": …, "if": …, "then": […] } ] // §3 behavior
|
|
45
47
|
}
|
|
46
48
|
```
|
|
@@ -75,7 +77,13 @@ their own `vars` (§9). A var declaration is a discriminated union on `type`:
|
|
|
75
77
|
```
|
|
76
78
|
|
|
77
79
|
**Reserved built-ins (read-only — don't redeclare):** players expose `position` (vec3), `connected` (bool), `active`
|
|
78
|
-
(bool)
|
|
80
|
+
(bool), `crouched` (bool) and `ragdolled` (bool — limp, whether alive or dead); the room exposes `phase` (string).
|
|
81
|
+
Read them like any var (`self.crouched`, `room.phase`). A `playerVars` entry named like any of these fails publish.
|
|
82
|
+
|
|
83
|
+
**`persistent: true`** makes a var survive the session (§18). On a **playerVar**: scalars only
|
|
84
|
+
(`number`/`string`/`boolean`/`vec3`), no `merge`. On a **roomVar**: scalars AND collections, and a `merge` policy is
|
|
85
|
+
then **required** (plus an optional `key` naming its own document). Never on entity vars, `ref`, or `list of ref`:
|
|
86
|
+
publish rejects those — a ref is a session identity.
|
|
79
87
|
|
|
80
88
|
---
|
|
81
89
|
|
|
@@ -92,7 +100,8 @@ their own `vars` (§9). A var declaration is a discriminated union on `type`:
|
|
|
92
100
|
`destroyEntity`→`entityDestroy`, `takeover`/`requestOwnership`→`ownershipChanged`) queue and drain **breadth-first
|
|
93
101
|
after** the primary rules, bounded by `maxCascadeDepth` (16). Publish rejects cascades that could cycle. (`varReached`
|
|
94
102
|
is separate — it is edge-checked once at the **end** of the rule phase, not part of the cascade drain.)
|
|
95
|
-
- Caps: `rules` ≤
|
|
103
|
+
- Caps: `rules` ≤768, effects per `then` ≤16, `if` depth ≤8 / ≤64 nodes. (The rule cap is a declaration
|
|
104
|
+
ceiling — the per-tick `tickNodeBudget` in §16 is unchanged and binds long before it.)
|
|
96
105
|
|
|
97
106
|
---
|
|
98
107
|
|
|
@@ -107,6 +116,7 @@ their own `vars` (§9). A var declaration is a discriminated union on `type`:
|
|
|
107
116
|
| `zoneInside` | `zone` | `self`; `source` = carrier (attached zones) | every tick while inside |
|
|
108
117
|
| `playerContact` | `radius` | `self`, `other` | once per pair entering `radius` (the tag primitive) |
|
|
109
118
|
| `action` | `name` | `self` = actor | a client sent a declared action (args pre-validated); read `action.args.<x>` |
|
|
119
|
+
| `purchase` | — | `self` = the buyer | the platform settled an in-world purchase for this seat; read `purchase.productKey` / `purchase.purchaseId` (§20) |
|
|
110
120
|
| `stateEnter` / `stateExit` | `phase` | — (room) | on `transitionTo` in/out of a phase (cascade) |
|
|
111
121
|
| `timerElapsed` | `timer` | `self` = the timer's key (if keyed) | a timer deadline passed |
|
|
112
122
|
| `entitySpawn` / `entityDestroy` | `kind` | `self` = the entity | once on spawn / destroy |
|
|
@@ -157,6 +167,30 @@ A `HelixExpr` is one of:
|
|
|
157
167
|
- `{"op":"hostLoad","of":<ref>}` → number of entities that player controls
|
|
158
168
|
- `{"op":"sameRef","a":<ref>,"b":<ref>}` → boolean (identity; false if either unset)
|
|
159
169
|
|
|
170
|
+
**Entitlements** (in-world purchases — §20). `of` is **required** on both (no implicit `self`) and must be a player:
|
|
171
|
+
- `{"op":"hasPass","passKey":"<key>","of":<ref>}` → boolean — does that player hold the pass?
|
|
172
|
+
- `{"op":"balanceOf","code":"<code>","of":<ref>}` → number — their balance of that currency
|
|
173
|
+
|
|
174
|
+
**Weapon damage** (guns as published definitions — the `shooter-range` template is the worked config):
|
|
175
|
+
- `{"op":"weaponDamage","of":<ref>,"target":<ref>?,"part":<string expr>?,"default":<number>?}` → number — the
|
|
176
|
+
damage of the gun the `of` player is HOLDING, read from its PUBLISHED definition (base × pellets; with `target`,
|
|
177
|
+
the definition's distance falloff applies over the of→target distance; a `part` resolving to `"head"` then
|
|
178
|
+
multiplies by the definition's `damage.headMultiplier` — publish-capped 1..3, absent = ×1, any other part
|
|
179
|
+
value = ×1. Pattern: `"part": {"var":"action.args.bodyPart"}` off a string-enum action arg — see
|
|
180
|
+
`shooter-worlds.md` §damage for both rule shapes). Requires the block-level
|
|
181
|
+
**`weaponItems`** declaration: `"weaponItems": ["<vaultAssetId>@<version>", …]` (≤16) — the room fetches
|
|
182
|
+
each pinned, checksummed definition ITSELF, so the number is server-trusted and a client can never name
|
|
183
|
+
its own damage. A player holding no declared gun yields `default` (0 when omitted) — your rule still owns
|
|
184
|
+
the consequences (clamp, scale, ignore).
|
|
185
|
+
- `{"op":"itemDamage","item":"<vaultAssetId>@<version>","default":<number>?}` → number — the damage of the
|
|
186
|
+
definition that literal PIN names, whoever is holding what (base × pellets, room-resolved). The pin must
|
|
187
|
+
itself be one of the declared `weaponItems`, or the world fails validation; any miss yields `default` (0
|
|
188
|
+
when omitted). NO falloff leg by design — a blast's distance term is blast-point→victim, which your own
|
|
189
|
+
`forEachPlayer` rule computes.
|
|
190
|
+
- **HELD vs THROWN:** `weaponDamage` for a weapon still in the hand (a rocket claimed at the impact),
|
|
191
|
+
`itemDamage` for one that has left it (a grenade fuse) — there `weaponDamage` silently returns whatever
|
|
192
|
+
gun the thrower now holds. Explosives, rule by rule: `shooter-worlds.md` §9.
|
|
193
|
+
|
|
160
194
|
---
|
|
161
195
|
|
|
162
196
|
## 7. Refs (`HelixRef`)
|
|
@@ -181,7 +215,9 @@ pattern for cross-player writes, subject to the firewall in §17).
|
|
|
181
215
|
|
|
182
216
|
**State** `{"do":"add","target":<lv>,"by":<expr>}` · `{"do":"set","target":<lv>,"to":<expr>}` ·
|
|
183
217
|
`{"do":"setRef","target":<lv>,"to":<ref>|null}`
|
|
184
|
-
**Movement** `{"do":"teleport"|"respawn","player":<ref>,"to":<vec3>}` (server-authoritative; re-anchors the move gate)
|
|
218
|
+
**Movement** `{"do":"teleport"|"respawn","player":<ref>,"to":<vec3>}` (server-authoritative; re-anchors the move gate).
|
|
219
|
+
The ONLY legal way to move a player across the map: a client that teleports its own body trips the movement gate and its
|
|
220
|
+
avatar freezes for everyone else (wire `LocalReconciler` to land these locally; round resets = `forEachPlayer` → `respawn`)
|
|
185
221
|
**Loops**
|
|
186
222
|
- `{"do":"forEachPlayer","as":"p","where":<expr>?,"includeEliminated":false?,"then":[…]}`
|
|
187
223
|
- `{"do":"forEachEntity","kind":"<k>","as":"e","where":?,"then":[…]}`
|
|
@@ -201,6 +237,11 @@ pattern for cross-player writes, subject to the firewall in §17).
|
|
|
201
237
|
|
|
202
238
|
**Turns & elimination** `{"do":"advanceTurn","order":<lv list-of-ref>,"index":<lv number>}` (skips inactive, wraps) ·
|
|
203
239
|
`{"do":"eliminate","player":<ref>}` / `{"do":"revive","player":<ref>}` (eliminated = `active:false`, still connected/spectating)
|
|
240
|
+
**Persistence (§18)** `{"do":"save","player":<ref>}` · `{"do":"saveRoom","key":"<destination>"?}` ·
|
|
241
|
+
`{"do":"increment","counter":"<c>","field":"<f>"?,"by":<expr>}` ·
|
|
242
|
+
`{"do":"submitScore","board":"<b>","player":<ref>,"score":<expr>}`
|
|
243
|
+
**Achievements (§19)** `{"do":"awardAchievement","key":"<key>","player":<ref>}`
|
|
244
|
+
**In-world purchases (§20)** `{"do":"consume","code":"<code>","amount":<literal int 1..1000000000>,"player":"self"}`
|
|
204
245
|
|
|
205
246
|
---
|
|
206
247
|
|
|
@@ -228,6 +269,7 @@ Server- or client-hosted objects beyond players. Declare per kind under `entitie
|
|
|
228
269
|
| `idleTimeout` | seconds | a hostless shared entity despawns after this |
|
|
229
270
|
| `zone` | attached zone | an entity-attached spatial volume (§10) |
|
|
230
271
|
| `physics` | physics block | opt-in client-simulated **dynamic rigid body** (colliding) — needs `authority:"owner"`; see "Networked physics" below |
|
|
272
|
+
| `group` | name (`[a-z0-9-]{1,32}`) | atomic-claim group for MULTI-body sets (a pool rack) — requires `physics` + `authority:"owner"` + `transferPolicy:"takeover"`; see "Group claims" (§9a) |
|
|
231
273
|
| `rejoinOnRelease` | `nearestPoint` (default) \| `timeIndex` \| `seekBack` | for a HYBRID (physics + non-static `motion`): how it re-bases its server path when released |
|
|
232
274
|
|
|
233
275
|
**Motion** types: `{"type":"linear","velocity":[…]}`, `{"type":"orbit","center":[…],"radius":r,"speed":rad/s}` (circle in the XZ plane),
|
|
@@ -295,6 +337,36 @@ handle) created against one shared `SharedPhysicsWorld` you `step(dt)` each fram
|
|
|
295
337
|
wiring. Fast/twitchy physics may opt the room up to `uploadHz:20` (§1) — which caps `maxPlayers` at 12. Cap:
|
|
296
338
|
**`physicsKinds` ≤ 8** per world.
|
|
297
339
|
|
|
340
|
+
### 9a. Group claims — an atomic rack (many bodies, one break)
|
|
341
|
+
|
|
342
|
+
`claimOnContact` transfers ONE contested body. A **pool break** is sixteen: the shooter must own the whole rack
|
|
343
|
+
for the same tick or the balls desync into per-ball tugs-of-war. That is what an entity **`group`** is for:
|
|
344
|
+
|
|
345
|
+
```jsonc
|
|
346
|
+
"entities": { "ball": { "authority":"owner", "maxSpeed":18, "transferPolicy":"takeover", "group":"rack",
|
|
347
|
+
"physics": { "bodyType":"dynamic", "shape":{"type":"sphere","radius":0.29} } } },
|
|
348
|
+
"actions": { "breakShot": { "args": { "dir": {"type":"vec3"} }, "claimGroup": "rack" } },
|
|
349
|
+
"rules": [ { "when": {"on":"action","name":"breakShot"}, "then": [ /* score/turn logic */ ] } ]
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
Publish enforces all three prerequisites on the kind (`physics`, `authority:"owner"`, `transferPolicy:"takeover"`)
|
|
353
|
+
and that a `{when:{on:"action",name:…}}` rule exists for the `claimGroup` action. Client sequence, all in ONE tick:
|
|
354
|
+
`room.sendAction('breakShot', …)` → wake your local bodies → apply the impulse → let `EntityScene` upload.
|
|
355
|
+
|
|
356
|
+
Two behaviors you cannot guess:
|
|
357
|
+
- **A contested claim drops the ENTIRE action, rule included.** If any member is held by another actor inside the
|
|
358
|
+
minimum hold window, nothing transfers and your rule never fires — the world must not assume the action ran
|
|
359
|
+
(drive follow-up state off the rule's effects, never off having sent the action).
|
|
360
|
+
- **`groupRestSnapshot` is the authoritative settle.** When every member has been at rest ~1.5 s the room broadcasts
|
|
361
|
+
`room.onMessage('groupRestSnapshot', { group, seq, entities:[{id,pos,rot}] })` (late joiners get a replay).
|
|
362
|
+
Hard-set your transforms from it and discard stale ones by `seq` — this is what makes every table agree on the
|
|
363
|
+
final rack.
|
|
364
|
+
|
|
365
|
+
There is no engine helper for either half yet — the wake/impulse and the snapshot-apply are world code.
|
|
366
|
+
**Compatibility:** a room build that predates group claims silently ignores `claimGroup` — the action still fires,
|
|
367
|
+
just without atomicity, and no snapshot ever arrives. Publish passes either way, so verify the behavior on the
|
|
368
|
+
dev stack (two clients, contest the claim) before shipping a world that depends on it.
|
|
369
|
+
|
|
298
370
|
---
|
|
299
371
|
|
|
300
372
|
## 10. Zones
|
|
@@ -307,7 +379,7 @@ Static volumes (`zones: []`) or entity-attached (`entities.<k>.zone`). Drive `zo
|
|
|
307
379
|
```
|
|
308
380
|
`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
381
|
`"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` ≤
|
|
382
|
+
Attached zones add `offset` and omit `id`. Caps: `zones` ≤256, extent ≤10000.
|
|
311
383
|
|
|
312
384
|
## 11. Timers
|
|
313
385
|
|
|
@@ -315,7 +387,7 @@ Attached zones add `offset` and omit `id`. Caps: `zones` ≤64, extent ≤10000.
|
|
|
315
387
|
"timers": { "round": {}, "cooldown": { "keyed":"player" } }
|
|
316
388
|
```
|
|
317
389
|
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: ≤
|
|
390
|
+
then need/bind a `key`). Arm with `startTimer` (`seconds` floors at 0.05). Read `timerRemaining`. Caps: ≤192.
|
|
319
391
|
|
|
320
392
|
## 12. State machine
|
|
321
393
|
|
|
@@ -325,7 +397,7 @@ then need/bind a `key`). Arm with `startTimer` (`seconds` floors at 0.05). Read
|
|
|
325
397
|
```
|
|
326
398
|
`room.phase` is the current phase; change it with `transitionTo` (fires `stateExit`→`stateEnter`). `joinPolicy.<phase>`
|
|
327
399
|
controls late-join: `joinable:false` runs `onLateJoin` instead of the normal `playerJoin` rules (spectate-until-next-round).
|
|
328
|
-
Caps: phases ≤
|
|
400
|
+
Caps: phases ≤64, cascade depth ≤16.
|
|
329
401
|
|
|
330
402
|
## 13. Broadcast events (server → client)
|
|
331
403
|
|
|
@@ -334,7 +406,7 @@ Caps: phases ≤32, cascade depth ≤16.
|
|
|
334
406
|
```
|
|
335
407
|
Fire with the `broadcast` effect; clients receive via `room.onMessage("scored", cb)`. Payload field types: scalars, `ref`,
|
|
336
408
|
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 ≤
|
|
409
|
+
leaderboard). `to`: `"all"`, a ref, or `{team:"<playerVar>=<value>"}` (matched stringified — `<value>` is compared as a string). Caps: events ≤256, fields ≤16.
|
|
338
410
|
|
|
339
411
|
## 14. Actions (client → server)
|
|
340
412
|
|
|
@@ -343,9 +415,13 @@ leaderboard). `to`: `"all"`, a ref, or `{team:"<playerVar>=<value>"}` (matched s
|
|
|
343
415
|
```
|
|
344
416
|
Clients call `room.sendAction("claimHit", { target: id })`; the server validates args (types/bounds/liveness) then fires
|
|
345
417
|
`{when:{on:"action",name:"claimHit"}}` with `self` = the actor and `action.args.<name>` readable. Arg types: scalars or
|
|
346
|
-
`ref`. Caps: actions ≤
|
|
418
|
+
`ref`. Caps: actions ≤256, args ≤16. **Actions are the cheat-resistant input path** (validated server-side) — prefer them
|
|
347
419
|
over owner-entity tricks for anything that affects other players.
|
|
348
420
|
|
|
421
|
+
An action may also declare `"claimGroup": "<group>"` naming a declared entity `group` (§9a): the room then treats the
|
|
422
|
+
action as an atomic ownership claim over every live member of that group before its rule fires (all-or-nothing — see §9a
|
|
423
|
+
for the drop semantics). Publish requires a matching `{when:{on:"action",name:…}}` rule to exist.
|
|
424
|
+
|
|
349
425
|
---
|
|
350
426
|
|
|
351
427
|
## 15. Consuming declared state on the client
|
|
@@ -367,28 +443,395 @@ place → **copy values you cache** (don't alias). **Skip your own `sessionId`**
|
|
|
367
443
|
|
|
368
444
|
## 16. Limits — stay inside the caps (publish enforces them)
|
|
369
445
|
|
|
370
|
-
The validator rejects an over-cap config with a precise message. Key caps: `roomVars`/`playerVars`
|
|
371
|
-
effects/rule 16 · `if` depth 8 / nodes 64 · `entityKinds` 32 · `entitiesPerKind` 256 · `entityVars` 64 · `zones`
|
|
372
|
-
`timers`
|
|
373
|
-
`stringMaxLen` 1024 · `physicsKinds` 8 (§9) · `enum` values 64 · `joinPolicy` effects 16
|
|
446
|
+
The validator rejects an over-cap config with a precise message. Key caps: `roomVars`/`playerVars` 384 · `rules` 768 ·
|
|
447
|
+
effects/rule 16 · `if` depth 8 / nodes 64 · `entityKinds` 32 · `entitiesPerKind` 256 · `entityVars` 64 · `zones` 256 ·
|
|
448
|
+
`timers` 192 · `phases` 64 · `events`/`actions` 256 · `listMaxLen` 256 · `counterKeys` 64 · `recordFields` 8 ·
|
|
449
|
+
`stringMaxLen` 1024 · `physicsKinds` 8 (§9) · `enum` values 64 · `joinPolicy` effects 16 · `persistentPlayerVars` 16 ·
|
|
450
|
+
`persistedPlayerBytes` 8192 · `persistentRoomVars` 16 · `persistentRoomKeys` 6 · `persistedRoomBytes` 32768 per document ·
|
|
451
|
+
`counters` 16 · `counterFields` 64 per counter · `leaderboards` 8 (§18). Var **names** are identifiers
|
|
374
452
|
≤32 chars. Also: `uploadHz:20` requires `maxPlayers ≤ 12`, and the platform caps **concurrent players per room** at 24
|
|
375
453
|
(extras spill into a fresh instance) regardless of `maxPlayers`. There is also a **per-tick evaluation budget**
|
|
376
454
|
(`tickNodeBudget` 100000) computed statically from your rules × loop fan-out × cascade depth — a too-large `forEach`
|
|
377
455
|
or deeply-nested expensive ops (`aggregate`/`nearest*`) fails publish. Simplify or spread work across ticks.
|
|
456
|
+
**The raised declaration caps did NOT raise this budget** — a composition-heavy world hits the tick budget long
|
|
457
|
+
before the 768-rule ceiling, so treat the declaration caps as headroom, not a target. (List-scan cost is charged
|
|
458
|
+
against the specific list a rule scans, so a large lookup table read only by `listAt` no longer taxes unrelated rules.)
|
|
459
|
+
`persistedPlayerBytes` is a **static worst-case estimate**, not a measured document: a persistent string counts its
|
|
460
|
+
declared `maxLen` (undeclared = the 1024 ceiling), so shrink a `maxLen` — or persist fewer vars — to get under it.
|
|
378
461
|
|
|
379
462
|
## 17. Security — the cross-player write firewall
|
|
380
463
|
|
|
381
464
|
Clients are not trusted. `owner`-authority entity uploads are sanity-validated, not cheat-proof. Publish **statically blocks**
|
|
382
465
|
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
|
|
466
|
+
mutation/ownership verb/keyed timer/`awardAchievement`/`broadcast` aimed at another member, and no dereferencing a client-supplied ref to
|
|
384
467
|
*read* another member's var — that's a publish error, not a runtime no-op. For anything that affects *other*
|
|
385
468
|
players, use a declared **`action`** (server-validated) — e.g. the `claimHit` pattern: validate distance + cooldown in the
|
|
386
469
|
rule, then write to the target via `{ref: action.args.target, var: "health"}`.
|
|
387
470
|
|
|
388
|
-
|
|
471
|
+
Money is stricter than this general firewall: `consume.player` is schema-locked to literal `"self"`, and the room
|
|
472
|
+
independently checks that literal before using the event-bound player. An authored ref can never select whose balance
|
|
473
|
+
is spent, even if an unvalidated config reaches the room.
|
|
474
|
+
|
|
475
|
+
## 18. Persistence — saves, room state, counters, leaderboards
|
|
476
|
+
|
|
477
|
+
**Worlds persist.** A player's declared vars can survive the session, a `roomVar` marked persistent survives the room,
|
|
478
|
+
and two world-global primitives accumulate across every instance and every day the world runs. A roomVar you do NOT
|
|
479
|
+
mark persistent still dies with the room — that is correct, match state is not progression. All of them are durable
|
|
480
|
+
storage: you declare them, the room writes them, any client can read them.
|
|
481
|
+
|
|
482
|
+
**Doctrine — keys are authority boundaries, JSON is everything else.** One writer per key; split into a new key only
|
|
483
|
+
when the *writer* changes, and keep everything else as fields inside one document. World-global state must be
|
|
484
|
+
**commutative** (a counter's `+n`) or **keep-best** (a board's conditional write), so concurrent instances of your world
|
|
485
|
+
can never conflict. Never read-modify-write a shared blob: two instances will silently overwrite each other.
|
|
486
|
+
|
|
487
|
+
### Persistent playerVars + the `save` verb — per-player progression
|
|
488
|
+
|
|
489
|
+
Mark a **scalar playerVar** `"persistent": true` (`number`/`string`/`boolean`/`vec3`), then write it with `save`:
|
|
490
|
+
|
|
491
|
+
```jsonc
|
|
492
|
+
"state": { "playerVars": {
|
|
493
|
+
"level": {"type":"number","default":1,"min":1,"max":99,"integer":true,"persistent":true},
|
|
494
|
+
"gems": {"type":"number","default":0,"min":0,"max":9999,"integer":true,"persistent":true},
|
|
495
|
+
"title": {"type":"string","default":"bronze","enum":["bronze","silver","gold"],"persistent":true},
|
|
496
|
+
"runStart": {"type":"number","default":0} // no flag → session-only, back to 0 next session
|
|
497
|
+
} },
|
|
498
|
+
"rules": [
|
|
499
|
+
{ "when": {"on":"action","name":"levelUp"},
|
|
500
|
+
"if": {"op":">=","a":{"var":"self.gems"},"b":5},
|
|
501
|
+
"then": [ {"do":"add","target":"self.gems","by":-5},
|
|
502
|
+
{"do":"add","target":"self.level","by":1},
|
|
503
|
+
{"do":"save","player":"self"} ] }, // save at the MOMENT that matters
|
|
504
|
+
{ "when": {"on":"playerJoin"}, // restored values are already in place here
|
|
505
|
+
"if": {"op":">=","a":{"var":"self.level"},"b":3},
|
|
506
|
+
"then": [ {"do":"broadcast","event":"veteran","to":"all","payload":{"level":{"var":"self.level"}}} ] }
|
|
507
|
+
]
|
|
508
|
+
```
|
|
509
|
+
|
|
510
|
+
- **Hydrated before join rules run** — the saved doc is loaded during auth and applied to the seat, so a `playerJoin`
|
|
511
|
+
rule reads restored values (the `veteran` rule above), not defaults.
|
|
512
|
+
- **`save` is event-driven by design** — fire it on the beat that changed something worth keeping ("on level up", "on
|
|
513
|
+
checkpoint", "on purchase"), never on a `tick` heartbeat. A save is debounced ~5 s and coalesced; the room also
|
|
514
|
+
flushes on disconnect, on leave and at shutdown, so you are not responsible for catching the exit.
|
|
515
|
+
- **Caps:** 16 persistent vars, `persistedPlayerBytes` 8192 per player (§16). **Publish fails** if a `save` appears with
|
|
516
|
+
no persistent var declared — that error means a forgotten `"persistent": true`, not a stray verb.
|
|
517
|
+
- **Failure mode:** a save that cannot be read plays the seat on defaults and **disables saving for that seat** — the
|
|
518
|
+
platform never overwrites a save it failed to read. A stored doc is re-validated against the CURRENT build on every
|
|
519
|
+
load: wrong types are discarded, numbers clamp to `min`/`max`, an enum miss falls back to the default, and vars you
|
|
520
|
+
deleted from the manifest are dropped. Renaming a var therefore resets it — treat persistent names as a schema.
|
|
521
|
+
- **Cannot:** a playerVar that is `ref`, `list` or `counterMap` — a ref to a player who no longer exists is meaningless
|
|
522
|
+
next session, and a player document holds scalar fields. For durable COLLECTIONS use persistent roomVars below.
|
|
523
|
+
|
|
524
|
+
### Persistent roomVars + the `saveRoom` verb — world state that outlives the room
|
|
525
|
+
|
|
526
|
+
Plain `roomVars` die with the room, which is right for match state. Mark one `"persistent": true` and it survives —
|
|
527
|
+
this is where guestbooks, world records, lifetime tallies and a live-editable MOTD live:
|
|
528
|
+
|
|
529
|
+
```jsonc
|
|
530
|
+
"state": { "roomVars": {
|
|
531
|
+
"notes": { "type":"list", "maxLen":10, "persistent":true, "merge":"append", "key":"guestbook",
|
|
532
|
+
"of": {"type":"record","fields":{"author":{"type":"string","maxLen":24},"text":{"type":"string","maxLen":80}}} },
|
|
533
|
+
"bestRun": { "type":"number", "default":999999, "persistent":true, "merge":"min" },
|
|
534
|
+
"tally": { "type":"counterMap", "keys":["visits","notes"], "persistent":true, "merge":"sum" },
|
|
535
|
+
"motd": { "type":"string", "default":"welcome", "maxLen":80, "persistent":true, "merge":"lastWrite" }
|
|
536
|
+
} },
|
|
537
|
+
"rules": [
|
|
538
|
+
{ "when": {"on":"action","name":"postNote"},
|
|
539
|
+
"then": [ {"do":"append","target":"room.notes",
|
|
540
|
+
"value":{"author":{"var":"action.args.author"},"text":{"var":"action.args.text"}}},
|
|
541
|
+
{"do":"addCount","target":"room.tally","key":"notes","by":1},
|
|
542
|
+
{"do":"saveRoom","key":"guestbook"} ] } // one destination; bare saveRoom persists all that changed
|
|
543
|
+
]
|
|
544
|
+
```
|
|
545
|
+
|
|
546
|
+
- **`merge` is REQUIRED on every persistent roomVar.** Many instances of your world run at once and all write the same
|
|
547
|
+
document, so the policy decides who wins. `lastWrite` (scalars) is **racy by declaration**; `max`/`min` (number),
|
|
548
|
+
`append` (list) and `sum` (counterMap) are conflict-free. There is no default — you must state which you are taking.
|
|
549
|
+
- **A `min` var's default must be WORSE than any real value** (999999 for a time, not 0), or the default itself wins
|
|
550
|
+
every merge and the record can never be set. Same in reverse for `max`.
|
|
551
|
+
- **`key` splits documents.** Without one a var lands in the shared default; with one it gets `mp:world:{key}` — its own
|
|
552
|
+
version guard and byte budget, so posting a note stops rewriting the rest of your world state. Up to 6 destinations.
|
|
553
|
+
- **A room reads its OWN view.** What it hydrated plus its own changes; other instances land in the stored document, not
|
|
554
|
+
in this room. Read the true cross-instance value with `Helix.dataStore.get("mp:world")`. Same as counters.
|
|
555
|
+
- **Caps:** 16 persistent roomVars, 6 destinations, 32 KB per document (§16). **Publish fails** on a `saveRoom` with no
|
|
556
|
+
persistent roomVar declared, or on a `key` naming a destination nothing declares.
|
|
557
|
+
- **Durability window:** entries live in room memory until the flush (~5 s debounce, 60 s ceiling), so a hard crash
|
|
558
|
+
loses the tail. Fine for boards and tallies — **wrong for anything a player paid for**: money-backed goods live in
|
|
559
|
+
server-held entitlements the backend writes at settle (§20), and per-player progression belongs on `save` or
|
|
560
|
+
`submitScore` because those are per-event.
|
|
561
|
+
- **Cannot:** `ref` or `list of ref` (session identities), and bulk data too large to be replicated state — ten thousand
|
|
562
|
+
placed objects can never be roomVars, so no amount of splitting reaches it.
|
|
563
|
+
|
|
564
|
+
### Counters — world-global atomic totals
|
|
565
|
+
|
|
566
|
+
`counters` are shared accumulators for community goals, boss HP, world votes, lifetime tallies — anything where every
|
|
567
|
+
instance adds into one number:
|
|
568
|
+
|
|
569
|
+
```jsonc
|
|
570
|
+
"counters": { "donations": {} },
|
|
571
|
+
"rules": [
|
|
572
|
+
{ "when": {"on":"action","name":"donate"},
|
|
573
|
+
"if": {"op":">=","a":{"var":"self.gems"},"b":1},
|
|
574
|
+
"then": [ {"do":"add","target":"self.gems","by":-1},
|
|
575
|
+
{"do":"increment","counter":"donations","by":1}, // field defaults to "value"
|
|
576
|
+
{"do":"increment","counter":"donations","field":"gifts","by":1} ] }
|
|
577
|
+
]
|
|
578
|
+
```
|
|
579
|
+
|
|
580
|
+
- **Instance-safe because increments commute.** The room accumulates deltas locally and flushes one call per counter
|
|
581
|
+
per ~10 s window — an `increment` is never a per-tick write, so it is cheap to fire from a hot rule.
|
|
582
|
+
- **Read from any client:** `Helix.dataStore.get('world:counter:donations')` → the field map (`{value, gifts}`). Poll it
|
|
583
|
+
or read it on join; the value is world-open, so a spectator page can render it too.
|
|
584
|
+
- **Caps:** 16 counters, 64 fields each, `by` a finite integer with |delta| ≤ 1e9 (§16).
|
|
585
|
+
- **Failure mode:** at-least-once. A flush retried after an ambiguous transport failure can double-apply one batch, so
|
|
586
|
+
a counter is a *total*, not a ledger — never derive money, ownership or an exact count of anything from it.
|
|
587
|
+
- **Cannot:** counters are **server-only**. A session has no increment path at all (`counter-server-only`); the value
|
|
588
|
+
moves through a rule or it does not move.
|
|
589
|
+
|
|
590
|
+
### Leaderboards — keep-best rankings
|
|
591
|
+
|
|
592
|
+
```jsonc
|
|
593
|
+
"leaderboards": { "main": {"size":10}, "bestTime": {"order":"asc","size":10} }, // desc default; asc = lower wins (times)
|
|
594
|
+
"rules": [
|
|
595
|
+
{ "when": {"on":"action","name":"levelUp"},
|
|
596
|
+
"then": [ {"do":"submitScore","board":"main","player":"self","score":{"var":"self.level"}} ] },
|
|
597
|
+
{ "when": {"on":"zoneEnter","zone":"runFinish"},
|
|
598
|
+
"if": {"op":">","a":{"var":"self.runStart"},"b":0},
|
|
599
|
+
"then": [ {"do":"set","target":"self.lastRun","to":{"op":"-","a":{"op":"now"},"b":{"var":"self.runStart"}}},
|
|
600
|
+
{"do":"submitScore","board":"bestTime","player":"self","score":{"var":"self.lastRun"}} ] }
|
|
601
|
+
]
|
|
602
|
+
```
|
|
603
|
+
|
|
604
|
+
- **Each board is ONE bounded document** keeping its declared top `size` entries (1..100, default 100), at most one
|
|
605
|
+
per player. Keep-best is the backend's atomic verdict: a worse score — or one below the board's cutoff — is a
|
|
606
|
+
normal no-op, and a retry writes the same result, so submit freely, including every lap of a race. A score that
|
|
607
|
+
doesn't place is NOT stored (the board is a podium, not an archive — keep a player's own history in their vars).
|
|
608
|
+
- **One board must be named `main`** (publish-checked): the platform surfaces a world's default board by that name.
|
|
609
|
+
- **Read from any client:** `Helix.leaderboard.top('bestTime', { limit: 10, order: 'asc' })` → ranked entries.
|
|
610
|
+
- **Caps:** 8 boards per build (§16), `size` ≤ 100, `limit` ≤ 100 per read. A board costs ONE durable key however
|
|
611
|
+
many players score — declare boards for what you'll display, and let `size` match it.
|
|
612
|
+
- **Cannot:** a session may submit only **its own** score (`leaderboard-self-only`), and a client-submitted entry is
|
|
613
|
+
flagged `source: 'session'` — client-claimed. Scores that must be trusted are submitted by a rule, like the two above.
|
|
614
|
+
- **Shape is the authority's:** a board's `order`/`size` are fixed when the board is created; the room's submits carry
|
|
615
|
+
the declared shape (so a republished declaration self-heals), while a session can never reshape an existing board
|
|
616
|
+
and may mint at most 8 new boards per world (`leaderboard-cap`).
|
|
617
|
+
|
|
618
|
+
## 19. Achievements — badges the platform vouches for
|
|
619
|
+
|
|
620
|
+
An achievement is a **per-world registry entry** (registered once, outside the bundle — it is NOT part of `helix.json`),
|
|
621
|
+
and earning one mints a **soulbound inventory item** on the player's account. Each entry carries an `unlockMode`, the
|
|
622
|
+
trust marker a player and the platform UI read to know whether a badge means anything: `room` (the authoritative room
|
|
623
|
+
awarded it — unforgeable), `criteria` (the backend verified evidence it owns), `platform` (first-party). **There is no
|
|
624
|
+
client-attested mode** — a world runs in the player's own browser, so it can never award its own achievements; from
|
|
625
|
+
inside a world `Helix.achievements` is read-only (`sdk.md`).
|
|
626
|
+
|
|
627
|
+
### Register the achievement BEFORE publishing the rule that awards it
|
|
628
|
+
|
|
629
|
+
Call **`register_achievement`** (or `helix achievement register <world-slug>`): `key`, `name`, a **required 2D icon
|
|
630
|
+
file**, and optionally `description`, `points` (0–1000, summed into gamerscore), `hidden`, a bespoke `.glb` trophy,
|
|
631
|
+
`unlockMode`, and — for `criteria` — the condition below. It is one creator-authenticated multipart round trip
|
|
632
|
+
(`POST /api/v1/achievements/worlds/:worldId`) that also mints the soulbound item behind the badge. `dryRun` validates
|
|
633
|
+
everything locally, logged out. `list_achievements` reads the registry back; `update_achievement` patches the
|
|
634
|
+
presentation (never the key, mode or criteria — see below).
|
|
635
|
+
|
|
636
|
+
The **`key`** is the stable slug your rules name: `^[a-z0-9][a-z0-9_-]{1,79}$` (2–80 chars, lowercase
|
|
637
|
+
letters/digits/`_`/`-`, starting alphanumeric), publish-checked in the DSL against the same charset registration uses.
|
|
638
|
+
|
|
639
|
+
**Every achievement has a graphic** — there is no icon-less badge, and a blank or sub-16px placeholder is refused by
|
|
640
|
+
both the tool and the server. If the human has no art, `generate_image` first. No trophy given, your icon is baked onto
|
|
641
|
+
the default plaque, so the badge is still placeable in a home.
|
|
642
|
+
|
|
643
|
+
**`unlockMode` defaults to `room`** (or `criteria`, the moment you declare a signal) — the two a world can actually
|
|
644
|
+
use. `platform` means first-party code awards it: nothing in your world can, so never pick it.
|
|
645
|
+
|
|
646
|
+
**Only presentation is patchable afterwards** (name/description/points/hidden/active). The key is what your rules name,
|
|
647
|
+
and re-declaring how a badge is earned would rewrite what every already-granted copy claims — register a new
|
|
648
|
+
achievement instead, and delist the old one with `active: false` (its holders keep theirs).
|
|
649
|
+
|
|
650
|
+
**The #1 gotcha: publish does NOT check the registry.** A rule awarding a key nobody registered publishes clean and is a
|
|
651
|
+
**counted runtime no-op** — the room queues the grant, the API answers "no such achievement", and the miss lands in the
|
|
652
|
+
room's write counters. Nothing surfaces in the world, and the player is told nothing. Register first, publish second, and
|
|
653
|
+
treat a key as a schema: renaming one needs a fresh registration.
|
|
654
|
+
|
|
655
|
+
### `awardAchievement` — the room awards the moment it saw
|
|
656
|
+
|
|
657
|
+
`{"do":"awardAchievement","key":"<key>","player":<ref>}` is the only in-world award path, and the reason `unlockMode:
|
|
658
|
+
"room"` is unforgeable: the room evaluated state it synced itself.
|
|
659
|
+
|
|
660
|
+
```jsonc
|
|
661
|
+
"events": { "achievementAwarded": { "payload": { "key": {"type":"string"} } } },
|
|
662
|
+
"rules": [
|
|
663
|
+
{ "when": {"on":"varReached","scope":"self","var":"level","cmp":">=","value":5},
|
|
664
|
+
"then": [ {"do":"awardAchievement","key":"level-5-champion","player":"self"},
|
|
665
|
+
{"do":"broadcast","event":"achievementAwarded","to":"self","payload":{"key":"level-5-champion"}} ] }
|
|
666
|
+
]
|
|
667
|
+
```
|
|
668
|
+
|
|
669
|
+
- **Fire-and-forget and idempotent.** The effect hands the key to the award lane and returns; the grant itself happens
|
|
670
|
+
off-tick, and the backend's grant is idempotent — a re-award is a no-op, so a rule that can retrigger needs no guard.
|
|
671
|
+
- **Pair every award with a `broadcast`** (as above). An award is a durable account write with **no in-world echo**: the
|
|
672
|
+
platform raises no banner while the player is in your world, so the toast/SFX/HUD line is the broadcast's job.
|
|
673
|
+
- **Unauthenticated seats are silent no-ops** — no account to credit (the `submitScore` precedent) — as is a room that
|
|
674
|
+
does not persist.
|
|
675
|
+
- **Players only.** An entity ref is a publish error, and an award is inside the cross-player firewall (§17): a
|
|
676
|
+
client-supplied ref can never pick who gets credited.
|
|
677
|
+
- **Costs 1 + the player-ref resolution** in the per-tick budget (§16) — a bound name is O(1), a ref-op is a scan.
|
|
678
|
+
|
|
679
|
+
### Criteria unlocks — the backend measures evidence it already owns
|
|
680
|
+
|
|
681
|
+
`unlockMode: "criteria"` hands the condition to the platform instead of a rule, so it needs no room at all and works in
|
|
682
|
+
a **single-player** world. One declared comparison — `{"signal":…,"op":"gte"|"lte"|"eq","value":<number>}` — plus that
|
|
683
|
+
signal's own field:
|
|
684
|
+
|
|
685
|
+
| `signal` | Extra field | Measures |
|
|
686
|
+
|---|---|---|
|
|
687
|
+
| `datastore` | `key` + `field` | a numeric field inside one per-user document (below) |
|
|
688
|
+
| `iwp` | `metric`: `count` (default) / `lix` | successful in-world purchases, or LIX spent |
|
|
689
|
+
| `analytics` | `eventName` | count of a **platform** analytics event in this world (`world_entered`, …) — a world cannot mint an event name for criteria |
|
|
690
|
+
| `playtime` | — | seconds the player has spent in this world |
|
|
691
|
+
|
|
692
|
+
**The `datastore` signal reads exactly two per-user documents, and which one you name IS the trust decision:**
|
|
693
|
+
|
|
694
|
+
```jsonc
|
|
695
|
+
{"signal":"datastore","op":"gte","value":5, "key":"mp:player:{userId}","field":"level"} // room-written ⇒ trustworthy
|
|
696
|
+
{"signal":"datastore","op":"gte","value":10, "key":"player:{userId}", "field":"stats.summits"} // client-written ⇒ self-reported
|
|
697
|
+
```
|
|
698
|
+
|
|
699
|
+
- `mp:player:{userId}` is the room's flushed **persistent playerVars** (§18) — `field` names the persistent var
|
|
700
|
+
DIRECTLY (declare `"persistent": true` on a scalar playerVar; the storage envelope around it is internal). Only the
|
|
701
|
+
room writes that document, so this is the criteria equivalent of a `room` award.
|
|
702
|
+
- `player:{userId}` is the player's own client-written save (`Helix.dataStore`, `sdk.md`). Accepted and supported
|
|
703
|
+
deliberately — it is how a single-player world unlocks anything — but the player can write it, so a badge on it is
|
|
704
|
+
self-reported. Use it for "finished the tutorial", not for the world's prestige badge.
|
|
705
|
+
- `field` is a dot-path of letters, digits and underscores (`"stats.summits"`), and the leaf must be a **real number**:
|
|
706
|
+
a stringified `"12"` never qualifies.
|
|
707
|
+
|
|
708
|
+
The same two, as `register_achievement` takes them (the tool flattens `key`/`field` to `criteriaKey`/`criteriaField`,
|
|
709
|
+
because `key` is already the achievement's own; `helix achievement register` uses `--criteria-key`/`--criteria-field`):
|
|
710
|
+
|
|
711
|
+
```jsonc
|
|
712
|
+
{ "worldSlug":"…", "key":"level_5", "name":"Level 5", "icon":"/abs/level5.png",
|
|
713
|
+
"signal":"datastore", "value":5, "criteriaKey":"mp:player:{userId}", "criteriaField":"level" }
|
|
714
|
+
```
|
|
715
|
+
|
|
716
|
+
**Latency.** `datastore` and `iwp` are evaluated within seconds of the evidence changing (the write, or the purchase
|
|
717
|
+
settling). `analytics` and `playtime` are aggregates nothing can poke, so an hourly sweep evaluates them. Every path
|
|
718
|
+
re-derives the measurement from STORED evidence, and registration itself runs one immediate backfill pass over that
|
|
719
|
+
evidence — so registering a criteria achievement later credits every player who ALREADY qualifies at that moment, on
|
|
720
|
+
any signal, without waiting for a sweep or their next write.
|
|
721
|
+
|
|
722
|
+
**Test the unlock BEFORE registering.** `helix dev` evaluates criteria locally off simulated evidence — seed the same
|
|
723
|
+
rows in `.helix/dev-achievements.json` (validated with the same rules as registration, no icon needed) and play to the
|
|
724
|
+
threshold; a `player:{userId}` criteria unlocks off real `Helix.dataStore` writes, an `mp:player:{userId}` one off the
|
|
725
|
+
debug menu's persistent-var control. Shape note: the seed stores criteria NESTED (`criteria: { signal, op, value, key,
|
|
726
|
+
field }` — the stored shape shown above), where `register_achievement` flattens to `criteriaKey`/`criteriaField`. See
|
|
727
|
+
the sdk doc's *Local development* section.
|
|
728
|
+
|
|
729
|
+
### The pattern worth copying — a threshold badge AND a moment badge
|
|
730
|
+
|
|
731
|
+
The `multiplayer-persistent-progress` template ships both halves, because they answer different questions:
|
|
732
|
+
|
|
733
|
+
- **A threshold** ("this player reached level 5") is a `criteria` achievement on `mp:player:{userId}` field `level`,
|
|
734
|
+
`gte` 5. No rule, no wiring, and measured from a document only the room can write — so it still reads true long after
|
|
735
|
+
the session that earned it.
|
|
736
|
+
- **A moment** ("this player became champion, live, in front of everyone") is a `room` award fired by the rule that saw
|
|
737
|
+
it happen, paired with the broadcast that puts the banner on screen.
|
|
738
|
+
|
|
739
|
+
Declare `level` persistent, `save` it on the beat that changes it, and both badges follow from that one var.
|
|
740
|
+
|
|
741
|
+
## 20. In-world purchases — the room's side of a sale
|
|
742
|
+
|
|
743
|
+
What a world SELLS is a per-world registry entry the creator registers, and the **shell** settles every sale
|
|
744
|
+
(`sdk.md` → `Helix.marketplace`). Fulfilment is the **backend's**: a settled purchase's `grants` land in the buyer's
|
|
745
|
+
**entitlements** — named passes and currency balances held server-side — and those are the durable truth for anything
|
|
746
|
+
a player paid for. The room reads that truth and spends from it; it never writes it. **Never mirror a pass or a
|
|
747
|
+
balance into a playerVar/roomVar**: a session copy resets on the next join while the entitlement does not, and the
|
|
748
|
+
two then disagree about money.
|
|
749
|
+
|
|
750
|
+
### The `purchase` event — a settled sale lands in the room
|
|
751
|
+
|
|
752
|
+
```jsonc
|
|
753
|
+
"counters": { "sales": {} },
|
|
754
|
+
"events": { "boughtVip": { "payload": { "who": {"type":"ref","of":"player"} } } },
|
|
755
|
+
"rules": [
|
|
756
|
+
{ "when": {"on":"purchase"}, // no params — filter in `if`
|
|
757
|
+
"if": {"op":"==","a":{"var":"purchase.productKey"},"b":"vip_pass"},
|
|
758
|
+
"then": [ {"do":"set","target":"self.tier","to":"vip"},
|
|
759
|
+
{"do":"save","player":"self"}, // durable — `tier` is a persistent playerVar (§18)
|
|
760
|
+
{"do":"increment","counter":"sales","by":1},
|
|
761
|
+
{"do":"broadcast","event":"boughtVip","to":"all","payload":{"who":"self"}} ] }
|
|
762
|
+
]
|
|
763
|
+
```
|
|
764
|
+
|
|
765
|
+
- **Nothing to wire.** The shell settles the purchase and the platform forwards the receipt to the room the buyer
|
|
766
|
+
occupies (including on the reload-recovery path) — the world calls `Helix.marketplace.purchaseProduct(…)` and this
|
|
767
|
+
rule fires with `self` = the buyer.
|
|
768
|
+
- **Two read-only fields, readable only here:** `purchase.productKey` (the key the creator registered; `""` if the
|
|
769
|
+
product has none) and `purchase.purchaseId`. They read like `action.args.<x>` — inside an `{on:"purchase"}` rule and
|
|
770
|
+
nowhere else — and the set is closed: nothing declares a purchase field. The event itself takes **no params**, so
|
|
771
|
+
branch on `productKey` in `if` (one rule per product).
|
|
772
|
+
- **Exactly once per purchase**, platform-enforced: a re-forward, a rejoin, a tab that died mid-sale and another
|
|
773
|
+
instance of your world all collapse to ONE firing. **So grant the consequences durably** — `save`, `increment`,
|
|
774
|
+
`submitScore`, `awardAchievement` — never into state a disconnect resets, because the rule will never fire again to
|
|
775
|
+
rebuild it.
|
|
776
|
+
- The buyer's entitlement snapshot is refreshed **before** the rule runs, so `hasPass`/`balanceOf` inside it already
|
|
777
|
+
see what this sale granted.
|
|
778
|
+
- A guest seat owns no purchase (no account to attribute it to), so nothing fires for one.
|
|
779
|
+
|
|
780
|
+
### `hasPass` / `balanceOf` — read the entitlement snapshot
|
|
781
|
+
|
|
782
|
+
```jsonc
|
|
783
|
+
{ "when": {"on":"zoneEnter","zone":"lounge"},
|
|
784
|
+
"if": {"op":"hasPass","passKey":"vip","of":"self"},
|
|
785
|
+
"then": [ {"do":"add","target":"self.coins","by":1} ] }
|
|
786
|
+
```
|
|
787
|
+
|
|
788
|
+
- Both are **O(1)** reads off a per-seat snapshot the room hydrates at join and refreshes when a receipt lands — a
|
|
789
|
+
rule never waits on the network. A timed pass lapses on its own (expiry is judged each tick).
|
|
790
|
+
- **`of` is REQUIRED** (there is no implicit `self`) and must be a player — an entity ref is a publish error.
|
|
791
|
+
- **Deny by default:** an unheld or unregistered `passKey` reads `false`, an unheld `code` reads `0`, and a guest seat
|
|
792
|
+
or a failed snapshot read the same. Nothing paid for is ever *assumed*.
|
|
793
|
+
- `passKey`/`code` are lowercase slugs (`^[a-z0-9][a-z0-9_-]{0,63}$` — the backend's grants charset). Publish does
|
|
794
|
+
**not** check the product registry: a code nobody registered publishes clean and simply reads empty (§19's gotcha).
|
|
795
|
+
|
|
796
|
+
### `consume` — spend a balance, server-side
|
|
797
|
+
|
|
798
|
+
```jsonc
|
|
799
|
+
"actions": { "drink": {} },
|
|
800
|
+
"rules": [
|
|
801
|
+
{ "when": {"on":"action","name":"drink"},
|
|
802
|
+
"if": {"op":">","a":{"op":"balanceOf","code":"potion","of":"self"},"b":0},
|
|
803
|
+
"then": [ {"do":"consume","code":"potion","amount":1,"player":"self"},
|
|
804
|
+
{"do":"add","target":"self.potions","by":1}, // the world-side effect of the spend
|
|
805
|
+
{"do":"save","player":"self"} ] } // persistent playerVar (§18)
|
|
806
|
+
]
|
|
807
|
+
```
|
|
808
|
+
|
|
809
|
+
- `amount` is a **literal integer 1..1000000000**, never an expression — how much a spend costs cannot depend on
|
|
810
|
+
state a client moved. `code` takes the charset above. `player` must be the literal `"self"`, meaning the player
|
|
811
|
+
bound by the event (the actor for an `action`, the member for a zone/player event, or the buyer for `purchase`). A
|
|
812
|
+
playerless event such as `tick` cannot spend; publish rejects it.
|
|
813
|
+
- **Verdict-gated continuation:** the room may debit its private snapshot optimistically (floored at zero, keeping
|
|
814
|
+
same-tick overspend gates coherent), but every sibling after `consume` is suspended. They run exactly once only
|
|
815
|
+
after the backend returns `resolved:true, applied:true`. `applied:false`, an unresolved verdict, the room rate
|
|
816
|
+
fence, or queue refusal ends the list without running `add`, `save`, `awardAchievement`, or any other consequence.
|
|
817
|
+
An authoritative rejected verdict restores/reconciles the balance. Running with no backend configured remains
|
|
818
|
+
playable in local development and continues immediately because there is no real ledger there.
|
|
819
|
+
- An effect tree may contain **at most one top-level `consume`**, and `consume` cannot appear inside a `forEach*`
|
|
820
|
+
body. Publish rejects unsafe trees; the room repeats the check and skips the entire tree if an older/unvalidated
|
|
821
|
+
artifact reaches it. Put the spend before all of its consequences, as in the example above.
|
|
822
|
+
- **Self-only by construction and at runtime.** `player` is not a general ref and cannot be an action arg, loop
|
|
823
|
+
binding, room ref, or server-selected player: the manifest accepts only literal `"self"`. The room repeats that
|
|
824
|
+
check and uses the event binding directly, so a stale/unvalidated artifact with another target fails closed before
|
|
825
|
+
any consume API call.
|
|
826
|
+
- **Rate-fenced per room** (60 spends/minute); past it a spend is refused, counted, and its continuation is dropped.
|
|
827
|
+
Spend on player-driven beats (an `action`, a zone event, a purchase); a bare `tick` has no player
|
|
828
|
+
binding and cannot publish a consume effect.
|
|
829
|
+
|
|
830
|
+
## 21. Where Tier-2 stops (use the escape hatch or a different layer)
|
|
389
831
|
|
|
390
832
|
Out of scope for the declarative DSL: **cheat-proof collision/raycast/hitscan** and **navmesh pathfinding / autonomous AI**
|
|
391
833
|
(straight-line `seek` is in; *navigating around obstacles* and *deciding* are not — run them on an `owner` entity
|
|
392
|
-
client-side). **Cross-session persistence**
|
|
393
|
-
|
|
394
|
-
physics
|
|
834
|
+
client-side). **Cross-session persistence** IS supported — see §18 — but **persistent room state** and **player-to-player
|
|
835
|
+
mailboxes** (async gifting, offline messages) are not: both need conflict semantics the platform hasn't settled.
|
|
836
|
+
**Networked physics** (colliding rigid bodies) IS supported — see §9 "Networked physics — colliding dynamic bodies."
|
|
837
|
+
Nested collections / free-form JSON are intentionally unsupported (flat records only).
|