@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
@@ -0,0 +1,310 @@
1
+ # Multiplayer template — `npc-wave`
2
+
3
+ **What it is.** `wave-survival` with **people** instead of orbs: every few seconds a wave of enemies spawns and
4
+ hunts the players, touch one and you take damage, shoot them down — real gun damage, headshots included — to
5
+ clear the wave, and each enemy is a full humanoid character that walks, turns and runs like a player. A
6
+ **stand-body shopkeeper** waits in the safe zone with a `Talk` prompt. **Capability: SHARED (game-owned)
7
+ entities with DISTRIBUTED, host-migrated local-authority AI, rendered as humanoids and SHOOTABLE — their
8
+ damage is gun-declared and server-resolved.** The authority story is `wave-survival`'s, unchanged: the enemies
9
+ belong to the *game*, the server hands **each enemy to the least-loaded client at spawn** so the swarm's
10
+ simulation **spreads across the whole room**, and any enemy whose host drops is **re-elected** to another client.
11
+
12
+ > **The lesson here is that the humanoid is only the RENDERING** — the brain stays exactly where
13
+ > `wave-survival` put it. Read `read_template({ name: "wave-survival" })` first: this page is a delta from it,
14
+ > and the manifest below is its manifest. The NPC surface (`NpcScene`, `InteractionSpots`, behaviours, nav) is
15
+ > `read_doc({ name: "npc-world" })`; grammar: `read_doc({ name: "multiplayer-logic" })` §9. The gun half of the
16
+ > client — installing `gun-control`, the FX/hit systems, the crosshair — is `read_doc({ name: "shooter-worlds" })`,
17
+ > and the claim lane below is `read_template({ name: "shooter-range" })`'s, aimed at an entity instead of a player.
18
+
19
+ ## 1. DSL used
20
+
21
+ `wave-survival`'s, unchanged by the humanoid body — plus the shooter's claim lane, which is the whole delta:
22
+
23
+ - **Entities** (§9) — one `enemy` kind, **`shared: true`** + `authority:'owner'` + `ownerLifecycle:'hostMigrate'`:
24
+ each enemy is hosted by the **least-loaded connected client at spawn**, so a swarm **distributes across the
25
+ clients** — and any enemy **re-elects** to another client if its host leaves. `idleTimeout: 4` despawns an
26
+ enemy left hostless for 4 s. `maxSpeed` (required for owner kinds) bounds the upload; an **attached zone**
27
+ damages players on contact; an **`hp` var** is the enemy's health — the room writes it and nothing else does.
28
+ - **Timers** (§11) — `wave`, **self-rearming** (a `timerElapsed` rule restarts it) → a steady spawn cadence, plus
29
+ a per-player `cooldown`: the server-side fire-rate ceiling on the shoot claim.
30
+ - **Actions** (§14) — `shoot` is the hit CLAIM: `target` (an enemy ref) + `bodyPart` (`head`|`body`). It carries
31
+ no damage number — the client names WHAT it hit, never how hard.
32
+ - **`weaponItems` + `weaponDamage`** — the room resolves the shot off the gun the shooter is actually holding
33
+ (`shooter-range` teaches the lane). `target` may be an **entity**, which applies the definition's distance
34
+ falloff shooter→enemy, and `part: "head"` applies its `headMultiplier` — the world names the part, never the factor.
35
+ - **Declared state** (§2) — `roomVars.wave` + `roomVars.cleared` (the score) + `playerVars.health`.
36
+ - **Rules** (§3) — arm the wave timer on `stateEnter`; on `timerElapsed` re-arm **and** (if `aggregate count < 6`)
37
+ `spawnEntity`; enemy-zone `zoneEnter` (binds `self` = the player) subtracts health; `shoot` gates on `distance`
38
+ + `cooldown` then subtracts `weaponDamage` from the target's `hp`; an entity-scope `varReached hp <= 0`
39
+ destroys the enemy and scores; `varReached self.health <= 0` respawns + heals.
40
+ - **The shopkeeper declares NOTHING.** It is a client-local `NpcScene` NPC on the `stand` tier: it never moves,
41
+ so every client's copy agrees for free and no state has to cross the wire. That is the dividing line this
42
+ template teaches — **a moving NPC belongs to the room, a standing one does not.**
43
+
44
+ ## 2. The manifest — `public/helix.json` *(the shoot lane needs the `weaponDamage` entity-target widening)*
45
+
46
+ ```json
47
+ {
48
+ "helixVersion": "0.3",
49
+ "title": "NPC Wave",
50
+ "slug": "npc-wave",
51
+ "entry": "index.html",
52
+ "maxPlayers": 8,
53
+ "permissions": ["auth.profile", "multiplayer", "voice.proximity"],
54
+ "multiplayer": {
55
+ "authoritative": true,
56
+ "weaponItems": ["11111111-2222-4333-8444-555555555555@1"],
57
+ "state": {
58
+ "roomVars": { "wave": { "type": "number", "default": 0 }, "cleared": { "type": "number", "default": 0 } },
59
+ "playerVars": { "health": { "type": "number", "default": 100 } }
60
+ },
61
+ "entities": {
62
+ "enemy": {
63
+ "authority": "owner",
64
+ "shared": true,
65
+ "ownerLifecycle": "hostMigrate",
66
+ "idleTimeout": 4,
67
+ "maxSpeed": 6,
68
+ "vars": { "hp": { "type": "number", "default": 60 } },
69
+ "zone": { "shape": "sphere", "radius": 1.2 }
70
+ }
71
+ },
72
+ "timers": { "wave": {}, "cooldown": { "keyed": "player" } },
73
+ "actions": {
74
+ "shoot": { "args": { "target": { "type": "ref", "of": "entity:enemy" }, "bodyPart": { "type": "string", "enum": ["head", "body"] } } }
75
+ },
76
+ "states": { "initial": "playing", "phases": ["playing"] },
77
+ "rules": [
78
+ { "when": { "on": "stateEnter", "phase": "playing" }, "then": [{ "do": "startTimer", "timer": "wave", "seconds": 3 }] },
79
+ { "when": { "on": "timerElapsed", "timer": "wave" }, "then": [{ "do": "startTimer", "timer": "wave", "seconds": 3 }] },
80
+ {
81
+ "when": { "on": "timerElapsed", "timer": "wave" },
82
+ "if": { "op": "<", "a": { "op": "aggregate", "scope": "entities:enemy", "agg": "count" }, "b": 6 },
83
+ "then": [
84
+ { "do": "spawnEntity", "kind": "enemy", "at": { "vec3": [0, 0, 14] } },
85
+ { "do": "add", "target": "room.wave", "by": 1 }
86
+ ]
87
+ },
88
+ { "when": { "on": "zoneEnter", "zone": "enemy" }, "then": [{ "do": "add", "target": "self.health", "by": -10 }] },
89
+ {
90
+ "when": { "on": "action", "name": "shoot" },
91
+ "if": { "op": "and", "of": [
92
+ { "op": "<", "a": { "op": "distance", "a": { "var": "self.position" }, "b": { "ref": { "var": "action.args.target" }, "var": "position" } }, "b": 60 },
93
+ { "op": "==", "a": { "op": "timerRemaining", "timer": "cooldown", "key": "self" }, "b": 0 }
94
+ ] },
95
+ "then": [
96
+ { "do": "add", "target": { "ref": { "var": "action.args.target" }, "var": "hp" },
97
+ "by": { "op": "*", "a": -1, "b": { "op": "weaponDamage", "of": "self", "target": { "var": "action.args.target" }, "part": { "var": "action.args.bodyPart" }, "default": 20 } } },
98
+ { "do": "startTimer", "timer": "cooldown", "seconds": 0.08, "key": "self" }
99
+ ]
100
+ },
101
+ {
102
+ "when": { "on": "varReached", "scope": "entity", "kind": "enemy", "var": "hp", "cmp": "<=", "value": 0 },
103
+ "then": [
104
+ { "do": "add", "target": "room.cleared", "by": 1 },
105
+ { "do": "destroyEntity", "entity": "self" }
106
+ ]
107
+ },
108
+ {
109
+ "when": { "on": "varReached", "scope": "self", "var": "health", "cmp": "<=", "value": 0 },
110
+ "then": [
111
+ { "do": "respawn", "player": "self", "to": { "vec3": [0, 1, 0] } },
112
+ { "do": "set", "target": "self.health", "to": 100 }
113
+ ]
114
+ }
115
+ ]
116
+ },
117
+ "supportsMobile": true,
118
+ "contentRating": "everyone",
119
+ "systems": { "humanoid-character": "^0.3" }
120
+ }
121
+ ```
122
+
123
+ The two `timerElapsed` rules fire in declared order: the first re-arms `wave`, the second spawns. The
124
+ `aggregate count < 6` cap is doing double duty here: **every live enemy is a full skinned character**, so it is
125
+ a frame budget as much as a difficulty knob (the same reason `NpcScene` caps itself at 16).
126
+
127
+ `shoot` is the cheat-resistant input, and the two gates are the anti-cheat floor — keep both: the **server**
128
+ checks the 60 m range, and the per-player `cooldown` is the fire-rate ceiling (claims inside 0.08 s are
129
+ ignored). Nothing about the damage is the client's: it names the target and the part, the room reads the gun
130
+ off the shooter's replicated grip attachment, applies the definition's falloff at shooter→enemy range, and
131
+ multiplies by `headMultiplier` when `part` is `"head"`. `default: 20` covers a player holding no declared gun.
132
+ **Death is a consequence, never a claim** — `hp` crossing 0 is what destroys the enemy, on an entity-scope
133
+ `varReached` that binds `self` to the enemy that crossed (one latch per live instance). Replace the
134
+ `weaponItems` example id with a supported, existing package-backed weapon definition pin (`assetId@version`).
135
+
136
+ **Version gate.** The shoot lane needs the platform's **`weaponDamage` entity-target widening** on BOTH halves:
137
+ the manifest validator (an older one rejects an entity ref in `target`, so the world fails publish) and the
138
+ room, which resolves the entity's position for falloff — an older room still applies the damage but silently
139
+ skips falloff, so every hit reads point-blank. **The room deploys before worlds use it** (the platform's
140
+ standing release-order rule); the rest of the template runs on any room. The humanoid rendering in §3 needs
141
+ `humanoid-character ≥ 0.3.13`.
142
+
143
+ ### Optional variant — a RANGED enemy (an interval rule, not a projectile)
144
+
145
+ An enemy that shoots instead of touching is **one timer and three rules**, and it stays entirely inside the
146
+ DSL: a per-player `volley` timer re-arms itself every **N** seconds, and when it elapses the player takes
147
+ **D** hp if the nearest enemy is inside **R** metres. Merge into §2's block:
148
+
149
+ ```json
150
+ {
151
+ "timers": { "volley": { "keyed": "player" } },
152
+ "rules": [
153
+ { "when": { "on": "playerJoin" }, "then": [{ "do": "startTimer", "timer": "volley", "seconds": 2, "key": "self" }] },
154
+ { "when": { "on": "timerElapsed", "timer": "volley" }, "then": [{ "do": "startTimer", "timer": "volley", "seconds": 2, "key": "self" }] },
155
+ {
156
+ "when": { "on": "timerElapsed", "timer": "volley" },
157
+ "if": { "op": "and", "of": [
158
+ { "op": ">", "a": { "op": "aggregate", "scope": "entities:enemy", "agg": "count" }, "b": 0 },
159
+ { "op": "<", "a": { "op": "distance", "a": { "var": "self.position" }, "b": { "ref": { "op": "nearestEntity", "from": { "var": "self.position" }, "kind": "enemy" }, "var": "position" } }, "b": 18 }
160
+ ] },
161
+ "then": [{ "do": "add", "target": "self.health", "by": -8 }]
162
+ }
163
+ ]
164
+ }
165
+ ```
166
+
167
+ **The three numbers are the whole design surface:** `seconds: 2` is the cadence, `b: 18` is the range, `by: -8`
168
+ is the damage. The two `timerElapsed` rules fire in declared order (§2's `wave` pair, again): the first re-arms
169
+ unconditionally, the second is the one the `if` gates — so a player out of range simply takes nothing this beat.
170
+ Keep the **`count > 0` guard**: reading through a dangling ref yields the typed zero, so with no enemies alive
171
+ `nearestEntity` would measure everyone's distance to the origin and shoot whoever stands near it.
172
+
173
+ **The trust model, stated plainly: this is distance-only and fully server-side by deliberate design** — no
174
+ client claim, no `bodyPart`, and **no line of sight** (LoS attestation is future machinery, so cover does not
175
+ stop these shots; keep `R` tight enough that the range itself is the cover). The `shoot` lane above is the
176
+ opposite trade and both can coexist: players claim what they hit, the room shoots back on its own clock.
177
+ Nothing on the client changes: whether the enemy visibly holds a gun is a LOOK decision (the arming recipe is
178
+ `read_doc({ name: "npc-world" })` §4a), and the damage still comes from this rule, never from the animation.
179
+
180
+ ## 3. The client — `src/main.ts` (delta from `wave-survival`) *(humanoid-character ≥ 0.3.13)*
181
+
182
+ **The only change to the entity wiring is `build`.** `humanoidEntities(...)` returns an `EntityScene` build fn
183
+ whose `object3d` is a full headless `Character`: it clones the shared body, registers locomotion, and derives
184
+ speed / heading / grounded state from the motion the transport already wrote — no driver, no behaviour, no
185
+ `seek` on the client. Authority, interpolation, hosting and host migration stay exactly where they were. Wrap
186
+ it to keep an `id → handle` map: the gun chain needs each enemy's BODY, which only the handle can reach.
187
+
188
+ ```ts
189
+ import { humanoidEntities, InteractionSpots, NpcScene, type AIBehaviour, type HumanoidEntityHandle } from '@helix/humanoid-character';
190
+
191
+ const ENEMY_MAX_SPEED = 6; // MUST mirror the DSL maxSpeed (reconcile clamp)
192
+ const room = mp.room!;
193
+
194
+ const buildEnemy = humanoidEntities({
195
+ scene,
196
+ assets: mp.assets, // the facade's already-loaded base model + clips
197
+ gestures: mp.gestures, // optional: lets a world gesture play on an enemy
198
+ name: (kind) => (kind === 'enemy' ? 'Zombie' : null), // null (or no fn) = no nameplate
199
+ });
200
+ const enemies = new Map<string, HumanoidEntityHandle>(); // the shot candidates, and the claim router
201
+
202
+ const entities = mp.entities({
203
+ maxSpeed: { enemy: ENEMY_MAX_SPEED },
204
+ motion: {
205
+ // Unchanged from wave-survival — the AI is still the host's motion fn, and it still runs only for
206
+ // enemies YOU host. seekNearest caps the per-frame step internally and stops 0.9 m short, which parks
207
+ // the enemy inside the 1.2 m damage zone rather than inside the player.
208
+ enemy: (_e, _dt, ctx) => ctx.seekNearest(ENEMY_MAX_SPEED * 0.83, 0.9),
209
+ },
210
+ // THE DELTA: a humanoid per entity instead of an orb mesh, registered so the hit chain can find it.
211
+ build: (kind, id) => {
212
+ const handle = buildEnemy(kind, id);
213
+ const free = handle.dispose?.bind(handle); // capture BEFORE shadowing it: a wrapper that called
214
+ enemies.set(id, handle); // handle.dispose() would then call ITSELF, forever
215
+ void handle.ready.then(() => handle.character?.playAnimation('zombie-walk')); // optional: a gait, once the body exists
216
+ return Object.assign(handle, { dispose: () => { enemies.delete(id); free?.(); } });
217
+ },
218
+ });
219
+ // mp.update(dt) drives the sim/interpolation — nothing to add in your frame loop.
220
+ ```
221
+
222
+ Two placement facts that change when the render is a humanoid: the handle's `object3d` origin is the entity's
223
+ networked point, which for a humanoid is its **feet** — so `spawnEntity … "at": [0, 0, 14]` puts it correctly on
224
+ a floor at `y = 0` (an orb at that height would be half-buried), and the attached `zone` is centred at the
225
+ floor, so size its radius to reach the player's capsule rather than picturing a chest-height sphere.
226
+
227
+ **Shooting them — the enemies are hit candidates like any replica.** The gun itself (install, profile, FX,
228
+ crosshair, the frame order) is `shooter-worlds` §1–3 unchanged; the npc-wave delta is what you CHAIN into the
229
+ `players` generator and where the claim goes. `entities.entries()` yields `{ id, state, object3d }` — the live
230
+ set and the synced `hp` — and the map above turns an id into the body whose head socket arms the headshot:
231
+
232
+ ```ts
233
+ const scratch = new THREE.Vector3(); // ONE shared scratch — each head read is consumed before the next
234
+
235
+ const gunHit = new GunHitSystem({
236
+ camera, body, muzzle: (out) => gunFx.muzzleWorld(out),
237
+ walkSpeedMps: mp.local.services.config.get('locomotion.walkSpeed'),
238
+ maxSpeedMps: mp.local.services.config.get('locomotion.runSpeed'),
239
+ players: function* () {
240
+ for (const [id, r] of replicaFx) yield { id, position: r.character.model.position }; // your existing PLAYER replicas
241
+ for (const e of entities.entries()) { // …then the enemies
242
+ const handle = enemies.get(e.id);
243
+ if (handle?.character == null || Number(e.state.vars.hp ?? 0) <= 0) continue; // still building, or already dead
244
+ yield {
245
+ id: e.id,
246
+ position: e.object3d.position, // the networked point = the humanoid's FEET, which is what the capsule wants
247
+ // getWorldPosition, not .position: the character tick leaves matrixWorld stale, so a raw read aims
248
+ // the headshot sphere at last frame's head. No head thunk at all = every hit is 'body'.
249
+ head: () => handle.character?.services.sockets?.anchorOf('head')?.getWorldPosition(scratch) ?? null,
250
+ };
251
+ }
252
+ },
253
+ onPlayerHit: ({ id, point, claim, part }) => {
254
+ tracers.spawn(tracerFrom(point), point);
255
+ if (!claim) return; // exactly ONE claim per trigger pull
256
+ if (enemies.has(id)) room.sendAction('shoot', { target: id, bodyPart: part });
257
+ // else it is a player id — this template has no friendly fire, so nothing is claimed. A PvP world sends
258
+ // its own claimHit here instead; entity ids and session ids are disjoint, so membership IS the router.
259
+ },
260
+ onWorldHit: ({ point }) => tracers.spawn(tracerFrom(point), point),
261
+ onMiss: ({ end }) => tracers.spawn(tracerFrom(end), end),
262
+ });
263
+ gunHit.attachTo(mp.local);
264
+ ```
265
+
266
+ The client never decides an enemy died: it claims the hit, the room subtracts `hp`, and the enemy simply
267
+ despawns out of `entities.entries()` when the room's `varReached` destroys it — which is also why the
268
+ generator skips `hp <= 0` (a corpse is unshootable for the frame or two before the despawn arrives).
269
+
270
+ **The shopkeeper is pure client-side decoration** — no manifest, no rules, no wire traffic:
271
+
272
+ ```ts
273
+ const npcs = new NpcScene({ scene, assets: mp.assets }); // no staticsFrom needed: 'stand' runs no physics
274
+ const COUNTER = { x: 0, y: 1, z: -2 };
275
+ const watch: AIBehaviour = (npc) => {
276
+ const self = npc.position;
277
+ const near = Math.hypot(body.position.x - self.x, body.position.z - self.z) <= 6;
278
+ npc.face(near ? body.position : COUNTER);
279
+ };
280
+ const shop = await npcs.add({ id: 'shopkeeper', at: { x: 0, y: 0, z: -3 }, body: 'stand', behaviour: watch, name: 'Quartermaster' });
281
+
282
+ const spots = new InteractionSpots({ body, input, scene, suppressed: () => mp.sitPrompt !== null });
283
+ spots.register({
284
+ id: 'shopkeeper-talk',
285
+ label: 'Talk',
286
+ at: () => { const p = shop.driver.position; return { x: p.x, y: p.y + 2.1, z: p.z }; }, // live anchor, head height
287
+ onInteract: () => showBanner('Quartermaster: keep them off the spawn pad.'),
288
+ });
289
+
290
+ renderer.setAnimationLoop(() => {
291
+ const dt = Math.min(clock.getDelta(), 0.1);
292
+ mp.update(dt); // local character + replicas + entities — shots latch here…
293
+ gunFx.update(dt); // …and resolve after it: the gun frame order is a contract (shooter-worlds §3)
294
+ gunHit.update(dt); // the ray fires against THIS frame's enemy transforms
295
+ npcs.update(dt); // then the client-local NPCs
296
+ spots.update(dt); // then the spots — the interact edge advanced inside mp.update
297
+ renderer.render(scene, camera);
298
+ });
299
+ ```
300
+
301
+ Read your `health` off `room.state.players[sessionId].vars.health`, the score off `room.vars.num('cleared')`.
302
+ **Footguns:** mirror `maxSpeed`; keep each motion step `≤ maxSpeed × 0.83 × dt`; you won't host every enemy
303
+ (shared) — never assume you control one, check `e.controller`; **never publish `hp` from the handle's
304
+ `controls()`** — that channel is uploaded by the enemy's HOST client, and `hp` is the room's alone; keep the
305
+ concurrent-enemy cap low, because each one is a full character; and never give the shopkeeper a `seek` — a
306
+ `stand` NPC ignores movement intent by design (`read_doc({ name: "npc-world" })` §1).
307
+
308
+ ## 4. Build, validate, publish
309
+
310
+ `npm install` → **`install_world_packages`** (MCP tool) → `npm run build` → `validate_world` on `dist/` → **`helix verify-subpath dist`** (REQUIRED — the only local gate that serves the bundle from a nested path, the way a published world is actually served; preview/validate/inspect all serve from the origin root and cannot see a root-absolute 404) → `whoami` → `publish_world`.
@@ -28,7 +28,7 @@ Use this for parkour, races, platformers, time trials. Lifted from the verified
28
28
  "slug": "obby",
29
29
  "entry": "index.html",
30
30
  "maxPlayers": 8,
31
- "permissions": ["auth.profile", "multiplayer"],
31
+ "permissions": ["auth.profile", "multiplayer", "voice.proximity"],
32
32
  "multiplayer": {
33
33
  "authoritative": true,
34
34
  "state": { "playerVars": { "checkpoint": { "type": "vec3", "default": [0, 1, 0] } } },
@@ -48,7 +48,7 @@ Use this for parkour, races, platformers, time trials. Lifted from the verified
48
48
  },
49
49
  "supportsMobile": true,
50
50
  "contentRating": "everyone",
51
- "systems": { "humanoid-character": "^0.2" }
51
+ "systems": { "humanoid-character": "^0.3" }
52
52
  }
