@hypersoniclabs/helix-mcp 0.2.4 → 0.2.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +81 -11
- package/dist/continuumCanary.d.ts +17 -0
- package/dist/continuumCanary.js +17 -0
- package/dist/continuumCanary.js.map +1 -0
- package/dist/server.d.ts +14 -1
- package/dist/server.js +4058 -162
- package/dist/server.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/dist/vehicleTools.d.ts +86 -0
- package/dist/vehicleTools.js +229 -0
- package/dist/vehicleTools.js.map +1 -0
- package/docs/avatar-face.md +115 -0
- package/docs/bridge.md +98 -0
- package/docs/bring-your-world.md +117 -0
- package/docs/catalog.md +69 -1
- package/docs/character-animation.md +442 -0
- package/docs/character-attachments.md +166 -0
- package/docs/character-world.md +785 -130
- package/docs/continuum.md +153 -0
- package/docs/items.md +73 -0
- package/docs/lighting-world.md +667 -0
- package/docs/locomotion-clip-spec.md +294 -0
- package/docs/manifest.md +31 -6
- package/docs/multiplayer-logic.md +460 -17
- package/docs/multiplayer-templates/chrono-orchard.md +36 -22
- package/docs/multiplayer-templates/collect-a-thon.md +28 -38
- package/docs/multiplayer-templates/collections.md +24 -24
- package/docs/multiplayer-templates/hangout.md +124 -111
- package/docs/multiplayer-templates/npc-wave.md +310 -0
- package/docs/multiplayer-templates/obby.md +13 -16
- package/docs/multiplayer-templates/persistent-progress.md +218 -0
- package/docs/multiplayer-templates/physics-bumper.md +27 -9
- package/docs/multiplayer-templates/physics-football.md +22 -8
- package/docs/multiplayer-templates/relic-bearers.md +12 -15
- package/docs/multiplayer-templates/server-motion.md +16 -19
- package/docs/multiplayer-templates/shooter-range.md +275 -0
- package/docs/multiplayer-templates/team-control.md +28 -15
- package/docs/multiplayer-templates/turn-arena.md +31 -22
- package/docs/multiplayer-templates/voice-radio.md +166 -0
- package/docs/multiplayer-templates/wave-survival.md +7 -8
- package/docs/multiplayer-templates/world-shop.md +240 -0
- package/docs/multiplayer-world.md +222 -129
- package/docs/npc-world.md +623 -0
- package/docs/publishing.md +108 -28
- package/docs/purchases.md +223 -0
- package/docs/scene-performance.md +64 -0
- package/docs/screenshots.md +140 -0
- package/docs/sdk.md +324 -5
- package/docs/shooter-worlds.md +537 -0
- package/docs/terrain.md +173 -0
- package/docs/upgrades.md +324 -0
- package/docs/vehicles.md +727 -0
- package/docs/world-inspect.md +156 -0
- package/docs/world-look.md +241 -0
- package/docs/world-recipe.md +65 -6
- package/package.json +15 -4
- package/skills/README.md +91 -0
- package/skills/helix-assets/SKILL.md +491 -0
- package/skills/helix-assets/references/asset-sources.md +143 -0
- package/skills/helix-assets/references/vault-api.md +105 -0
- package/skills/helix-avatar-qa/SKILL.md +85 -0
- package/skills/helix-avatars/SKILL.md +206 -0
- package/skills/helix-avatars/references/contract.md +166 -0
- package/skills/helix-avatars/references/dynamics.md +367 -0
- package/skills/helix-avatars/references/face.md +50 -0
- package/skills/helix-avatars/references/publish.md +76 -0
- package/skills/helix-avatars/references/qa.md +251 -0
- package/skills/helix-avatars/references/rigging.md +88 -0
- package/skills/helix-avatars/references/source-generated.md +190 -0
- package/skills/helix-avatars/references/source-model.md +90 -0
- package/skills/helix-avatars/references/source-rigid.md +90 -0
- package/skills/helix-avatars/references/source-vrm.md +61 -0
- package/skills/helix-gauntlet/SKILL.md +128 -0
- package/skills/helix-multiplayer/SKILL.md +150 -0
- package/skills/helix-multiplayer/references/dsl-capability-map.md +107 -0
- package/skills/helix-vehicles/SKILL.md +218 -0
- package/skills/helix-vehicles/references/addons.md +212 -0
- package/skills/helix-vehicles/references/appearance.md +339 -0
- package/skills/helix-vehicles/references/audio-import.md +138 -0
- package/skills/helix-vehicles/references/audio.md +580 -0
- package/skills/helix-vehicles/references/cabin.md +225 -0
- package/skills/helix-vehicles/references/host-manifest.md +174 -0
- package/skills/helix-vehicles/references/physics-from-specs.md +463 -0
- package/skills/helix-vehicles/references/publish.md +214 -0
- package/skills/helix-vehicles/references/qa.md +177 -0
- package/skills/helix-vehicles/references/reference-package.json +3481 -0
- package/skills/helix-vehicles/references/reference-package.md +69 -0
- package/skills/helix-vehicles/references/source-beamng.md +167 -0
- package/skills/helix-vehicles/references/source-concept.md +38 -0
- package/skills/helix-vehicles/references/source-model.md +100 -0
- package/skills/helix-vehicles/references/source-scratch.md +60 -0
- package/skills/helix-world-build/SKILL.md +376 -0
- package/skills/helix-world-build/references/config-gates.md +104 -0
- package/skills/helix-world-director/SKILL.md +210 -0
- package/skills/helix-world-qa/SKILL.md +371 -0
- package/skills/helix-world-qa/references/perf-budgets.md +240 -0
- package/skills/helix-world-qa/references/perf-handle.md +125 -0
- package/skills/helix-world-qa/references/visual-scorecard.md +85 -0
|
@@ -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.
|
|
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
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
|
78
|
-
|
|
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.
|