@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 @@ farming, idle/incremental, day-cycle, anything where elapsed real time matters.
30
30
  "slug": "chrono-orchard",
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": {
@@ -80,7 +80,7 @@ farming, idle/incremental, day-cycle, anything where elapsed real time matters.
80
80
  },
81
81
  "supportsMobile": true,
82
82
  "contentRating": "everyone",
83
- "systems": { "humanoid-character": "^0.2" }
83
+ "systems": { "humanoid-character": "^0.3" }
84
84
  }
85
85
  ```
86
86
 
@@ -90,29 +90,43 @@ thresholds are all plain expressions. `season` re-arms itself, so the weather to
90
90
 
91
91
  ## 3. The client — `src/main.ts` (delta from `hangout`)
92
92
 
93
- Presence + a crop render-proxy (server entities, like `collect-a-thon`) **scaled by `growth`**, plus the two
94
- actions and the weather banner:
93
+ Presence + a crop `mp.entities()` proxy (server entities, like `collect-a-thon`) **scaled by `growth`**, plus the
94
+ two actions and the weather banner:
95
95
 
96
96
  ```ts
97
- const crops = new Map<string, { mesh: THREE.Object3D; state: EntityState }>();
98
- if (room) {
99
- room.onAdd('entities', (e, id) => { const m = makeSprout(); scene.add(m); crops.set(id, { mesh: m, state: e }); });
100
- room.onRemove('entities', (_e, id) => { const c = crops.get(id); if (c) { scene.remove(c.mesh); crops.delete(id); } });
101
- }
97
+ const entities = mp.entities({
98
+ build: () => {
99
+ const mesh = makeSprout(); scene.add(mesh);
100
+ return {
101
+ object3d: mesh,
102
+ // per-frame: grow each crop visually from its live synced `growth` (clamped to a ripe size at ~15s)
103
+ onUpdate: (e) => {
104
+ const g = Math.min(1, Number((e.vars as Record<string, unknown>)?.growth ?? 0) / 15);
105
+ mesh.scale.setScalar(0.2 + 0.8 * g); // sprout → ripe
106
+ },
107
+ dispose: () => scene.remove(mesh),
108
+ };
109
+ },
110
+ });
102
111
 
103
- // frame loop (inside `if (room)`): grow each crop visually from its synced `growth` (clamped to a ripe size at ~15s)
104
- for (const [, c] of crops) {
105
- if (c.state.position) c.mesh.position.set(c.state.position.x, c.state.position.y, c.state.position.z);
106
- const g = Math.min(1, Number((c.state.vars as Record<string, unknown>)?.growth ?? 0) / 15);
107
- c.mesh.scale.setScalar(0.2 + 0.8 * g); // sprout → ripe
108
- }
112
+ // World-owned input router (character-world §8c): plant is the STANDARD `interact` (E / face-left),
113
+ // water is a custom action on genre-freed reload real estate (KeyF + faceUp) with a `label`.
114
+ const input = new InputService();
115
+ input.attach(window as never);
116
+ input.attachPointer(renderer.domElement as never, undefined, { dragLook: true });
117
+ registerStandardActions(input, { only: ['interact'] });
118
+ input.registerAction('world.water', { kind: 'button', keys: ['KeyF'], pad: 'faceUp', label: 'Water' }, 'world');
119
+ const mp = await CharacterMultiplayer.create({ /* …, */ input });
109
120
 