53
53
  ```
54
54
 
@@ -59,26 +59,23 @@ body) — only the *zones* are declared.
59
59
 
60
60
  ## 3. The client — `src/main.ts` (delta from `hangout`)
61
61
 
62
- Almost none — presence, a win banner, and **one line to wire respawn**. The respawn is server-authoritative, but to
63
- land it on your *local* (client-predicted) body you construct a **`LocalReconciler`** once after joining — it
64
- self-hooks the server's position ack and snaps your body, with no per-frame work. Build the course geometry as
65
- static colliders on your character body (as in the character recipe), aligned with the declared zone centers.
62
+ Almost none — presence + a win banner. The respawn is server-authoritative, and landing it on your *local*
63
+ (client-predicted) body is the **`LocalReconciler`**, which the facade wires **by default** — nothing to add.
64
+ Build the course geometry as static colliders on your character body (as in the character recipe), aligned with
65
+ the declared zone centers.
66
66
 
67
67
  ```ts
68
- import { LocalReconciler } from '@helix/humanoid-character';
69
-
70
- // once, after joinRoom() succeeds — `body` is your local character's RapierBody:
71
- new LocalReconciler({ room, body }); // snaps the local player to the server's anchor on a respawn/teleport ack
72
-
73
- room.onMessage('win', (m) => showBanner(`🏁 ${nameOf(String(m.who))} finished!`));
74
- // (otherwise identical to hangout: local player + ReplicaScene + sendState)
68
+ mp.room?.onMessage('win', (m) => showBanner(`🏁 ${nameOf(String(m.who))} finished!`));
69
+ // read your saved checkpoint for the HUD off the typed accessors:
70
+ const cp = mp.room?.me.vec3('checkpoint');
71
+ // (otherwise identical to hangout: the one facade call)
75
72
  ```
76
73
 
77
- **Footguns:** use `respawn` (not a client-side teleport) for death; you **must** wire `LocalReconciler` once —
78
- without it the server re-anchors the seat but your predicted body keeps falling; keep the platform colliders
74
+ **Footguns:** use `respawn` (not a client-side teleport) for death — the facade's reconciler lands the server's
75
+ move on your body (never pass `reconciler: false` in a world with respawns); keep the platform colliders
79
76
  aligned with the declared zone centers so checkpoints fire where players land; `checkpoint` is a `vec3` default
80
77
  `[0,1,0]` (the start), so a fresh player respawns at spawn.
81
78
 
82
79
  ## 4. Build, validate, publish
83
80
 
84
- `npm install` → **`install_world_packages`** (MCP tool) → `npm run build` → `validate_world` on `dist/` → `whoami` → `publish_world`.
81
+ `npm install` → **`install_world_packages`** (MCP tool) → `npm run build` → `validate_world` on `dist/` → **`helix verify-subpath dist`** (REQUIRED — the only local gate that serves the bundle from a nested path, the way a published world is actually served; preview/validate/inspect all serve from the origin root and cannot see a root-absolute 404) → `whoami` → `publish_world`.
@@ -0,0 +1,218 @@
1
+ # Multiplayer template — `persistent-progress`
2
+
3
+ **What it is.** A workshop world that **remembers**. Your level, gems and title come back when you rejoin;
4
+ the community's donation total climbs across every instance and every day; the leaderboards keep the best
5
+ level and the fastest run; a guestbook holds the last ten notes anyone left; and a world-record time survives
6
+ the room shutting down. **Capability: durable storage** — every persistence primitive the platform has, and
7
+ (the part that actually matters) **which one to reach for**. Lifted from the verified
8
+ `multiplayer-persistent-progress` world.
9
+
10
+ > A normal character world (presence) **plus** storage. Grammar:
11
+ > `read_doc({ name: "multiplayer-logic" })` §18 (saves, room state, counters, leaderboards) and §2 (var types).
12
+
13
+ ## 1. Pick the shape FIRST — this is the whole template
14
+
15
+ Storage bugs here are almost never syntax; they are picking the wrong shape and discovering it in production.
16
+ Keys are authority boundaries, JSON is everything else: **one writer per key.**
17
+
18
+ | What you are storing | Shape | Reach for |
19
+ |---|---|---|
20
+ | Player progression you must not let players forge (currency, XP, unlocks) | one document per player, server-written | `persistent: true` playerVars + `save` |
21
+ | Player preferences, cosmetic choices, a single-player save | one JSON blob per player, client-written | `Helix.dataStore.set` on the player's own key |
22
+ | This round's score, who is `it`, the current phase | room memory | plain roomVars — they SHOULD die with the room |
23
+ | Guestbooks, galleries, player-built things everyone sees | one bounded document, room-written | persistent roomVar, `list of record`, `merge: "append"` |
24
+ | A community total, boss HP, a vote count | atomic accumulator | `counters` + `increment` |
25
+ | Rankings | one bounded document per board, keep-best | `leaderboards` + `submitScore` |
26
+ | A world record, a high-water mark | a single monotone number | persistent roomVar, `merge: "max"` / `"min"` |
27
+
28
+ **Two shapes that look interchangeable and are not.** A `counter` is write-only-ish shared arithmetic read
29
+ back through the data store; a **`sum` roomVar is replicated state** you can render every frame without a
30
+ fetch. Use a counter for a number nobody looks at continuously (lifetime donations); use a `sum` roomVar when
31
+ the HUD shows it live. And **anything a player PAID for belongs on `save` or `submitScore`, never on
32
+ `saveRoom`** — see the durability window in §5.
33
+
34
+ ## 2. DSL used
35
+
36
+ - **Persistent playerVars + `save`** (§18) — scalars marked `persistent: true`, flushed on the beat that
37
+ changed them. Hydrated before join rules run, so a `playerJoin` rule reads restored values.
38
+ - **Persistent roomVars + `saveRoom`** (§18) — world state that outlives the room. Every persistent roomVar
39
+ declares a **`merge`** policy, and may declare a **`key`** routing it to its own document.
40
+ - **`counters` + `increment`** (§18) — commutative totals batched by the room.
41
+ - **`leaderboards` + `submitScore`** (§18) — keep-best per player, decided by the backend.
42
+ - **`awardAchievement`** (§19) — fire-and-forget, idempotent.
43
+ - **Zones + actions + `varReached`** — the beats that trigger the writes.
44
+
45
+ ## 3. The manifest — `public/helix.json` (the storage block)
46
+
47
+ ```json
48
+ {
49
+ "helixVersion": "0.3",
50
+ "title": "Persistent Progress",
51
+ "slug": "persistent-progress",
52
+ "entry": "index.html",
53
+ "maxPlayers": 8,
54
+ "permissions": ["auth.profile", "multiplayer"],
55
+ "contentRating": "everyone",
56
+ "multiplayer": {
57
+ "authoritative": true,
58
+ "state": {
59
+ "playerVars": {
60
+ "level": { "type": "number", "default": 1, "min": 1, "max": 99, "integer": true, "persistent": true },
61
+ "gems": { "type": "number", "default": 0, "min": 0, "max": 9999, "integer": true, "persistent": true },
62
+ "title": { "type": "string", "default": "bronze", "enum": ["bronze", "silver", "gold"], "persistent": true },
63
+ "runStart": { "type": "number", "default": 0 },
64
+ "lastRun": { "type": "number", "default": 0 }
65
+ },
66
+ "roomVars": {
67
+ "notes": {
68
+ "type": "list",
69
+ "of": { "type": "record", "fields": { "author": { "type": "string", "maxLen": 24 }, "text": { "type": "string", "maxLen": 80 } } },
70
+ "maxLen": 10,
71
+ "persistent": true, "merge": "append", "key": "guestbook"
72
+ },
73
+ "bestRun": { "type": "number", "default": 999999, "min": 0, "max": 999999, "persistent": true, "merge": "min" },
74
+ "tally": { "type": "counterMap", "keys": ["visits", "notes"], "persistent": true, "merge": "sum" },
75
+ "motd": { "type": "string", "default": "welcome to the workshop", "maxLen": 80, "persistent": true, "merge": "lastWrite" }
76
+ }
77
+ },
78
+ "counters": { "donations": {} },
79
+ "leaderboards": { "main": { "size": 10 }, "bestTime": { "order": "asc", "size": 10 } },
80
+ "zones": [
81
+ { "id": "runStart", "shape": "sphere", "center": [-14, 0, -10], "radius": 2 },
82
+ { "id": "runFinish", "shape": "sphere", "center": [14, 0, -10], "radius": 2 }
83
+ ],
84
+ "actions": {
85
+ "levelUp": {},
86
+ "donate": {},
87
+ "postNote": { "args": { "text": { "type": "string", "maxLen": 80 }, "author": { "type": "string", "maxLen": 24 } } }
88
+ },
89
+ "rules": [
90
+ { "when": { "on": "playerJoin" },
91
+ "then": [ { "do": "addCount", "target": "room.tally", "key": "visits", "by": 1 }, { "do": "saveRoom" } ] },
92
+
93
+ { "when": { "on": "action", "name": "levelUp" },
94
+ "if": { "op": ">=", "a": { "var": "self.gems" }, "b": 5 },
95
+ "then": [
96
+ { "do": "add", "target": "self.gems", "by": -5 },
97
+ { "do": "add", "target": "self.level", "by": 1 },
98
+ { "do": "save", "player": "self" },
99
+ { "do": "submitScore", "board": "main", "player": "self", "score": { "var": "self.level" } }
100
+ ] },
101
+
102
+ { "when": { "on": "action", "name": "donate" },
103
+ "if": { "op": ">=", "a": { "var": "self.gems" }, "b": 1 },
104
+ "then": [
105
+ { "do": "add", "target": "self.gems", "by": -1 },
106
+ { "do": "increment", "counter": "donations", "by": 1 }
107
+ ] },
108
+
109
+ { "when": { "on": "action", "name": "postNote" },
110
+ "if": { "op": ">=", "a": { "op": "listLength", "list": "room.notes" }, "b": 10 },
111
+ "then": [ { "do": "removeAt", "target": "room.notes", "index": 0 } ] },
112
+ { "when": { "on": "action", "name": "postNote" },
113
+ "then": [
114
+ { "do": "append", "target": "room.notes", "value": { "author": { "var": "action.args.author" }, "text": { "var": "action.args.text" } } },
115
+ { "do": "addCount", "target": "room.tally", "key": "notes", "by": 1 },
116
+ { "do": "saveRoom", "key": "guestbook" },
117
+ { "do": "saveRoom" }
118
+ ] },
119
+
120
+ { "when": { "on": "varReached", "scope": "self", "var": "level", "cmp": ">=", "value": 5 },
121
+ "then": [
122
+ { "do": "set", "target": "self.title", "to": "silver" },
123
+ { "do": "save", "player": "self" },
124
+ { "do": "awardAchievement", "key": "level-5-champion", "player": "self" }
125
+ ] },
126
+
127
+ { "when": { "on": "zoneEnter", "zone": "runStart" },
128
+ "then": [ { "do": "set", "target": "self.runStart", "to": { "op": "now" } } ] },
129
+ { "when": { "on": "zoneEnter", "zone": "runFinish" },
130
+ "if": { "op": ">", "a": { "var": "self.runStart" }, "b": 0 },
131
+ "then": [
132
+ { "do": "set", "target": "self.lastRun", "to": { "op": "-", "a": { "op": "now" }, "b": { "var": "self.runStart" } } },
133
+ { "do": "submitScore", "board": "bestTime", "player": "self", "score": { "var": "self.lastRun" } },
134
+ { "do": "set", "target": "self.runStart", "to": 0 }
135
+ ] },
136
+ { "when": { "on": "zoneEnter", "zone": "runFinish" },
137
+ "if": { "op": "and", "of": [
138
+ { "op": ">", "a": { "var": "self.lastRun" }, "b": 0 },
139
+ { "op": "<", "a": { "var": "self.lastRun" }, "b": { "var": "room.bestRun" } } ] },
140
+ "then": [ { "do": "set", "target": "room.bestRun", "to": { "var": "self.lastRun" } }, { "do": "saveRoom" } ] }
141
+ ]
142
+ },
143
+ "systems": { "humanoid-character": "^0.3" }
144
+ }
145
+ ```
146
+
147
+ **The ring buffer is two rules, not one.** `append` is a no-op at `maxLen`, so a bounded board drops its
148
+ oldest first (`removeAt` index 0) and then appends. Rules fire in declaration order, so the guard rule must
149
+ come first.
150
+
151
+ **`merge` is REQUIRED on every persistent roomVar.** Many instances of your world run at once and all write
152
+ the same document, so the policy decides who wins: `lastWrite` (scalars) is **racy by declaration**;
153
+ `max`/`min` (number), `append` (list) and `sum` (counterMap) are conflict-free. There is no default — you
154
+ must state which you are taking.
155
+
156
+ **`key` splits documents.** Without one a var lands in the shared document; with one it gets its own, with its
157
+ own version guard and byte budget — so posting a note stops rewriting the rest of the world's state. Up to 6
158
+ destinations. Bare `saveRoom` persists every destination whose snapshot changed; `saveRoom` with a `key`
159
+ names one.
160
+
161
+ ## 4. The client — `src/main.ts` (delta from `hangout`)
162
+
163
+ Room vars are ordinary synced state, so a persisted board renders with **no extra read** — `persistent` only
164
+ changes whether it OUTLIVES the room:
165
+
166
+ ```ts
167
+ const room = mp.room!;
168
+ type Note = { author: string; text: string };
169
+
170
+ // Persistent ROOM state — replicated like any other roomVar, no fetch.
171
+ const notes = room.vars.list<Note>('notes');
172
+ const record = room.vars.num('bestRun');
173
+ boardEl.innerHTML = notes.map((n) => `<li><b>${n.author}</b> — ${n.text}</li>`).join('');
174
+ recordEl.textContent = record >= 999999 ? '—' : `${record.toFixed(1)}s`;
175
+
176
+ // Post a note (the room owns the write; the client only sends intent)
177
+ room.sendAction('postNote', { text, author: myDisplayName });
178
+
179
+ // The player's OWN blob — client-written KV, one JSON document per player
180
+ await Helix.dataStore.set({ favouriteColour: 'teal', tutorialDone: true });
181
+ const mine = await Helix.dataStore.get();
182
+
183
+ // Rankings + the community total (both read through the data store, not synced state)
184
+ const top = await Helix.leaderboard.top('main', { limit: 10 });
185
+ const donated = await Helix.dataStore.get('world:counter:donations');
186
+ ```
187
+
188
+ ## 5. Footguns — every one of these fails SILENTLY
189
+
190
+ - **A `min` var must default to something WORSE than any real value** (999999 for a time, not 0), or the
191
+ default wins every merge forever and the record can never be set. Mirrored for `max`.
192
+ - **A room reads its OWN view.** A `sum` or `append` var shows what this room hydrated plus its own changes;
193
+ other instances land in the stored document, not in this room's replicated state. The true cross-instance
194
+ value is `Helix.dataStore.get('mp:world')`. Same deal counters already make — it is why the policies must be
195
+ commutative: rooms never have to agree live.
196
+ - **Durability window.** Room state lives in memory until the flush (about 5 s, 60 s ceiling), so a hard crash
197
+ loses the tail. Fine for boards and tallies; **wrong for anything a player paid for** — that belongs on
198
+ `save` or `submitScore`, which are per-event.
199
+ - **`save` is event-driven, never a `tick` heartbeat.** Fire it on the beat that changed something worth
200
+ keeping. Every write spends a slice of the world's shared budget.
201
+ - **Renaming a persistent var resets it.** Stored documents are re-validated against the CURRENT build: wrong
202
+ types are discarded, numbers clamp, an enum miss falls back to the default, and vars you deleted are
203
+ dropped. Treat persistent names as a schema.
204
+ - **Publish fails on a `save` with no persistent playerVar, or a `saveRoom` with no persistent roomVar.** That
205
+ error means a forgotten `"persistent": true`, not a stray verb.
206
+ - **A `ref` never persists** — not as a var, not as a list element. A ref names a live player or entity in
207
+ THIS session and means nothing after a restart.
208
+ - **Bulk world data has no home here.** Ten thousand placed objects cannot be replicated state, so they cannot
209
+ be roomVars and the rule language cannot reach them. Splitting destinations does not help.
210
+
211
+ ## 6. Build, validate, publish
212
+
213
+ `npm install` → **`install_world_packages`** (MCP tool) → `npm run build` → `validate_world` on `dist/`
214
+ (watch the persistence caps: 16 persistent playerVars, 16 persistent roomVars, 6 destinations, and the
215
+ per-document byte budget) → **`helix verify-subpath dist`** (REQUIRED — the only local gate that serves the bundle from a nested path, the way a published world is actually served; preview/validate/inspect all serve from the origin root and cannot see a root-absolute 404) → `whoami` → `publish_world`.
216
+
217
+ To see it work, leave and rejoin: your level and title come back, and the guestbook survives the room itself
218
+ shutting down.