@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.
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
@@ -30,7 +30,7 @@ king-of-the-hill, domination, territory control, capture points, objective races
30
30
  "slug": "team-control",
31
31
  "entry": "index.html",
32
32
  "maxPlayers": 8,
33
- "permissions": ["auth.profile", "multiplayer"],
33
+ "permissions": ["auth.profile", "multiplayer", "voice.proximity"],
34
34
  "multiplayer": {
35
35
  "authoritative": true,
36
36
  "state": {
@@ -65,7 +65,7 @@ king-of-the-hill, domination, territory control, capture points, objective races
65
65
  },
66
66
  "supportsMobile": true,
67
67
  "contentRating": "everyone",
68
- "systems": { "humanoid-character": "^0.2" }
68
+ "systems": { "humanoid-character": "^0.3" }
69
69
  }
70
70
  ```
71
71
 
@@ -74,25 +74,38 @@ no health**. `varReached` fires once on the edge (false→true), so each point s
74
74
 
75
75
  ## 3. The client — `src/main.ts` (delta from `hangout`)
76
76
 
77
- Presence + team selection + a control-meter HUD (read the `roomVars`):
77
+ Presence + team selection + a control-meter HUD, all through the typed accessors. Team-join is a
78
+ custom action PAIR on the world-owned input router (character-world §8c) — F/H on the keyboard,
79
+ dpadUp/dpadDown on the pad (paired verbs share one symmetric button group):
78
80
 
79
81
  ```ts
80
- addEventListener('keydown', (e) => {
81
- if (e.key === '1') room.sendAction('joinRed');
82
- else if (e.key === '2') room.sendAction('joinBlue');
83
- });
82
+ const input = new InputService();
83
+ input.attach(window as never);
84
+ input.attachPointer(renderer.domElement as never, undefined, { dragLook: true });
85
+ input.registerAction('world.joinRed', { kind: 'button', keys: ['KeyF'], pad: 'dpadUp', label: 'Join red' }, 'world');
86
+ input.registerAction('world.joinBlue', { kind: 'button', keys: ['KeyH'], pad: 'dpadDown', label: 'Join blue' }, 'world');
87
+ const mp = await CharacterMultiplayer.create({ /* …, */ input });
84
88
 
85
- // each frame: tint your character by your team, draw the meter from room.state.roomVars
86
- const myTeam = String((room.state.players[room.sessionId]?.vars as Record<string, unknown> | undefined)?.team ?? 'blue');
87
- const control = numRoomVar('control'); // -100 (blue) … +100 (red)
88
- hud.innerHTML = `Hill: ${numRoomVar('redOnHill')}🔴 vs ${numRoomVar('blueOnHill')}🔵 · control ${control} · ${numRoomVar('redScore')}–${numRoomVar('blueScore')}`;
89
+ const room = mp.room!;
90
+ // each frame, AFTER mp.update(dt): read the INPUT action, send the DECLARED action
91
+ if (input.wasPressed('world.joinRed')) room.sendAction('joinRed');
92
+ else if (input.wasPressed('world.joinBlue')) room.sendAction('joinBlue');
93
+
94
+ // each frame: tint your character by your team, draw the meter off the room vars (live reads, guarded)
95
+ const myTeam = room.me.str('team', 'blue');
96
+ const control = room.vars.num('control'); // -100 (blue) … +100 (red)
97
+ hud.innerHTML = `Hill: ${room.vars.num('redOnHill')}🔴 vs ${room.vars.num('blueOnHill')}🔵 · control ${control} · ${room.vars.num('redScore')}–${room.vars.num('blueScore')}`
98
+ + `<br/><small>${input.format('{move} to move · {world.joinRed}/{world.joinBlue} to switch team')}</small>`;
89
99
  drawControlBar(control);
90
100
  ```
91
101
 
92
- **Footguns:** read `roomVars` live each frame (guard until the first patch); team is a server-written `playerVar`
93
- (the client sends the `joinRed`/`joinBlue` intent, the server sets it); `enum` on `team` makes the validator reject
94
- any value other than `red`/`blue`.
102
+ **Footguns:** team is a server-written `playerVar` (the client sends the `joinRed`/`joinBlue` intent, the server
103
+ sets it); `enum` on `team` makes the validator reject any value other than `red`/`blue` (a hardening ADDITION
104
+ here — the engine-repo template ships without it, and it also predates the typed accessors shown above);
105
+ accessors read live — call them per frame. Never name keys in the HUD — `input.format()` tokens render the
106
+ current binding for the active device (keyboard "F"/"H", pad "D-pad Up"/"D-pad Down") and stay correct after
107
+ rebinds.
95
108
 
96
109
  ## 4. Build, validate, publish
97
110
 
98
- `npm install` → **`install_world_packages`** (MCP tool) → `npm run build` → `validate_world` on `dist/` → `whoami` → `publish_world`.
111
+ `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`.
@@ -32,7 +32,7 @@ co-op boss fights, round-based battlers. Lifted from the verified `multiplayer-t
32
32
  "slug": "turn-arena",
33
33
  "entry": "index.html",
34
34
  "maxPlayers": 8,