110
- addEventListener('keydown', (e) => {
111
- if (e.key === 'e') room.sendAction('plant');
112
- else if (e.key === 'q') room.sendAction('water'); // server enforces the 3s cooldown
113
- });
114
- room.onMessage('weatherChanged', (m) => showBanner(Number(m.reward) > 1 ? '☀ Bumper crop! (reward up)' : '☁ Lean season'));
115
- room.onMessage('watered', () => showBanner('💧 Watered — crops sped up'));
121
+ // each frame, AFTER mp.update(dt): input action → declared action
122
+ if (input.wasPressed('interact')) mp.room?.sendAction('plant');
123
+ if (input.wasPressed('world.water')) mp.room?.sendAction('water'); // server enforces the 3s cooldown
124
+
125
+ mp.room?.onMessage('weatherChanged', (m) => showBanner(Number(m.reward) > 1 ? '☀ Bumper crop! (reward up)' : '☁ Lean season'));
126
+ mp.room?.onMessage('watered', () => showBanner('💧 Watered — crops sped up'));
127
+
128
+ // HUD control hints render through tokens (E/F on keyboard, X/Y on pad), never hardcoded key names:
129
+ hud.innerHTML = `…<small>${input.format('{interact} plant where you stand · {world.water} waters')}</small>`;
116
130
  ```
117
131
 
118
132
  **Footguns:** time is **server** time (`{op:now}`) — never compute elapsed from a client clock; read each crop's
@@ -120,4 +134,4 @@ live `growth` per frame; the `water` cooldown is server-enforced (the client jus
120
134
 
121
135
  ## 4. Build, validate, publish
122
136
 
123
- `npm install` → **`install_world_packages`** (MCP tool) → `npm run build` → `validate_world` on `dist/` → `whoami` → `publish_world`.
137
+ `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`.
@@ -30,7 +30,7 @@ A small slice of the DSL (each links into `multiplayer-logic`):
30
30
  "slug": "collect-a-thon",
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": {
@@ -70,7 +70,7 @@ A small slice of the DSL (each links into `multiplayer-logic`):
70
70
  },
71
71
  "supportsMobile": true,
72
72
  "contentRating": "everyone",
73
- "systems": { "humanoid-character": "^0.2" }
73
+ "systems": { "humanoid-character": "^0.3" }
74
74
  }
75
75
  ```
76
76
 
@@ -80,47 +80,37 @@ a typo'd verb/var with a did-you-mean — run `validate_world` and read the path
80
80
 
81
81
  ## 3. The client — `src/main.ts` (delta from `hangout`)
82
82
 
83
- Same presence wiring as `hangout` (join, replicas, `sendState`). The **new part** is the *entity render-proxy*:
84
- spawn a mesh per synced server entity and render it off its **live** state each frame — the client never writes
85
- entity state (the server owns it).
83
+ Presence is the facade (hangout §3). The **new part** is rendering the synced server entities —
84
+ **`mp.entities()`**: a mesh per entity, positioned off EntityScene's source-time interpolation (a kind with no
85
+ `motion` fn is server-authoritative: render only, never uploaded). The client never writes entity state.
86
86
 
87
87
  ```ts
88
- import type { EntityState } from '@hypersoniclabs/helix-sdk';
89
-
90
- const coins = new Map<string, { mesh: THREE.Mesh; state: EntityState }>();
91
- const isActive = (e: EntityState) => (e.vars as Record<string, unknown> | undefined)?.active !== false;
92
-
93
- if (room) {
94
- // Spawn a coin mesh on add, drop it on destroy. `entity` is a LIVE ref colyseus mutates in place —
95
- // stash it and read .position / .vars each frame (no per-entity subscription needed).
96
- room.onAdd('entities', (entity, id) => {
97
- const mesh = makeCoin(); scene.add(mesh); // makeCoin(): your spinning-disc THREE.Mesh
98
- coins.set(id, { mesh, state: entity });
99
- });
100
- room.onRemove('entities', (_entity, id) => {
101
- const c = coins.get(id);
102
- if (c) { scene.remove(c.mesh); c.mesh.geometry.dispose(); coins.delete(id); }
103
- });
104
- }
105
-
106
- // …in the frame loop, inside `if (room) { … }`, after replicas.update(dt):
107
- for (const [, c] of coins) {
108
- const active = isActive(c.state);
109
- c.mesh.visible = active; // hidden after pickup until the respawn timer fires
110
- if (active && c.state.position) {
111
- const p = c.state.position;
112
- c.mesh.position.lerp(target.set(p.x, p.y + 0.8, p.z), 0.25); // interpolate toward the synced position
113
- c.mesh.rotation.y += dt * 2.6;
114
- }
115
- }
88
+ const entities = mp.entities({
89
+ build: (kind, id) => {
90
+ // EntityScene positions the GROUP at the entity's networked point — bake the visual float into a child.
91
+ const group = new THREE.Group();
92
+ const coin = makeCoin(); // makeCoin(): your spinning-disc THREE.Mesh
93
+ coin.position.y = 0.8;
94
+ group.add(coin); scene.add(group);
95
+ return {
96
+ object3d: group,
97
+ onUpdate: (e, dt) => { // per-frame: read the LIVE vars, drive the visual
98
+ coin.visible = (e.vars as Record<string, unknown>)?.active !== false; // hidden until the respawn timer fires
99
+ if (coin.visible) coin.rotation.y += dt * 2.6;
100
+ },
101
+ dispose: () => { scene.remove(group); coin.geometry.dispose(); },
102
+ };
103
+ },
104
+ });
105
+ // mp.update(dt) drives the interpolation + onUpdate — nothing to add in your frame loop.
116
106
  ```
