@hypersoniclabs/helix-mcp 0.2.5 → 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.
Files changed (98) hide show
  1. package/README.md +81 -11
  2. package/dist/continuumCanary.d.ts +17 -0
  3. package/dist/continuumCanary.js +17 -0
  4. package/dist/continuumCanary.js.map +1 -0
  5. package/dist/server.d.ts +14 -1
  6. package/dist/server.js +4058 -162
  7. package/dist/server.js.map +1 -1
  8. package/dist/tsconfig.build.tsbuildinfo +1 -1
  9. package/dist/vehicleTools.d.ts +86 -0
  10. package/dist/vehicleTools.js +229 -0
  11. package/dist/vehicleTools.js.map +1 -0
  12. package/docs/avatar-face.md +115 -0
  13. package/docs/bridge.md +98 -0
  14. package/docs/bring-your-world.md +117 -0
  15. package/docs/catalog.md +69 -1
  16. package/docs/character-animation.md +442 -0
  17. package/docs/character-attachments.md +166 -0
  18. package/docs/character-world.md +785 -130
  19. package/docs/continuum.md +153 -0
  20. package/docs/items.md +73 -0
  21. package/docs/lighting-world.md +667 -0
  22. package/docs/locomotion-clip-spec.md +294 -0
  23. package/docs/manifest.md +31 -6
  24. package/docs/multiplayer-logic.md +460 -17
  25. package/docs/multiplayer-templates/chrono-orchard.md +36 -22
  26. package/docs/multiplayer-templates/collect-a-thon.md +28 -38
  27. package/docs/multiplayer-templates/collections.md +24 -24
  28. package/docs/multiplayer-templates/hangout.md +124 -111
  29. package/docs/multiplayer-templates/npc-wave.md +310 -0
  30. package/docs/multiplayer-templates/obby.md +13 -16
  31. package/docs/multiplayer-templates/persistent-progress.md +218 -0
  32. package/docs/multiplayer-templates/physics-bumper.md +27 -9
  33. package/docs/multiplayer-templates/physics-football.md +22 -8
  34. package/docs/multiplayer-templates/relic-bearers.md +12 -15
  35. package/docs/multiplayer-templates/server-motion.md +16 -19
  36. package/docs/multiplayer-templates/shooter-range.md +275 -0
  37. package/docs/multiplayer-templates/team-control.md +28 -15
  38. package/docs/multiplayer-templates/turn-arena.md +31 -22
  39. package/docs/multiplayer-templates/voice-radio.md +166 -0
  40. package/docs/multiplayer-templates/wave-survival.md +7 -8
  41. package/docs/multiplayer-templates/world-shop.md +240 -0
  42. package/docs/multiplayer-world.md +222 -129
  43. package/docs/npc-world.md +623 -0
  44. package/docs/publishing.md +108 -28
  45. package/docs/purchases.md +223 -0
  46. package/docs/scene-performance.md +64 -0
  47. package/docs/screenshots.md +140 -0
  48. package/docs/sdk.md +324 -5
  49. package/docs/shooter-worlds.md +537 -0
  50. package/docs/terrain.md +173 -0
  51. package/docs/upgrades.md +324 -0
  52. package/docs/vehicles.md +727 -0
  53. package/docs/world-inspect.md +156 -0
  54. package/docs/world-look.md +241 -0
  55. package/docs/world-recipe.md +65 -6
  56. package/package.json +15 -4
  57. package/skills/README.md +91 -0
  58. package/skills/helix-assets/SKILL.md +491 -0
  59. package/skills/helix-assets/references/asset-sources.md +143 -0
  60. package/skills/helix-assets/references/vault-api.md +105 -0
  61. package/skills/helix-avatar-qa/SKILL.md +85 -0
  62. package/skills/helix-avatars/SKILL.md +206 -0
  63. package/skills/helix-avatars/references/contract.md +166 -0
  64. package/skills/helix-avatars/references/dynamics.md +367 -0
  65. package/skills/helix-avatars/references/face.md +50 -0
  66. package/skills/helix-avatars/references/publish.md +76 -0
  67. package/skills/helix-avatars/references/qa.md +251 -0
  68. package/skills/helix-avatars/references/rigging.md +88 -0
  69. package/skills/helix-avatars/references/source-generated.md +190 -0
  70. package/skills/helix-avatars/references/source-model.md +90 -0
  71. package/skills/helix-avatars/references/source-rigid.md +90 -0
  72. package/skills/helix-avatars/references/source-vrm.md +61 -0
  73. package/skills/helix-gauntlet/SKILL.md +128 -0
  74. package/skills/helix-multiplayer/SKILL.md +150 -0
  75. package/skills/helix-multiplayer/references/dsl-capability-map.md +107 -0
  76. package/skills/helix-vehicles/SKILL.md +218 -0
  77. package/skills/helix-vehicles/references/addons.md +212 -0
  78. package/skills/helix-vehicles/references/appearance.md +339 -0
  79. package/skills/helix-vehicles/references/audio-import.md +138 -0
  80. package/skills/helix-vehicles/references/audio.md +580 -0
  81. package/skills/helix-vehicles/references/cabin.md +225 -0
  82. package/skills/helix-vehicles/references/host-manifest.md +174 -0
  83. package/skills/helix-vehicles/references/physics-from-specs.md +463 -0
  84. package/skills/helix-vehicles/references/publish.md +214 -0
  85. package/skills/helix-vehicles/references/qa.md +177 -0
  86. package/skills/helix-vehicles/references/reference-package.json +3481 -0
  87. package/skills/helix-vehicles/references/reference-package.md +69 -0
  88. package/skills/helix-vehicles/references/source-beamng.md +167 -0
  89. package/skills/helix-vehicles/references/source-concept.md +38 -0
  90. package/skills/helix-vehicles/references/source-model.md +100 -0
  91. package/skills/helix-vehicles/references/source-scratch.md +60 -0
  92. package/skills/helix-world-build/SKILL.md +376 -0
  93. package/skills/helix-world-build/references/config-gates.md +104 -0
  94. package/skills/helix-world-director/SKILL.md +210 -0
  95. package/skills/helix-world-qa/SKILL.md +371 -0
  96. package/skills/helix-world-qa/references/perf-budgets.md +240 -0
  97. package/skills/helix-world-qa/references/perf-handle.md +125 -0
  98. 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); the room exposes `phase` (string). Read them like any var (`self.position`, `room.phase`).
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` ≤128, effects per `then` ≤16, `if` depth ≤8 / ≤64 nodes.
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` ≤64, extent ≤10000.
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: ≤32.
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 ≤32, cascade depth ≤16.
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 ≤64, fields ≤16.
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 ≤64, args ≤16. **Actions are the cheat-resistant input path** (validated server-side) — prefer them
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` 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
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
- ## 18. Where Tier-2 stops (use the escape hatch or a different layer)
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** (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).
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).