35
- "permissions": ["auth.profile", "multiplayer"],
35
+ "permissions": ["auth.profile", "multiplayer", "voice.proximity"],
36
36
  "multiplayer": {
37
37
  "authoritative": true,
38
38
  "state": {
@@ -115,7 +115,7 @@ co-op boss fights, round-based battlers. Lifted from the verified `multiplayer-t
115
115
  },
116
116
  "supportsMobile": true,
117
117
  "contentRating": "everyone",
118
- "systems": { "humanoid-character": "^0.2" }
118
+ "systems": { "humanoid-character": "^0.3" }
119
119
  }
120
120
  ```
121
121
 
@@ -125,35 +125,44 @@ The turn gate is the key idiom: `if sameRef(self, listAt(turnOrder, turnIndex))`
125
125
 
126
126
  ## 3. The client — `src/main.ts` (delta from `hangout`)
127
127
 
128
- Presence + a turn/phase HUD. Read `room.state.phase` + the current player, bind the three actions, and react to
129
- the broadcasts:
128
+ Presence + a turn/phase HUD. Read the turn machinery through the typed accessors (`room.phase()`,
129
+ `room.currentTurn()`, `room.me.num(…)`) — no casts, no hand-rolled `turnOrder[turnIndex]` plumbing.
130
+ (The engine-repo template predates these accessors and hand-rolls the same reads; write NEW worlds
131
+ the accessor way shown here.)
130
132
 
131
- ```ts
132
- // whose turn? — read turnOrder[turnIndex] off room.state
133
- function currentTurnId(): string {
134
- const order = (room.state.roomVars as { turnOrder?: { length: number; [i: number]: { toString(): string } } } | undefined)?.turnOrder;
135
- const idx = numRoomVar('turnIndex');
136
- return order && order.length ? String(order[idx]) : '';
137
- }
138
- const myTurn = () => currentTurnId() === room.sessionId;
133
+ The verbs live on the world-owned input router (character-world §8c): strike is the STANDARD `melee`
134
+ id (V / right-stick click — never mint a private strike action), surrender/reset are custom actions
135
+ on genre-freed face buttons with `label`s:
139
136
 
140
- addEventListener('keydown', (e) => {
141
- if (e.key === ' ' && myTurn()) room.sendAction('strike'); // only on your turn
142
- else if (e.key === 'x') room.sendAction('surrender');
143
- else if (e.key === 'r') room.sendAction('reset'); // after won/defeat
144
- });
137
+ ```ts
138
+ const input = new InputService();
139
+ input.attach(window as never);
140
+ input.attachPointer(renderer.domElement as never, undefined, { dragLook: true });
141
+ registerStandardActions(input, { only: ['melee'] });
142
+ input.registerAction('world.surrender', { kind: 'button', keys: ['KeyX'], pad: 'faceLeft', label: 'Surrender' }, 'world');
143
+ input.registerAction('world.reset', { kind: 'button', keys: ['KeyR'], pad: 'faceUp', label: 'New round' }, 'world');
144
+ const mp = await CharacterMultiplayer.create({ /* …, */ input });
145
+ const room = mp.room!; // this template assumes a joined room
146
+
147
+ // each frame, AFTER mp.update(dt): input action → declared action
148
+ if (input.wasPressed('melee')) room.sendAction('strike'); // the server turn-gates it (sameRef)
149
+ if (input.wasPressed('world.surrender')) room.sendAction('surrender');
150
+ if (input.wasPressed('world.reset')) room.sendAction('reset'); // after won/defeat
145
151
 
146
152
  room.onMessage('turnChanged', (m) => showBanner(`Turn: ${nameOf(String(m.current))}`));
147
153
  room.onMessage('gameOver', (m) => showBanner(m.winner ? `🏆 ${nameOf(String(m.winner))} wins` : 'Defeat'));
148
154
 
149
- // each frame: render room.state.phase, currentTurnId(), your health.
155
+ // each frame (accessors read the LIVE state — call them fresh, never cache):
156
+ hud.textContent = `${room.phase()} · turn: ${nameOf(room.currentTurn())} · hp: ${room.me.num('health')}`
157
+ + ` · ${input.format('{melee} strike · {world.surrender} surrender · {world.reset} new round')}`;
150
158
  ```
151
159
 
152
- **Footguns:** read `room.state.phase` (a reserved built-in) + `roomVars` (incl. `turnIndex`) live each frame; gate
153
- `strike` on `myTurn()` client-side for UX, but the **server** re-checks `sameRef` (never trust the client). Refs
154
- on the wire stringify to a session id — compare with `room.sessionId`.
160
+ **Footguns:** you may gate `strike` on `room.isMyTurn()` client-side for UX, but the **server** re-checks
161
+ `sameRef` either way (never trust the client); accessors re-read live state each call — invoke them per frame,
162
+ don't cache results. Control names in the HUD come from `input.format()` tokens (keyboard "V"/"X"/"R", pad
163
+ "RS"/"X"/"Y"), never hardcoded text.
155
164
 
156
165
  ## 4. Build, validate, publish
157
166
 
158
167
  `npm install` → **`install_world_packages`** (MCP tool) → `npm run build` → `validate_world` on `dist/` (watch the cascade depth + the
159
- per-tick budget — `forEachEntity` × monsters) → `whoami` → `publish_world`.
168
+ per-tick budget — `forEachEntity` × monsters) → **`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,166 @@
1
+ # Multiplayer template — `voice-radio`
2
+
3
+ **What it is.** A hangout where players TALK: ambient **proximity voice** (hear who's near you, fading with
4
+ distance), **spatial** (directional — on headphones a speaker is heard from where they stand), three
5
+ walkie-talkie **frequencies** (keys 1–3 — flat, room-wide channels), and **private calls** (key C calls the
6
+ nearest player on a dynamic channel id). **Capability: voice chat + programmable audio channels.** Use this
7
+ for social spaces with voice, team radio, squad channels, phone calls, proximity-voice tuning. Lifted from
8
+ the verified `multiplayer-voice-radio` world.
9
+
10
+ > Voice is a PARALLEL media plane — no game-logic DSL is needed for any of this, and the room server is never
11
+ > involved. Channel membership is **exclusive**: a player is in the ambient space OR one channel, never both.
12
+ > The manifest block + permissions: `read_doc({ name: "manifest" })` §multiplayer.voice; the SDK surface:
13
+ > `read_doc({ name: "sdk" })` §Helix.voice.
14
+
15
+ ## 1. Voice config used (no game-logic DSL needed)
16
+
17
+ - **The `voice.proximity` permission** — voice chat with distance-attenuated ambient render (use `voice.room`
18
+ instead for flat, room-wide ambient — then set `mode: "global"`).
19
+ - **`multiplayer.voice.mode/refDistance/maxDistance`** — the ambient falloff: full volume within 4 m, silent
20
+ beyond 20 m, equal-power fade between. Tune per world scale (`refDistance` must stay below `maxDistance`).
21
+ - **`multiplayer.voice.spatial`** — directional ambient voice (default `false`): each speaker is HRTF-panned
22
+ to where they stand. Direction only — the falloff above still owns loudness. Channels are exempt by design
23
+ (a radio voice comes from the device, not the body — they stay center-panned). Declare it whenever presence
24
+ matters — realistic hangouts, horror, hide-and-seek, social worlds; the full when-to-use guide lives in the
25
+ multiplayer hub's voice section.
26
+ - **`multiplayer.voice.channels`** — three declared walkie-talkie frequencies. Declared channels default to
27
+ `"render": "global"` (flat — radio/phone semantics); declare `"render": "proximity"` instead to keep the
28
+ falloff inside a channel (a squad channel in a huge map). Max 32 declared channels.
29
+ - **Dynamic channel ids** — the private call uses an UNDECLARED id built at runtime; undeclared ids are always
30
+ allowed and render flat. Both callers derive the same id by sorting the pair of userIds.
31
+
32
+ ## 2. The manifest — `public/helix.json`
33
+
34
+ ```json
35
+ {
36
+ "helixVersion": "0.3",
37
+ "title": "Voice Radio",
38
+ "slug": "voice-radio",
39
+ "entry": "index.html",
40
+ "maxPlayers": 8,
41
+ "permissions": ["auth.profile", "multiplayer", "voice.proximity"],
42
+ "multiplayer": {
43
+ "authoritative": true,
44
+ "voice": {
45
+ "mode": "proximity",
46
+ "refDistance": 4,
47
+ "maxDistance": 20,
48
+ "spatial": true,
49
+ "channels": { "freq-1": {}, "freq-2": {}, "freq-3": {} }
50
+ }
51
+ },
52
+ "contentRating": "everyone",
53
+ "systems": { "humanoid-character": "^0.3" }
54
+ }
55
+ ```
56
+
57
+ Publish gates: a `voice` block requires a `voice.*` permission; `voice.*` requires `multiplayer`; a
58
+ `mode`/permission mismatch (e.g. `"proximity"` with only `voice.room`) warns. The block is CLIENT-render
59
+ config — your code passes it to the facade below; the platform never reads it server-side.
60
+
61
+ ## 3. The client — `src/main.ts` (delta from `hangout`)
62
+
63
+ Everything from `hangout` (the one facade call), plus the voice join and the channel switcher. Import your
64
+ own manifest so the voice block has ONE source of truth:
65
+
66
+ ```ts
67
+ import manifest from '../public/helix.json'; // vite JSON import — tsconfig needs "resolveJsonModule": true
68
+
69
+ const FREQUENCIES = ['freq-1', 'freq-2', 'freq-3'] as const;
70
+ // The JSON import widens string literals — re-narrow to the attachVoice options shape.
71
+ const VOICE_CONFIG = manifest.multiplayer.voice as {
72
+ mode: 'proximity' | 'global'; refDistance: number; maxDistance: number; spatial: boolean;
73
+ channels: Record<string, { render?: 'proximity' | 'global' }>;
74
+ };
75
+
76
+ // After CharacterMultiplayer.create(...) — the voice join is fail-soft: false (voice off for this
77
+ // environment) and errors BOTH leave the world fully playable; channels then no-op.
78
+ let voiceOn = false;
79
+ if (mp.room) {
80
+ try {
81
+ voiceOn = await Helix.voice.join();
82
+ if (voiceOn) mp.attachVoice(Helix.voice, VOICE_CONFIG); // proximity falloff + channel policies + nameplate glyphs
83
+ } catch (err) {
84
+ console.info('voice unavailable:', err);
85
+ }
86
+ }
87
+
88
+ // Radio controls on the world-owned input router (character-world §8c) — created before the facade
89
+ // and injected via `input`. Digits direct-select (no equip slots ⇒ ability1-9's digits are freed),
90
+ // the bumpers cycle the dial (no equip cycle ⇒ freed bumpers host cycling verbs), dpadUp = ambient/
91
+ // hang-up, and call-nearest is the STANDARD `interact` (E / face-left). Every custom has a `label`.
92
+ registerStandardActions(input, { only: ['interact'] });
93
+ input.registerAction('world.radioAmbient', { kind: 'button', keys: ['Digit0'], pad: 'dpadUp', label: 'Ambient' }, 'world');
94
+ input.registerAction('world.radioChannel1', { kind: 'button', keys: ['Digit1'], label: 'Radio 1' }, 'world');
95
+ input.registerAction('world.radioChannel2', { kind: 'button', keys: ['Digit2'], label: 'Radio 2' }, 'world');
96
+ input.registerAction('world.radioChannel3', { kind: 'button', keys: ['Digit3'], label: 'Radio 3' }, 'world');
97
+ input.registerAction('world.radioPrev', { kind: 'button', pad: 'bumperL', label: 'Radio down' }, 'world');
98
+ input.registerAction('world.radioNext', { kind: 'button', pad: 'bumperR', label: 'Radio up' }, 'world');
99
+
100
+ // Channel switching — EXCLUSIVE semantics: setChannel(id) leaves ambient, setChannel(null) returns.
101
+ let status = '';
102
+ const setChannel = (id: string | null, label: string): void => {
103
+ if (!voiceOn) return;
104
+ Helix.voice.setChannel(id).then(
105
+ () => void (status = label),
106
+ (err: unknown) => void (status = `channel switch failed: ${String(err)}`),
107
+ );
108
+ };
109
+
110
+ // each frame, AFTER mp.update(dt) — queries, not listeners; edges are latched by the router:
111
+ if (input.wasPressed('world.radioAmbient')) setChannel(null, 'ambient');
112
+ if (input.wasPressed('world.radioChannel1')) setChannel(FREQUENCIES[0], `radio 1 (${FREQUENCIES[0]})`);
113
+ // …channels 2/3 identical; the bumper dial cycles ambient → 1 → 2 → 3 with wraparound:
114
+ if (input.wasPressed('world.radioPrev')) cycleChannel(-1);
115
+ if (input.wasPressed('world.radioNext')) cycleChannel(1);
116
+ if (input.wasPressed('interact') && mp.room) {
117
+ const selfId = (mp.user as { id?: string } | null)?.id; // the facade keeps user untyped
118
+ const other = selfId ? nearestPlayerUserId(mp.room.state, body.position, selfId) : null;
119
+ if (!selfId || !other) status = 'no one nearby to call';
120
+ // Dynamic channel id: both callers sort the userId pair, so interacting near each other lands both
121
+ // in the SAME private call — no declaration needed.
122
+ else setChannel(`call:${[selfId, other].sort().join(':')}`, 'private call');
123
+ }
124
+ ```
125
+
126
+ `nearestPlayerUserId(state, localPos, selfId)` is **your own helper**: iterate `state.players` (each seat has
127
+ `userId` + `position`, contract-synced) and return the closest other player's userId. HUD tip: show
128
+ `Helix.voice.channel() ?? 'ambient'` so players always know what they're tuned to; the platform tablet's
129
+ Voice tab shows the same channel chip automatically. Render the control line from live hints, never literals —
130
+ e.g. `` `[${input.hint('world.radioAmbient')}] ambient · [${input.hint('interact')}] call nearest` `` — with ONE
131
+ exception: the keyboard push-to-talk key is the SDK's `pttKey` (default **N**), not a router action, so show the
132
+ key you passed to `join({ pttKey })` (or "N" if you kept the default — never hardcode a different letter) and
133
+ `input.hint('voicePTT')` (the modifier+RB chord) only when
134
+ `input.activeDevice() === 'gamepad' && input.hasAction('voicePTT')` (voicePTT is auto-registered by
135
+ `mp.attachVoice`; if the voice join failed it never registers). Mic behavior (PTT / open / muted, device,
136
+ volumes, per-player mutes) is the PLAYER's via the tablet — render none of it.
137
+
138
+ ## 4. Build, validate, publish
139
+
140
+ `npm install` → **`install_world_packages`** (MCP tool) (resolves the `humanoid-character` pin) → `npm run
141
+ build` → `validate_world` on `dist/` (fix every problem) → **`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`. The build contains no
142
+ `.glb`/`.ktx2` — character assets stream from the CDN.
143
+
144
+ ## Appendix — server-driven channels (teams → voice), still no server code
145
+
146
+ Channel switching above is client-initiated. To have the GAME assign channels (team voice, a phase that
147
+ silences everyone), drive it from declared state: a rule sets a `voiceChannel` playerVar, and each client
148
+ follows it — membership becomes server-authoritative without any server code.
149
+
150
+ ```json
151
+ "state": { "playerVars": { "voiceChannel": { "type": "string", "default": "" } } },
152
+ "actions": { "joinRed": {} },
153
+ "rules": [
154
+ { "when": { "on": "action", "name": "joinRed" },
155
+ "then": [{ "do": "set", "target": "self.voiceChannel", "to": "team-red" }] }
156
+ ]
157
+ ```
158
+
159
+ ```ts
160
+ // Client: follow your own synced playerVar into the voice layer (poll per frame or on change).
161
+ const assigned = mp.room.me.str('voiceChannel') || null;
162
+ if (voiceOn && assigned !== Helix.voice.channel()) void Helix.voice.setChannel(assigned);
163
+ ```
164
+
165
+ Declare `team-red` under `multiplayer.voice.channels` if it should render with proximity; leave it undeclared
166
+ for flat radio semantics.
@@ -35,7 +35,7 @@ verified `multiplayer-wave-survival-2` world.
35
35
  "slug": "wave-survival",
36
36
  "entry": "index.html",
37
37
  "maxPlayers": 8,
38
- "permissions": ["auth.profile", "multiplayer"],
38
+ "permissions": ["auth.profile", "multiplayer", "voice.proximity"],
39
39
  "multiplayer": {
40
40
  "authoritative": true,
41
41
  "state": {
@@ -83,7 +83,7 @@ verified `multiplayer-wave-survival-2` world.
83
83
  },
84
84
  "supportsMobile": true,
85
85
  "contentRating": "everyone",
86
- "systems": { "humanoid-character": "^0.2" }
86
+ "systems": { "humanoid-character": "^0.3" }
87
87
  }
88
88
  ```
89
89
 
@@ -97,11 +97,10 @@ host** (`controller === sessionId`), interpolates the rest, and freezes orphans
97
97
  server re-elects a host. The motion fn is the **AI** — chase the nearest player:
98
98
 
99
99
  ```ts
100
- import { EntityScene } from '@helix/humanoid-character';
101
100
  const ENEMY_MAX_SPEED = 6; // MUST mirror the DSL maxSpeed (reconcile clamp)
101
+ const room = mp.room!;
102
102
 
103
- const entities = new EntityScene({
104
- room,
103
+ const entities = mp.entities({
105
104
  maxSpeed: { enemy: ENEMY_MAX_SPEED },
106
105
  motion: {
107
106
  // Chase the nearest player. ctx.seekNearest(speed, stopAt) returns the gate-safe next position: it caps the
@@ -113,12 +112,12 @@ const entities = new EntityScene({
113
112
  const mesh = makeEnemy(); scene.add(mesh);
114
113
  return {
115
114
  object3d: mesh,
116
- onUpdate: (e) => { mesh.visible = true; tintByController(mesh, e.controller, room!.sessionId); }, // host / orphan tint
115
+ onUpdate: (e) => { mesh.visible = true; tintByController(mesh, e.controller, room.sessionId); }, // host / orphan tint
117
116
  dispose: () => scene.remove(mesh),
118
117
  };
119
118
  },
120
119
  });
121
- // frame loop (inside `if (room)`): entities.update(dt);
120
+ // mp.update(dt) drives the sim/interpolation — nothing to add in your frame loop.
122
121
 
123
122
  // Shoot the nearest enemy in front of you — client picks the id, server validates range + destroys:
124
123
  function shootNearest() {
@@ -138,4 +137,4 @@ motion step `≤ maxSpeed × 0.83 × dt`; you won't host every enemy (shared)
138
137
 
139
138
  ## 4. Build, validate, publish
140
139
 
141
- `npm install` → **`install_world_packages`** (MCP tool) → `npm run build` → `validate_world` on `dist/` → `whoami` → `publish_world`.
140
+ `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,240 @@
1
+ # Multiplayer template — `world-shop`
2
+
3
+ **What it is.** A world that **sells things** and honours them afterwards. A coin pack you spend on doors, a
4
+ potion 3-pack you drink, a VIP day pass that lapses on its own, a permanent founder badge, a bundle that hands
5
+ over two things in one charge, a tip jar that hands over nothing, and a free daily claim —
6
+ then the room reacts to every sale and gates play on what the buyer owns. **Capability: in-world purchases
7
+ (IWP)** — the eight product shapes, and (the part that actually matters) **which one to reach for**. Lifted
8
+ from the verified `iwp-shop-playground` (single-player) and `multiplayer-iwp-shop` (the room-side capstone).
9
+
10
+ > A normal character world (presence) **plus** money. Grammar: `read_doc({ name: "purchases" })` for the whole
11
+ > system, `read_doc({ name: "multiplayer-logic" })` §20 for the room-side rules.
12
+
13
+ ## 1. Pick the grant shape FIRST — this is the whole template
14
+
15
+ A product's `grants` array is **immutable once registered**, so this is the one decision you cannot walk back.
16
+ Money bugs here are almost never syntax; they are picking a shape that cannot deliver what you promised.
17
+
18
+ | What the player is buying | Grant shape | Why this one |
19
+ |---|---|---|
20
+ | Soft currency to spend later (coins, gems, credits) | `{ kind: "currency", code, amount, display: "wallet" }` | a server-held balance; spend it with `consume` |
21
+ | A stack of uses (potions, revives, hints) | `{ kind: "currency", code, amount, display: "uses" }` | same mechanism — `display` is only a UI hint |
22
+ | A subscription / day pass / battle pass | `{ kind: "pass", durationSeconds }` | lapses on its own; re-purchase EXTENDS from the current expiry |
23
+ | A permanent unlock (founder badge, ad-free, a character slot) | `{ kind: "pass" }` (no duration) | never lapses — and caps the product at 1 per player |
24
+ | A portable item in universal inventory | **Not a World Product** | publish it, create an ItemDistribution, then request the registered distribution key |
25
+ | A bundle — several things, one charge | several effects in one array (max 8) | one popup, one price, all-or-nothing fulfilment |
26
+ | A tip / donation / "support the dev" | `grants: []` with a price | charges, records the sale, hands over nothing |
27
+ | A daily freebie or a tutorial reward | any shape with `priceLix: 0` | a **Claim** — the popup confirms, nothing is spent |
28
+
29
+ **The two that look interchangeable and are not.** A **currency** balance is arithmetic you spend down; a
30
+ **pass** is a boolean you check. If the player can "run out", it is currency. If the answer is only yes/no, it
31
+ is a pass — modelling a pass as `balanceOf(x) >= 1` works right up until someone consumes it by accident.
32
+
33
+ **A permanent-pass-only product is capped at `maxPerUser: 1`, and registration REFUSES a higher value.** A
34
+ re-buy would charge again and deliver nothing it does not already have. If you want it re-buyable, add a timed
35
+ pass or a currency effect.
36
+
37
+ ## 2. Register the products BEFORE writing the code that buys them
38
+
39
+ `helix.json` has **no products block** and world code cannot mint one — a world can only sell what its creator
40
+ registered on it. `purchaseProduct` on an unregistered key returns `ProductNotFound` at runtime, which is a
41
+ silent-looking failure in a UI that just does nothing.
42
+
43
+ ```
44
+ register_world_product({ worldSlug: "world-shop", key: "coin-pack", title: "Coin Pack", priceLix: 25, grants: [{ kind: "currency", code: "coin", amount: 500, display: "wallet" }] })
45
+ register_world_product({ worldSlug: "world-shop", key: "potion-pack", title: "Potion 3-Pack", priceLix: 10, grants: [{ kind: "currency", code: "potion", amount: 3, display: "uses" }] })
46
+ register_world_product({ worldSlug: "world-shop", key: "vip-pass", title: "VIP Day Pass", priceLix: 40, grants: [{ kind: "pass", durationSeconds: 86400 }] })
47
+ register_world_product({ worldSlug: "world-shop", key: "founder-badge", title: "Founder Badge", priceLix: 100, grants: [{ kind: "pass", passKey: "founder" }] })
48
+ register_world_product({ worldSlug: "world-shop", key: "starter-bundle", title: "Starter Bundle", priceLix: 60, grants: [{ kind: "currency", code: "coin", amount: 100 }, { kind: "pass", passKey: "founder" }] })
49
+ register_world_product({ worldSlug: "world-shop", key: "tip-jar", title: "Tip Jar", priceLix: 5, grants: [] })
50
+ register_world_product({ worldSlug: "world-shop", key: "daily-gift", title: "Daily Gift", priceLix: 0, grants: [{ kind: "currency", code: "coin", amount: 10 }] })
51
+ ```
52
+
53
+ - Pass `dryRun: true` first — it validates the whole shape locally, with no network and no login.
54
+ - A **pass with no `passKey` defaults to the product key** (`vip-pass` above grants the pass `vip-pass`).
55
+ Name it explicitly when two products grant the SAME pass, as `starter-bundle` and `founder-badge` do.
56
+ - The **key is immutable** and the **price lives server-side only** — a world never sends an amount.
57
+ - Registering works on a **draft** world; **buying** needs it live (Published or Unlisted).
58
+ - `list_world_products` shows what is registered; `update_world_product` changes title/description/price/active
59
+ — never the key or the grants.
60
+
61
+ ## 3. DSL used
62
+
63
+ - **`{ "on": "purchase" }`** (§20) — fires on the buyer's seat when a settled sale reaches the room. Reads
64
+ `purchase.productKey` and `purchase.purchaseId`, and nothing else.
65
+ - **`hasPass` / `balanceOf`** (§20) — O(1) reads off the per-seat entitlement snapshot. `of` is REQUIRED.
66
+ - **`consume`** (§20) — the server-side atomic, floor-at-zero spend.
67
+ - **`counters` + `increment`, `save`, `awardAchievement`** (§18/§19) — where a purchase's consequences go, because
68
+ the purchase rule fires **exactly once, ever**.
69
+ - **Zones + actions** — the beats that read the entitlements.
70
+
71
+ ## 4. The manifest — `public/helix.json` (the money block)
72
+
73
+ ```json
74
+ {
75
+ "helixVersion": "0.3",
76
+ "title": "World Shop",
77
+ "slug": "world-shop",
78
+ "entry": "index.html",
79
+ "maxPlayers": 8,
80
+ "permissions": ["auth.profile", "multiplayer"],
81
+ "contentRating": "everyone",
82
+ "multiplayer": {
83
+ "authoritative": true,
84
+ "state": {
85
+ "playerVars": {
86
+ "tier": { "type": "string", "default": "free", "enum": ["free", "vip", "founder"], "persistent": true },
87
+ "potionsDrunk": { "type": "number", "default": 0, "min": 0, "max": 9999, "integer": true, "persistent": true }
88
+ },
89
+ "roomVars": {
90
+ "sales": { "type": "number", "default": 0 },
91
+ "lastProduct": { "type": "string", "default": "", "maxLen": 64 },
92
+ "tips": { "type": "number", "default": 0 }
93
+ }
94
+ },
95
+ "counters": { "lifetimeSales": {} },
96
+ "zones": [ { "id": "lounge", "shape": "sphere", "center": [12, 0, 0], "radius": 4 } ],
97
+ "actions": { "openDoor": {}, "drinkPotion": {} },
98
+ "events": { "loungeEntered": { "payload": {} } },
99
+ "rules": [
100
+ { "when": { "on": "purchase" },
101
+ "then": [
102
+ { "do": "add", "target": "room.sales", "by": 1 },
103
+ { "do": "set", "target": "room.lastProduct", "to": { "var": "purchase.productKey" } },
104
+ { "do": "increment", "counter": "lifetimeSales", "by": 1 }
105
+ ] },
106
+
107
+ { "when": { "on": "purchase" },
108
+ "if": { "op": "==", "a": { "var": "purchase.productKey" }, "b": "tip-jar" },
109
+ "then": [ { "do": "add", "target": "room.tips", "by": 1 } ] },
110
+
111
+ { "when": { "on": "purchase" },
112
+ "if": { "op": "hasPass", "passKey": "founder", "of": "self" },
113
+ "then": [
114
+ { "do": "set", "target": "self.tier", "to": "founder" },
115
+ { "do": "save", "player": "self" },
116
+ { "do": "awardAchievement", "key": "founder", "player": "self" }
117
+ ] },
118
+
119
+ { "when": { "on": "playerJoin" },
120
+ "if": { "op": "hasPass", "passKey": "vip-pass", "of": "self" },
121
+ "then": [ { "do": "set", "target": "self.tier", "to": "vip" }, { "do": "save", "player": "self" } ] },
122
+
123
+ { "when": { "on": "zoneEnter", "zone": "lounge" },
124
+ "if": { "op": "hasPass", "passKey": "vip-pass", "of": "self" },
125
+ "then": [ { "do": "broadcast", "event": "loungeEntered", "to": "self", "payload": {} } ] },
126
+
127
+ { "when": { "on": "action", "name": "openDoor" },
128
+ "if": { "op": ">=", "a": { "op": "balanceOf", "code": "coin", "of": "self" }, "b": 20 },
129
+ "then": [ { "do": "consume", "code": "coin", "amount": 20, "player": "self" } ] },
130
+
131
+ { "when": { "on": "action", "name": "drinkPotion" },
132
+ "if": { "op": ">=", "a": { "op": "balanceOf", "code": "potion", "of": "self" }, "b": 1 },
133
+ "then": [
134
+ { "do": "consume", "code": "potion", "amount": 1, "player": "self" },
135
+ { "do": "add", "target": "self.potionsDrunk", "by": 1 },
136
+ { "do": "save", "player": "self" }
137
+ ] }
138
+ ]
139
+ },
140
+ "systems": { "humanoid-character": "^0.3" }
141
+ }
142
+ ```
143
+
144
+ **The purchase rule fires once and never again**, so its consequences must be durable — `save`, `increment`,
145
+ `submitScore`, `awardAchievement`. Notice `room.sales` is deliberately NOT the record of a sale: it is a live
146
+ HUD number that dies with the room, while `lifetimeSales` is the counter that actually keeps it.
147
+
148
+ **Branch on `productKey` in `if`, one rule per product** — the event itself takes no params, so every
149
+ `{on:"purchase"}` rule fires for every sale until its own `if` filters it.
150
+
151
+ **The entitlement snapshot is refreshed BEFORE the purchase rule runs**, which is why the founder rule can ask
152
+ `hasPass` about the very pass that sale just granted.
153
+
154
+ ## 5. The client — `src/main.ts` (delta from `hangout`)
155
+
156
+ ```ts
157
+ import { Helix } from '@hypersoniclabs/helix-sdk';
158
+
159
+ // ── buy ────────────────────────────────────────────────────────────────────────
160
+ const result = await Helix.marketplace.purchaseProduct('coin-pack'); // buys pkey:coin-pack
161
+ if (result.completed) refresh(); // Granted | Claimed | AlreadyOwned
162
+ else if (result.status === 'InsufficientFunds') showTopUp();
163
+ else if (result.status === 'PriceChanged') { /* nothing charged; the shell re-prompts */ }
164
+
165
+ // ── read what fulfilment granted ───────────────────────────────────────────────
166
+ const ent = await Helix.purchases.getEntitlements();
167
+ // { v: 1, passes: { 'vip-pass': { since, expiresAt, active } }, balances: { coin: 740, potion: 3 }, owned: {} }
168
+ if (ent.passes['vip-pass']?.active) unlockLounge();
169
+ hud.coins = ent.balances.coin ?? 0;
170
+
171
+ // ── spend (client-side; the room-side twin is the `consume` effect in §4) ───────
172
+ const spent = await Helix.purchases.consume('potion', 1);
173
+ if (spent.applied) drinkAnimation(); else offerShop(spent.balance);
174
+
175
+ // ── stay in sync — fires after the shell settles a sale, no payload ────────────
176
+ Helix.purchases.onEntitlementsChanged(() => refresh());
177
+
178
+ // ── multiplayer: nothing to wire ───────────────────────────────────────────────
179
+ // The SDK forwards the backend receipt to the room automatically, so the {on:purchase}
180
+ // rule above fires with no world code. `result.receipt` is only evidence it was sent.
181
+ room.sendAction('openDoor', {}); // the ROOM decides: balanceOf(coin) >= 20 ⇒ consume 20
182
+ ```
183
+
184
+ **Surviving a reload mid-purchase.** Supply your own `idempotencyKey`, keep it, and re-read on boot — this is
185
+ the difference between a player who got what they paid for and a support ticket:
186
+
187
+ ```ts
188
+ const key = localStorage.getItem('buy:coin-pack') ?? crypto.randomUUID();
189
+ localStorage.setItem('buy:coin-pack', key);
190
+ const res = await Helix.marketplace.purchaseProduct('coin-pack', { idempotencyKey: key });
191
+
192
+ // …after a reload, before showing the shop:
193
+ const prior = await Helix.purchases.getPurchase(key, 'coin-pack'); // PASS the ref
194
+ if (prior?.completed) { localStorage.removeItem('buy:coin-pack'); refresh(); }
195
+ ```
196
+
197
+ **A custom confirm UI** — product, the player's live balance, eligibility and the `grants` list in one call
198
+ (the built-in popup uses this internally; the popup still settles the sale):
199
+
200
+ ```ts
201
+ const ctx = await Helix.marketplace.getPurchaseContext('vip-pass');
202
+ ```
203
+
204
+ ## 6. Footguns — every one of these fails SILENTLY
205
+
206
+ - **Never mirror a pass or a balance into a playerVar, roomVar or client state.** A session copy resets on the
207
+ next join while the entitlement does not, and the two then disagree about money. Read the entitlement where
208
+ you need it. (`self.tier` above is a derived *label* for the HUD, never the authority for access.)
209
+ - **`of` is REQUIRED on `hasPass`/`balanceOf`** — there is no implicit `self`, and an entity ref is a publish
210
+ error.
211
+ - **Deny by default:** an unheld or unregistered `passKey` reads `false`, an unknown `code` reads `0`, and a
212
+ guest seat reads the same. **Publish does not check the product registry** — a typo in `passKey` publishes
213
+ clean and silently reads empty forever.
214
+ - **A guest seat owns no purchase**, so `{on:purchase}` never fires for one.
215
+ - **The purchase rule fires exactly once per sale, platform-enforced** — a re-forward, a rejoin, a tab that died
216
+ mid-sale and a second instance of your world all collapse to ONE firing. Anything it grants into
217
+ session-scoped state is gone forever, because the rule will not fire again to rebuild it.
218
+ - **`priceLix: 0` is a Claim, not a bypass** — the popup still confirms, and the status is `Claimed`, not
219
+ `Granted`. Branch on `completed`, not on `Granted` alone.
220
+ - **`AlreadyOwned` is `completed` but nothing was charged and nothing new arrived.** Treat it as "they have it",
221
+ never as "a sale happened".
222
+ - **A currency `code` and a `passKey` are lowercase slugs** (`^[a-z0-9][a-z0-9_-]{0,63}$`). A world may mint at
223
+ most **16 distinct currency codes**, and a `grants` array holds at most **8** effects.
224
+ - **`consume` is per-call, not per-key.** Retries of the SAME call are replay-safe; calling it twice is two
225
+ spends. Do not "retry" by calling again.
226
+ - **Registration does not need a published world; buying does.** A product on a draft world exists, lists, and
227
+ refuses to sell.
228
+
229
+ ## 7. Build, validate, publish
230
+
231
+ `npm install` → **`install_world_packages`** (MCP tool) → **`create_world`** (first time only — the product
232
+ registry hangs off the world row, which nothing has minted yet) → **register the products** (§2) → `npm run build` →
233
+ **`helix dev`** — drive the whole shop under the simulated shell BEFORE anything real: seed the same product
234
+ rows in `.helix/dev-products.json`, buy, consume, and force InsufficientFunds / PriceChanged / Pending from the
235
+ debug menu (nothing is charged; see the sdk doc's *Local development*) → `validate_world` on `dist/` →
236
+ **`helix verify-subpath dist`** (REQUIRED — the only local gate that serves the
237
+ bundle from a nested path, the way a published world is actually served) → `whoami` → `publish_world`.
238
+
239
+ To see it work, buy the coin pack and walk into the lounge with a second client watching: `room.sales` moves on
240
+ their screen, which is proof the receipt reached the room and not just the buyer.