117
107
 
118
- Read each player's `score` off `room.state.players[id].vars.score` for a HUD/leaderboard. **Footguns:** read the
119
- live `state` each frame (don't snapshot `.vars` once); guard `room.state.entities` until the first patch (empty on
120
- frame 0). This *server-only* proxy is for entities the **server** drives — for entities a **client** simulates
121
- (`authority:'owner'`), use `EntityScene` instead (see the `relic-bearers` / physics templates).
108
+ Read scores through the typed accessors — `mp.room.me.num('score')` for your HUD,
109
+ `mp.room.player(id).num('score')` for a leaderboard. Count live coins via `entities.entries()`.
110
+ **Footgun:** this render-only shape is for entities the **server** drives — for entities a **client** simulates
111
+ (`authority:'owner'`), add a `motion` fn (see the `relic-bearers` / physics templates); same `mp.entities()` call.
122
112
 
123
113
  ## 4. Build, validate, publish
124
114
 
125
115
  `npm install` → **`install_world_packages`** (MCP tool) → `npm run build` → `validate_world` on `dist/` (fix every problem; watch the
126
- per-tick budget + entity caps) → `whoami` → `publish_world`.
116
+ per-tick budget + entity caps) → **`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`.
@@ -31,7 +31,7 @@ world.
31
31
  "slug": "card-showdown",
32
32
  "entry": "index.html",
33
33
  "maxPlayers": 8,
34
- "permissions": ["auth.profile", "multiplayer"],
34
+ "permissions": ["auth.profile", "multiplayer", "voice.proximity"],
35
35
  "multiplayer": {
36
36
  "authoritative": true,
37
37
  "state": {
@@ -69,7 +69,7 @@ world.
69
69
  },
70
70
  "supportsMobile": true,
71
71
  "contentRating": "everyone",
72
- "systems": { "humanoid-character": "^0.2" }
72
+ "systems": { "humanoid-character": "^0.3" }
73
73
  }
74
74
  ```
75
75
 
@@ -81,36 +81,36 @@ per-tick budget — list scans are charged at the declared `maxLen`.
81
81
  ## 3. The client — `src/main.ts` (delta from `hangout`)
82
82
 
83
83
  A normal presence world; the new part is **reading the synced hand** and binding the actions. The hand is a
84
- Colyseus list of record objects — read it off `room.state.players[id].vars.hand` and render a HUD:
84
+ declared list of records — `room.me.list()` hands you a real Array (live elements), no casts, no index loops.
85
+ (The engine-repo template predates the typed accessors and hand-rolls these reads; write NEW worlds the
86
+ accessor way shown here.)
85
87
 
86
88
  ```ts
87
89
  type CardRec = { suit: string; rank: number; played: boolean };
88
- function handOf(p: PlayerState | undefined): CardRec[] {
89
- const list = (p?.vars as { hand?: { length: number; [i: number]: unknown } } | undefined)?.hand;
90
- const out: CardRec[] = [];
91
- for (let i = 0; i < (list?.length ?? 0); i++) {
92
- const r = list![i] as { suit?: string; rank?: number; played?: boolean };
93
- out.push({ suit: String(r?.suit ?? '?'), rank: Number(r?.rank ?? 0), played: Boolean(r?.played) });
94
- }
95
- return out;
96
- }
97
90
 
98
- // each frame, render your hand + the computed scalars:
99
- const me = room.state.players[room.sessionId];
100
- renderHandHud(handOf(me), numVar(me, 'handValue'), numVar(me, 'redCount'));
91
+ // World-owned input router (character-world §8c). A card game has no gunplay or crouching — the
92
+ // genre frees interact's faceLeft and crouch's faceRight for the two card verbs (gate-disable the
93
+ // always-on owner first: config `character.locomotion.allowCrouch: false`). Customs carry labels.
94
+ const input = new InputService();
95
+ input.attach(window as never);
96
+ input.attachPointer(renderer.domElement as never, undefined, { dragLook: true });
97
+ input.registerAction('world.mark', { kind: 'button', keys: ['KeyM'], pad: 'faceLeft', label: 'Mark card' }, 'world');
98
+ input.registerAction('world.discard', { kind: 'button', keys: ['KeyH'], pad: 'faceRight', label: 'Discard' }, 'world');
99
+ const mp = await CharacterMultiplayer.create({ /* …, */ input, character: { locomotion: { allowCrouch: false } } });
100
+ const room = mp.room!;
101
101
 
102
- // input → actions (the server mutates the list authoritatively):
103
- addEventListener('keydown', (e) => {
104
- if (e.key === 'm') room.sendAction('mark');
105
- else if (e.key === 'g') room.sendAction('discard');
106
- });
102
+ // each frame, AFTER mp.update(dt) — render the hand + send intents (the server mutates the list):
103
+ renderHandHud(room.me.list<CardRec>('hand'), room.me.num('handValue'), room.me.num('redCount'));
104
+ if (input.wasPressed('world.mark')) room.sendAction('mark');
105
+ if (input.wasPressed('world.discard')) room.sendAction('discard');
106
+ // hint the verbs via tokens (M/H on keyboard, X/B on pad), never hardcoded key names:
107
+ hintEl.innerHTML = input.format('{world.mark} mark red · {world.discard} discard played');
107
108
  ```
108
109
 
109
- **Footguns:** the hand is a live `ArraySchema` (read length + index per frame, don't cache the array); guard
110
- `vars` until the first patch; drawing/marking/discarding are all server writes via zones/actions — the client only
111
- reads + sends intents.
110
+ **Footguns:** call `list()` fresh each frame (it materializes the current list; the elements stay live);
111
+ drawing/marking/discarding are all server writes via zones/actions — the client only reads + sends intents.
112
112
 
113
113
  ## 4. Build, validate, publish
114
114
 
115
115
  `npm install` → **`install_world_packages`** (MCP tool) → `npm run build` → `validate_world` on `dist/` (watch `listMaxLen`/`recordFields`
116
- caps + the per-tick budget) → `whoami` → `publish_world`.
116
+ caps + the per-tick 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`.
@@ -1,13 +1,15 @@
1
1
  # Multiplayer template — `hangout`
2
2
 
3
3
  **What it is.** A 2–8 player social space: everyone sees everyone else move, animate, jump, and look around in
4
- one shared world. **Capability: presence** — the see-each-other-move substrate every other multiplayer template
5
- builds on. Lifted from the verified `multiplayer-hangout` world; this is the reference `src/main.ts` the other
6
- templates *delta from*.
4
+ one shared world, wearing their universal avatars, with floating nameplates. **Capability: presence** — the
5
+ see-each-other substrate every other multiplayer template builds on. Lifted from the verified
6
+ `multiplayer-hangout` world; the whole substrate is ONE `CharacterMultiplayer` call (the appendix below shows
7
+ what it composes — the eject path when a world outgrows the defaults).
7
8
 
8
9
  > Read the multiplayer **hub** first (`get_started({ kind: "multiplayer" })`) for the model + the manifest opt-in,
9
10
  > and the **character recipe** (`get_started({ kind: "character" })`) — a hangout is the character world plus the
10
- > layer below. This template is publish-shaped: a real `systems` pin + `helix install`.
11
+ > layer below. This template is publish-shaped: a real `systems` pin + `helix install`. **`scaffold_world` with
12
+ > `kind: "multiplayer"` writes exactly this world** — the sections below explain what it wrote.
11
13
 
12
14
  ## 1. DSL used
13
15
 
@@ -26,159 +28,170 @@ from a logic-bearing template — `collect-a-thon` is the simplest next step.
26
28
  "slug": "my-hangout",
27
29
  "entry": "index.html",
28
30
  "maxPlayers": 8,
29
- "permissions": ["auth.profile", "multiplayer"],
31
+ "permissions": ["auth.profile", "multiplayer", "voice.proximity"],
30
32
  "multiplayer": { "authoritative": true },
31
33
  "supportsMobile": true,
32
34
  "contentRating": "everyone",
33
- "systems": { "humanoid-character": "^0.2" }
35
+ "systems": { "humanoid-character": "^0.3" }
34
36
  }
35
37
  ```
36
38
 
39
+ `voice.proximity` opts the hangout into proximity voice chat (hear who's near you) — natural for a social
40
+ space; drop it for a silent world. Falloff tuning + channels live in an optional `multiplayer.voice` block —
41
+ see the `voice-radio` template.
42
+
37
43
  `maxPlayers > 1` **requires** the `multiplayer` permission; `multiplayer` **forces** login (coerced for you). The
38
44
  `humanoid-character` pin is resolved by `helix install` into `helix_modules/` (the `@helix/humanoid-character`
39
- vite alias from the character recipe). Keep `maxPlayers` modest — `validate_world` warns if you exceed the
40
- per-room cap.
45
+ vite alias from the character recipe) — the `CharacterMultiplayer` facade needs **≥ 0.2.4**. Keep `maxPlayers`
46
+ modest — `validate_world` warns if you exceed the per-room cap.
41
47
 
42
48
  ## 3. The client — `src/main.ts`
43
49
 
44
- Add `"@hypersoniclabs/helix-sdk": "{{SDK_DEP_SPEC}}"` to `package.json` dependencies for `Helix.multiplayer`; the replica primitives
45
- (`ReplicaScene`, `NetworkDriver`, `ReplicaBody`) come from the `humanoid-character` system you already pin.
50
+ Add `"@hypersoniclabs/helix-sdk": "{{SDK_DEP_SPEC}}"` to `package.json` dependencies for `Helix.multiplayer`.
46
51
  `index.html`, `vite.config.ts`, `tsconfig.json`, `src/loading.ts`, `src/helix.runtime.ts` are **identical to the
47
- character recipe** — only `src/main.ts` + `public/helix.json` differ. Build your local player exactly as in the
48
- character recipe, then layer multiplayer on:
49
-
50
- **a. Init FIRST, then load bodies — every player renders THEIR universal avatar.** The character body is
51
- bind-once, so the session (and with it the local player's equipped avatar) must resolve *before* assets load:
52
+ character recipe** (start with scaffold_world for the CLI command and recipe). Build your scene + physics as usual, then the whole
53
+ multiplayer substrate is one call:
52
54
 
53
55
  ```ts
54
56
  import { Helix } from '@hypersoniclabs/helix-sdk';
55
- import type { HelixRoom, PlayerState, ReplicaInput } from '@hypersoniclabs/helix-sdk';
56
- import { AvatarModelCache, createCharacterAssetIO, loadCharacterAssets,
57
- UNIVERSAL_AVATAR_SKELETON } from '@helix/humanoid-character';
57
+ import { CharacterMultiplayer, InputService, RapierBody } from '@helix/humanoid-character';
58
58
  import { SYSTEM_ASSET_BASE, TRANSCODER_PATH } from './helix.runtime';
59
59
 
60
+ // …three.js scene/camera/ground as in the character recipe. LIGHTING: the verified hangout world this
61
+ // template lifts from is RUNTIME-LIT and ships BAKED — no hand-placed lights; it pins the `visual` system,
62
+ // fetches public/helix.visuals.json, hands its canvas to createVisualRuntime (renderer = visuals.renderer),
63
+ // and bakes its light field at publish (a hangout is the bounded-static poster child). The scaffold still
64
+ // writes the classic hand-lit starter; converting is five steps — read_doc({ name: "lighting-world" })…
65
+
66
+ // Physics + collision geometry are YOUR code — the facade never creates floors.
67
+ const body = await RapierBody.create({ position: SPAWN });
68
+ body.addStaticCuboid({ x: 24, y: 0.5, z: 24 }, { x: 0, y: -0.5, z: 0 }); // flat floor, top at y=0
69
+
70
+ // ONE world-owned input router, shared with the character (character-world §8c). A pure hangout
71
+ // registers NO world actions on it — it's pre-wired so game verbs land on input.registerAction
72
+ // (never raw addEventListener) the moment the world grows any.
73
+ const input = new InputService();
74
+ input.attach(window as never);
75
+ input.attachPointer(renderer.domElement as never, undefined, { dragLook: true });
76
+
77
+ // init → login → avatars (yours + everyone's) → local Character → guarded join → LocalReconciler →
78
+ // replicas with nameplates → wire adapters + throttled send loop. Every option has a default; see the
79
+ // hub's options table for the opt-outs (nameplates/universalAvatars: false) and hooks (abilities,
80
+ // replica.decorate/build).
81
+ const mp = await CharacterMultiplayer.create({
82
+ helix: Helix, renderer, scene, camera, body, input,
83
+ assetBase: SYSTEM_ASSET_BASE, transcoderPath: TRANSCODER_PATH, spawn: SPAWN,
84
+ });
85
+
86
+ // Proximity voice (this world declares voice.proximity): fail-soft — false (voice off for this
87
+ // environment) and errors both leave the world fully playable. The facade drives per-player volume
88
+ // off synced positions + lights the nameplate mic glyph; mic behavior (push-to-talk N, pad modifier+RB / open /
89
+ // muted, device, mutes) is the PLAYER's via the platform tablet — render no mic UI.
90
+ if (mp.room) {
91
+ try {
92
+ if (await Helix.voice.join()) mp.attachVoice(Helix.voice);
93
+ } catch (err) {
94
+ console.info('voice unavailable:', err);
95
+ }
96
+ }
97
+
98
+ const clock = new THREE.Clock();
99
+ renderer.setAnimationLoop(() => {
100
+ const dt = Math.min(clock.getDelta(), 0.1);
101
+ mp.update(dt); // local character → remote snapshots → replica anim → upload
102
+ hud.textContent = mp.room
103
+ ? `Hangout — ${mp.count + 1} here (you + ${mp.count})`
104
+ : mp.embedded
105
+ ? 'Single-player (log in for multiplayer)'
106
+ : 'Standalone dev — open inside HELIX for multiplayer';
107
+ renderer.render(scene, camera);
108
+ });
109
+ ```
110
+
111
+ That's the whole presence surface. `mp.room` is your live `HelixRoom` (null = single-player) for `sendAction` /
112
+ `onMessage` / the typed accessors when you add game logic; `mp.local` is your `Character`.
113
+
114
+ ## 4. Build, validate, publish
115
+
116
+ `npm install` → **`install_world_packages`** (MCP tool) (resolves the `humanoid-character` pin) → `npm run build` → `validate_world` on
117
+ `dist/` (fix every problem; heed the `maxPlayers` clamp warning) → **`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
118
+ no `.glb`/`.ktx2` — character assets stream from the CDN.
119
+
120
+ ## Appendix — under the hood (the eject path)
121
+
122
+ What `CharacterMultiplayer.create` composes, expanded — **you don't write this**; it's here so you can reason
123
+ about the substrate, and it's the starting point if a world outgrows the facade (all these primitives stay
124
+ exported). The wire-level rules live here because only this code touches the wire.
125
+
126
+ **a. Init FIRST, then load bodies** — the session (and with it your equipped avatar) must resolve *before*
127
+ assets load; the character body is bind-once:
128
+
129
+ ```ts
60
130
  const { embedded, user } = await Helix.init();
61
131
  if (embedded && !user) {
62
132
  try { await Helix.auth.requestLogin(); } catch { /* declined → guest, default body, single-player */ }
63
133
  }
64
-
65
- // One shared IO = one KTX2 transcoder; one AvatarModelCache fetches each distinct avatar GLB once
66
- // (your own + every remote's — rejoins and repeat wearers are free).
134
+ // One shared IO = one KTX2 transcoder; one AvatarModelCache fetches each distinct avatar GLB once.
67
135
  const io = createCharacterAssetIO({ renderer, transcoderPath: TRANSCODER_PATH });
68
136
  const avatarCache = new AvatarModelCache({ io });
69
137
  const { model: baseModel, clips } = await loadCharacterAssets(SYSTEM_ASSET_BASE, { io }); // the DEFAULT body
70
-
71
- // Local player: my equipped avatar when it's converted for this rig, else a clone of the default body.
72
- // Worlds that opt OUT of universal avatars delete these three lines (see character.universalAvatar.enabled).
73
- const equipped = await Helix.avatar.getEquipped(); // null for guests / no avatar / any failure
74
- const ownAvatarUrl = equipped?.glbUrl && equipped.skeleton === UNIVERSAL_AVATAR_SKELETON ? equipped.glbUrl : null;
75
- const localModel = (ownAvatarUrl !== null ? await avatarCache.load(ownAvatarUrl) : null)?.model ?? cloneSkinned(baseModel);
76
- // …scene.add(localModel) and Character.create({ model: localModel, … }) exactly as the character recipe.
138
+ // Local body: my equipped avatar when it's converted for this rig, else a clone of the default body.
139
+ const equipped = await Helix.avatar.getEquipped();
140
+ const ownUrl = equipped?.glbUrl && equipped.skeleton === UNIVERSAL_AVATAR_SKELETON ? equipped.glbUrl : null;
141
+ const localModel = (ownUrl ? await avatarCache.load(ownUrl) : null)?.model ?? cloneSkinned(baseModel);
142
+ // …scene.add(localModel), Character.create({ model: localModel, camera, domElement, body }), register abilities.
77
143
  ```
78
144
 
79
- **b. Join — guarded, so guests / standalone fall back to single-player:**
145
+ **b. Join — guarded, so guests / standalone fall back to single-player; then wire the `LocalReconciler`**
146
+ (mandatory in EVERY multiplayer world — the room acks gate-rejected uploads with the held authoritative
147
+ position, and the reconciler snaps your local body to it; that's how a server `respawn`/`teleport` rule lands on
148
+ YOUR client-predicted body, and how a reconnect/F5 re-syncs):
80
149
 
81
150
  ```ts
82
151
  let room: HelixRoom | null = null;
83
152
  if (embedded) {
84
- try {
85
- room = await Helix.multiplayer.joinRoom(); // defaults to the current world; resolves + connects
86
- } catch (err) {
87
- console.info('multiplayer unavailable — running single-player:', err);
88
- room = null;
89
- }
153
+ try { room = await Helix.multiplayer.joinRoom(); }
154
+ catch (err) { console.info('multiplayer unavailable — running single-player:', err); }
90
155
  }
156
+ if (room) new LocalReconciler({ room, body });
91
157
  ```
92
158
 
93
- The `HelixRoom` handle (Colyseus, re-exposed under `Helix.*`): `room.state` (`players`) · `room.sessionId` (yours
94
- — skip it in `onAdd`) · `room.onAdd('players', …)` / `onRemove` (fires for present players too) ·
95
- `room.sendState(input)` (throttled + seq-tagged for you) · `room.sendAbility(id, active)` · `room.onStateChange` /
96
- `onMessage` / `leave` · `room.onDrop` / `onReconnect` / `onLeave` (auto-reconnect).
97
-
98
- > Player objects from `onAdd` are **live references** Colyseus mutates in place each patch — stash them and read
99
- > per frame. Don't iterate `room.state.players` as a plain object (it's a `MapSchema`, not a `Record`).
100
-
101
- **c. Render remotes — `ReplicaScene` + `NetworkDriver`** (each remote is a headless `Character` driven off the
102
- wire, wearing **their** avatar — the room replicates each player's backend-resolved `avatarUrl`, `''` = none):
159
+ **c. Remote replicas — `ReplicaScene` + `NetworkDriver`** (each remote is a headless `Character` driven off the
160
+ wire, wearing THEIR avatar, with a `Nameplate`):
103
161
 
104
162
  ```ts
105
- import { clone as cloneSkinned } from 'three/examples/jsm/utils/SkeletonUtils.js';
106
- import { Character, LocomotionAbility, NetworkDriver, ReplicaBody, ReplicaScene,
107
- type ReplicaHandle, type ReplicatedParams } from '@helix/humanoid-character';
108
-
109
163
  const remote = new Map<string, PlayerState>();
110
-
111
164
  async function buildReplica(id: string): Promise<ReplicaHandle> {
112
- // THEIR avatar via the shared cache; any failure (or '') falls back to the DEFAULT body — never yours.
113
- const avatarUrl = remote.get(id)?.avatarUrl;
165
+ const avatarUrl = remote.get(id)?.avatarUrl; // room-replicated, backend-resolved ('' = none)
114
166
  const model = (avatarUrl ? await avatarCache.load(avatarUrl) : null)?.model ?? cloneSkinned(baseModel);
115
167
  scene.add(model);
116
- const rbody = new ReplicaBody(SPAWN); // inert, no-physics body — pose comes from the wire
168
+ const rbody = new ReplicaBody(SPAWN); // inert, no-physics body — pose comes from the wire
117
169
  const character = await Character.create({ model, body: rbody });
118
- character.abilities.register(new LocomotionAbility(clips)); // same anim graph as the local player
119
- const driver = new NetworkDriver({ body: rbody, blackboard: character.blackboard });
120
- character.setDriver(driver); // the driver feeds the blackboard from snapshots
170
+ character.abilities.register(new LocomotionAbility(clips)); // same anim graph as the local player
171
+ let active = new Set<string>();
172
+ const driver = new NetworkDriver({
173
+ body: rbody, blackboard: character.blackboard,
174
+ onActiveAbilitiesChanged: (next) => { active = syncActiveAbilities(character.abilities, active, next); },
175
+ });
176
+ character.setDriver(driver);
177
+ const plate = new Nameplate(nameplateLabel(remote.get(id)?.displayName, id)).attachTo(character);
121
178
  return { pushSnapshot: (p) => driver.pushSnapshot(p), update: (dt) => character.update(dt),
122
- dispose: () => { character.dispose(); scene.remove(model); } };
179
+ dispose: () => { plate.dispose(); character.dispose(); scene.remove(model); } };
123
180
  }
124
-
125
- const replicas = new ReplicaScene({ build: buildReplica, maxReplicas: 8 }); // maxReplicas is a real perf budget
181
+ const replicas = new ReplicaScene({ build: buildReplica, maxReplicas: 8 }); // a real perf budget
126
182
  if (room) {
127
183
  room.onAdd('players', (player, id) => {
128
- if (id === room!.sessionId) return; // that's me — I render my own local player
129
- remote.set(id, player); replicas.add(id); // async build; the pool buffers snapshots until ready
184
+ if (id === room.sessionId) return; // that's me — I render my own local player
185
+ remote.set(id, player); replicas.add(id);
130
186
  });
131
187
  room.onRemove('players', (_p, id) => { remote.delete(id); replicas.remove(id); });
132
188
  }
133
189
  ```
134
190
 
135
- **d. The adapter + send loop.** The wire is all-degrees (`*Deg`); the body wants `facingYaw` in radians (the one
136
- conversion). **Copy `position`/`activeAbilities` BY VALUE** — `p` is a live schema object mutated in place, so
137
- aliasing it collapses the `NetworkDriver` jitter buffer and the remote snaps instead of interpolating.
138
-
139
- ```ts
140
- function toReplicatedParams(p: PlayerState): ReplicatedParams {
141
- return {
142
- position: { x: p.position.x, y: p.position.y, z: p.position.z }, // copy, don't alias
143
- facingYaw: (p.facingYawDeg * Math.PI) / 180, // the one unit conversion
144
- speed: p.speed, moveDirectionDeg: p.moveDirectionDeg, verticalVelocity: p.verticalVelocity,
145
- grounded: p.grounded, crouched: p.crouched, aimYawDeg: p.aimYawDeg, aimPitchDeg: p.aimPitchDeg,
146
- activeAbilities: [...p.activeAbilities],
147
- };
148
- }
149
-
150
- // Build your local wire state from the blackboard (the engine's live param bus) + body, in DEGREES.
151
- function localState(): ReplicaInput {
152
- const bb = local.blackboard;
153
- return {
154
- position: body.position,
155
- facingYawDeg: (body.facingYaw * 180) / Math.PI,
156
- speed: bb.get<number>('speed'), moveDirectionDeg: bb.get<number>('direction'),
157
- verticalVelocity: bb.get<number>('verticalVelocity'), grounded: bb.get<boolean>('isGrounded'),
158
- crouched: body.isCrouched, aimYawDeg: bb.get<number>('cameraYaw'), aimPitchDeg: bb.get<number>('cameraPitch'),
159
- };
160
- }
161
-
162
- const clock = new THREE.Clock();
163
- renderer.setAnimationLoop(() => {
164
- const dt = Math.min(clock.getDelta(), 0.1);
165
- local.update(dt); // local player always runs (single-player safe)
166
- if (room) {
167
- for (const [id, player] of remote) replicas.pushSnapshot(id, toReplicatedParams(player));
168
- replicas.update(dt);
169
- room.sendState(localState()); // coalesced + throttled to the room rate
170
- hud.textContent = `${replicas.count + 1} here (you + ${replicas.count})`;
171
- }
172
- renderer.render(scene, camera);
173
- });
174
- ```
175
-
176
- That's the whole presence surface: build local **unconditionally** → join when logged in → spawn replicas off
177
- `onAdd` → each frame, snapshot the remotes + `sendState` yourself. **Never send bone transforms** — you replicate
178
- the *inputs* to the animation system; each remote animates client-side.
179
-
180
- ## 4. Build, validate, publish
181
-
182
- `npm install` → **`install_world_packages`** (MCP tool) (resolves the `humanoid-character` pin) → `npm run build` → `validate_world` on
183
- `dist/` (fix every problem; heed the `maxPlayers` clamp warning) → `whoami` → `publish_world`. The build contains
184
- no `.glb`/`.ktx2` — character assets stream from the CDN.
191
+ **d. The adapters + send loop.** The wire is all-degrees (`*Deg`); the body wants `facingYaw` in radians (the one
192
+ conversion — `toReplicatedParams`/`buildLocalState` are exported if you eject). **Copy `position` /
193
+ `activeAbilities` BY VALUE** — a player object from `onAdd` is a live schema object mutated in place each patch;
194
+ aliasing it collapses the `NetworkDriver` jitter buffer and the remote snaps instead of interpolating. Each
195
+ frame: `local.update(dt)` → push `toReplicatedParams(player)` per remote → `replicas.update(dt)` →
196
+ `room.sendState(buildLocalState(local.blackboard, body))` (coalesced + throttled for you). **Never send bone
197
+ transforms** — you replicate the *inputs* to the animation system; each remote animates client-side.