@hypersoniclabs/helix-mcp 0.2.5 → 0.2.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/README.md +81 -11
  2. package/dist/continuumCanary.d.ts +17 -0
  3. package/dist/continuumCanary.js +17 -0
  4. package/dist/continuumCanary.js.map +1 -0
  5. package/dist/server.d.ts +14 -1
  6. package/dist/server.js +4058 -162
  7. package/dist/server.js.map +1 -1
  8. package/dist/tsconfig.build.tsbuildinfo +1 -1
  9. package/dist/vehicleTools.d.ts +86 -0
  10. package/dist/vehicleTools.js +229 -0
  11. package/dist/vehicleTools.js.map +1 -0
  12. package/docs/avatar-face.md +115 -0
  13. package/docs/bridge.md +98 -0
  14. package/docs/bring-your-world.md +117 -0
  15. package/docs/catalog.md +69 -1
  16. package/docs/character-animation.md +442 -0
  17. package/docs/character-attachments.md +166 -0
  18. package/docs/character-world.md +785 -130
  19. package/docs/continuum.md +153 -0
  20. package/docs/items.md +73 -0
  21. package/docs/lighting-world.md +667 -0
  22. package/docs/locomotion-clip-spec.md +294 -0
  23. package/docs/manifest.md +31 -6
  24. package/docs/multiplayer-logic.md +460 -17
  25. package/docs/multiplayer-templates/chrono-orchard.md +36 -22
  26. package/docs/multiplayer-templates/collect-a-thon.md +28 -38
  27. package/docs/multiplayer-templates/collections.md +24 -24
  28. package/docs/multiplayer-templates/hangout.md +124 -111
  29. package/docs/multiplayer-templates/npc-wave.md +310 -0
  30. package/docs/multiplayer-templates/obby.md +13 -16
  31. package/docs/multiplayer-templates/persistent-progress.md +218 -0
  32. package/docs/multiplayer-templates/physics-bumper.md +27 -9
  33. package/docs/multiplayer-templates/physics-football.md +22 -8
  34. package/docs/multiplayer-templates/relic-bearers.md +12 -15
  35. package/docs/multiplayer-templates/server-motion.md +16 -19
  36. package/docs/multiplayer-templates/shooter-range.md +275 -0
  37. package/docs/multiplayer-templates/team-control.md +28 -15
  38. package/docs/multiplayer-templates/turn-arena.md +31 -22
  39. package/docs/multiplayer-templates/voice-radio.md +166 -0
  40. package/docs/multiplayer-templates/wave-survival.md +7 -8
  41. package/docs/multiplayer-templates/world-shop.md +240 -0
  42. package/docs/multiplayer-world.md +222 -129
  43. package/docs/npc-world.md +623 -0
  44. package/docs/publishing.md +108 -28
  45. package/docs/purchases.md +223 -0
  46. package/docs/scene-performance.md +64 -0
  47. package/docs/screenshots.md +140 -0
  48. package/docs/sdk.md +324 -5
  49. package/docs/shooter-worlds.md +537 -0
  50. package/docs/terrain.md +173 -0
  51. package/docs/upgrades.md +324 -0
  52. package/docs/vehicles.md +727 -0
  53. package/docs/world-inspect.md +156 -0
  54. package/docs/world-look.md +241 -0
  55. package/docs/world-recipe.md +65 -6
  56. package/package.json +15 -4
  57. package/skills/README.md +91 -0
  58. package/skills/helix-assets/SKILL.md +491 -0
  59. package/skills/helix-assets/references/asset-sources.md +143 -0
  60. package/skills/helix-assets/references/vault-api.md +105 -0
  61. package/skills/helix-avatar-qa/SKILL.md +85 -0
  62. package/skills/helix-avatars/SKILL.md +206 -0
  63. package/skills/helix-avatars/references/contract.md +166 -0
  64. package/skills/helix-avatars/references/dynamics.md +367 -0
  65. package/skills/helix-avatars/references/face.md +50 -0
  66. package/skills/helix-avatars/references/publish.md +76 -0
  67. package/skills/helix-avatars/references/qa.md +251 -0
  68. package/skills/helix-avatars/references/rigging.md +88 -0
  69. package/skills/helix-avatars/references/source-generated.md +190 -0
  70. package/skills/helix-avatars/references/source-model.md +90 -0
  71. package/skills/helix-avatars/references/source-rigid.md +90 -0
  72. package/skills/helix-avatars/references/source-vrm.md +61 -0
  73. package/skills/helix-gauntlet/SKILL.md +128 -0
  74. package/skills/helix-multiplayer/SKILL.md +150 -0
  75. package/skills/helix-multiplayer/references/dsl-capability-map.md +107 -0
  76. package/skills/helix-vehicles/SKILL.md +218 -0
  77. package/skills/helix-vehicles/references/addons.md +212 -0
  78. package/skills/helix-vehicles/references/appearance.md +339 -0
  79. package/skills/helix-vehicles/references/audio-import.md +138 -0
  80. package/skills/helix-vehicles/references/audio.md +580 -0
  81. package/skills/helix-vehicles/references/cabin.md +225 -0
  82. package/skills/helix-vehicles/references/host-manifest.md +174 -0
  83. package/skills/helix-vehicles/references/physics-from-specs.md +463 -0
  84. package/skills/helix-vehicles/references/publish.md +214 -0
  85. package/skills/helix-vehicles/references/qa.md +177 -0
  86. package/skills/helix-vehicles/references/reference-package.json +3481 -0
  87. package/skills/helix-vehicles/references/reference-package.md +69 -0
  88. package/skills/helix-vehicles/references/source-beamng.md +167 -0
  89. package/skills/helix-vehicles/references/source-concept.md +38 -0
  90. package/skills/helix-vehicles/references/source-model.md +100 -0
  91. package/skills/helix-vehicles/references/source-scratch.md +60 -0
  92. package/skills/helix-world-build/SKILL.md +376 -0
  93. package/skills/helix-world-build/references/config-gates.md +104 -0
  94. package/skills/helix-world-director/SKILL.md +210 -0
  95. package/skills/helix-world-qa/SKILL.md +371 -0
  96. package/skills/helix-world-qa/references/perf-budgets.md +240 -0
  97. package/skills/helix-world-qa/references/perf-handle.md +125 -0
  98. package/skills/helix-world-qa/references/visual-scorecard.md +85 -0
@@ -6,10 +6,10 @@ Every multiplayer world builds on the **`humanoid-character`** system (read `get
6
6
  first — same project layout, build config, loading screen, character config) plus **`@hypersoniclabs/helix-sdk`**
7
7
  for `Helix.multiplayer`.
8
8
 
9
- > **Discover versions first.** The replica primitives (`ReplicaScene`, `NetworkDriver`, `ReplicaBody`) ship inside
10
- > the **`humanoid-character`** system; `Helix.multiplayer` is in **`@hypersoniclabs/helix-sdk`**. Run
9
+ > **Discover versions first.** The `CharacterMultiplayer` facade below ships in **`humanoid-character` ≥ 0.2.4**;
10
+ > `Helix.multiplayer` + the typed state accessors are in **`@hypersoniclabs/helix-sdk`**. Run
11
11
  > `get_package_manifest("humanoid-character")` + `check_for_updates` before you build — if a pinned version
12
- > predates multiplayer, those imports won't exist; don't guess around it.
12
+ > predates the facade, those imports won't exist; don't guess around it.
13
13
 
14
14
  ## The model — five facts
15
15
 
@@ -19,12 +19,13 @@ for `Helix.multiplayer`.
19
19
  2. **Multiplayer is declarative — game logic is DATA.** You declare state + a `when`/`if`/`then` rule pipeline in
20
20
  your `helix.json` `multiplayer` block; the room interprets it at a fixed **20 Hz**. The full grammar is its own
21
21
  reference: **`read_doc({ name: "multiplayer-logic" })`.**
22
- 3. **Two layers.** (a) **Presence** — players see each other move/animate (the on-ramp below; this alone is a full
23
- hangout). (b) **Declarative game logic** — shared scores/teams/state, server- and client-hosted entities, zones,
24
- timers, a state machine, collections, ownership. Most worlds are presence **plus** some logic.
25
- 4. **Login required; single-player must still work.** Set up your local player + scene **unconditionally**, then
26
- *layer* multiplayer on only when a room join succeeds (**the golden rule** — the standalone/guest path never
27
- breaks).
22
+ 3. **Two layers.** (a) **Presence** — players see each other move/animate, wearing their avatars, with floating
23
+ nameplates (ONE facade call; this alone is a full hangout). (b) **Declarative game logic** — shared
24
+ scores/teams/state, server- and client-hosted entities, zones, timers, a state machine, collections, ownership.
25
+ Most worlds are presence **plus** some logic.
26
+ 4. **Login required; single-player must still work.** The facade owns this: it sets your local player up
27
+ unconditionally and layers multiplayer on only when a room join succeeds (**the golden rule** — the
28
+ standalone/guest path never breaks).
28
29
  5. **`maxPlayers` is a platform-enforced cap.** The room locks at `maxPlayers` and spills extras into a fresh
29
30
  instance; the platform also clamps it to its per-room ceiling. Keep it modest (2–8 is the sweet spot;
30
31
  `validate_world` warns if you exceed the cap). Note: the `uploadHz: 20` fast tier caps `maxPlayers` at **12**.
@@ -32,8 +33,11 @@ for `Helix.multiplayer`.
32
33
  ## Opt in — the manifest (`public/helix.json`, v0.3)
33
34
 
34
35
  Bump `helixVersion` to `"0.3"`, set `maxPlayers > 1`, and add the **`multiplayer`** permission (keep `auth.profile`
35
- — you need the player identity). Cross-field rules are enforced at validate/publish: `maxPlayers > 1` **requires**
36
- `multiplayer`, and `multiplayer` **forces** `requiresAuth: true` (coerced for you).
36
+ — you need the player identity). **Also keep `voice.proximity` — voice chat is the DEFAULT for HELIX multiplayer
37
+ worlds** (players expect to talk; remove it only for a deliberately silent world). Cross-field rules are enforced at
38
+ validate/publish: `maxPlayers > 1` **requires** `multiplayer`, and `multiplayer` **forces** `requiresAuth: true`
39
+ (coerced for you). **`scaffold_world` returns the CLI scaffold command and this recipe**; the manifest
40
+ below is what it produces for `kind: "multiplayer"`:
37
41
 
38
42
  ```json
39
43
  {
@@ -42,11 +46,11 @@ Bump `helixVersion` to `"0.3"`, set `maxPlayers > 1`, and add the **`multiplayer
42
46
  "slug": "my-hangout",
43
47
  "entry": "index.html",
44
48
  "maxPlayers": 8,
45
- "permissions": ["auth.profile", "multiplayer"],
49
+ "permissions": ["auth.profile", "multiplayer", "voice.proximity"],
46
50
  "multiplayer": { "authoritative": true },
47
51
  "supportsMobile": true,
48
52
  "contentRating": "everyone",
49
- "systems": { "humanoid-character": "^0.2" }
53
+ "systems": { "humanoid-character": "^0.3" }
50
54
  }
51
55
  ```
52
56
 
@@ -62,145 +66,177 @@ at 12). There is **no `tickRate` knob** — the platform owns the room tick.
62
66
 
63
67
  ## Pick your starting point — the template index
64
68
 
65
- Every multiplayer world is **presence + (optionally) declarative game logic.** Find the closest template, read it
66
- with **`read_template({ name })`**, and adapt it — each template is a complete, publish-shaped world that links
67
- into `multiplayer-logic` per construct. Templates 2→7 walk the full entity-**authority** spectrum (static →
68
- server-moving → client-owned → shared → physics), which is the most footgun-prone area — skim them in order.
69
+ Every multiplayer world is **presence + (optionally) declarative game logic.** Start with **`scaffold_world`**
70
+ (returns the CLI scaffold command + recipe), find the closest template, read it with **`read_template({ name })`**,
71
+ and adapt it — each template is a complete, publish-shaped world that links into `multiplayer-logic` per
72
+ construct. Templates 2→7 walk the full entity-**authority** spectrum (static → server-moving → client-owned →
73
+ shared → physics), which is the most footgun-prone area — skim them in order.
69
74
 
70
75
  Each template's §3 client code is a **delta** from the presence on-ramp below; unshown helpers (`makeMesh()`,
71
- `showBanner()`, `numRoomVar()`, your `body`/`scene`) are **your own code**, not SDK APIs.
76
+ `showBanner()`, your `body`/`scene`) are **your own code**, not SDK APIs.
72
77
 
73
78
  | Building… | Template | What it teaches |
74
79
  |---|---|---|
75
- | a hangout / social space / co-op room — players just see each other | `hangout` | **presence**: join flow, `ReplicaScene`/`NetworkDriver` replicas, `sendState`. The substrate every other template builds on (it's the on-ramp below). |
76
- | a collectible / scavenger world — grab items for points | `collect-a-thon` | declared room/player state + server-spawned **static** pickups (`spawnEntity`) + zones + scoring. |
80
+ | a hangout / social space / co-op room — players just see each other | `hangout` | **presence**: the one facade call + what it composes ("under the hood" appendix = the eject path). The substrate every other template builds on. |
81
+ | a collectible / scavenger world — grab items for points | `collect-a-thon` | declared room/player state + server-spawned **static** pickups (`spawnEntity`) + zones + scoring, rendered via `mp.entities()`. |
77
82
  | roaming NPCs, moving platforms, patrolling guards, homing pickups | `server-motion` | **server-authoritative** entity motion (`seek`/`waypoints`) — deterministic, cheat-proof, never freezes. The **preferred** entity default (a roaming bot vacuum that hoovers up coins). |
78
83
  | a pet/familiar that follows you, or a single-owner carryable | `relic-bearers` | **client-hosted** single-owner entities (`authority:'owner'`) + ownership reads + owner-leave migration. The **escape hatch** — only when the server genuinely can't compute the motion. |
79
84
  | co-op survival / horde / Vampire-Survivors-style enemy swarms | `wave-survival` | **shared** game-owned entities **distributed** across clients (each enemy hosted by the least-loaded client, re-elected if its host leaves) — the swarm's sim spreads across the room — plus timed waves. |
80
- | a ball/puck sport — soccer, hockey, pool | `physics-football` | networked physics, **authority-transfer**: one shared dynamic body whose control hands off to whoever touches it (`claimOnContact`) and reverts to the server at rest. |
81
- | bumper cars / derby / sumo — players **are** physics bodies | `physics-bumper` | networked physics, **dual-sim**: each player owns their own body and collisions reconcile favor-local, on the `uploadHz: 20` fast tier (so `maxPlayers ≤ 12`). |
82
- | a card game / hand / inventory / crafting | `collections` | structured **collections** — lists of records (a hand of cards), counterMaps, `forEachInList`, `append`/`removeWhere`. |
83
- | a turn-based / board game / co-op boss fight | `turn-arena` | a **state machine** (play → win/lose) + **turn order** (`advanceTurn`) + **elimination**. |
85
+ | enemies, monsters, guards or townsfolk that are PEOPLE — a horde of humanoids, plus a shopkeeper you can talk to | `npc-wave` | **NPCs in a shared world**: the same shared/host-migrated enemies as `wave-survival`, rendered as full humanoid characters via `humanoidEntities` (the brain stays in the room — the humanoid is only the rendering), plus a client-local stand-body NPC with a proximity `Talk` prompt. The solo NPC surface (behaviours, nav, Vault bodies) is `read_doc({ name: "npc-world" })`. |
86
+ | a ball/puck sport — soccer, hockey, air-hockey | `physics-football` | networked physics, **authority-transfer**: ONE shared dynamic body whose control hands off to whoever touches it (`claimOnContact`) and reverts to the server at rest. A MULTI-body set (a pool rack) needs a `group` + `claimGroup` action instead — multiplayer-logic §9a. |
87
+ | bumper cars / derby / sumo — players **are** physics bodies | `physics-bumper` | networked physics, **dual-sim**: each player owns their own body and collisions reconcile favor-local, on the `uploadHz: 20` fast tier (so `maxPlayers ≤ 12`). **The facade-free template** — see its "when NOT to use the facade" note. |
88
+ | a card game / hand / inventory / crafting | `collections` | structured **collections** — lists of records (a hand of cards), counterMaps, `forEachInList`, `append`/`removeWhere`, read via `room.me.list()`. |
89
+ | a turn-based / board game / co-op boss fight | `turn-arena` | a **state machine** (play → win/lose) + **turn order** (`room.isMyTurn()`) + **elimination**. |
84
90
  | farming / growth / idle / day-cycle — things change over real time | `chrono-orchard` | the **time axis**: timers + the `{op:now}` wall-clock + dynamic durations; entities (crops) that ripen over time. |
85
91
  | an obstacle course / parkour / race | `obby` | ordered zone **checkpoints** + **respawn**-to-checkpoint on a fall + a per-player saved checkpoint + a finish line. |
86
92
  | king-of-the-hill / domination / territory / capture points | `team-control` | **teams** (a player var) + zone-**presence** scoring (red vs blue on the hill) + a tug-of-war capture/scoring loop — **no combat**. |
87
-
88
- **Combat is not yet a template** (shooting, melee, tag, capture-the-flag, tower defense) — it depends on
89
- character abilities still in progress. Until then, players affect each other through **declared `actions`**
90
- (server-validated, the cheat-resistant path) — see `multiplayer-logic` §14 + the cross-player firewall in §17.
93
+ | a shooter — deathmatch, PvE range, horde-with-guns, anything players SHOOT | `shooter-range` | **the combat loop**: guns via the `gun-control` ability (its own recipe: `read_doc({ name: "shooter-worlds" })`), `playerVars.health` + one-claim-per-trigger actions with cooldown/distance gates, gun-declared damage (`weaponItems` + the `weaponDamage` op — server-resolved, never client-named), kill credit + the `down` feed + respawn, destructible slot-respawning targets, and the death ragdoll. |
94
+ | walkie-talkies / team radio / phone calls / voice-chat tuning | `voice-radio` | **voice channels**: the manifest `multiplayer.voice` block (ambient proximity falloff + declared frequencies) + `Helix.voice.setChannel` — exclusive channels, dynamic private-call ids, flat vs proximity render per channel. |
95
+ | anything the world SELLS — a shop, currency packs, a VIP/season pass, cosmetics, unlocks, tips | `world-shop` | **in-world purchases**: the eight product shapes and which to pick (currency, timed/permanent pass, item, bundle, tip jar, free claim), registering products BEFORE the code that buys them, `Helix.purchases` entitlements + `consume`, and the room-side `{on:"purchase"}` rule with `hasPass`/`balanceOf`. Works single-player too. |
96
+ | anything that must be REMEMBERED — progression, currency, a guestbook, a world record, community totals, rankings | `persistent-progress` | **durable storage**, the whole taxonomy and which shape to pick: server-owned player saves (`persistent` playerVars + `save`), the player's own client-written blob (`Helix.dataStore`), world state that outlives the room (`persistent` roomVars + `saveRoom` + merge policies), `counters` and `leaderboards`. |
97
+
98
+ **Persistence also composes onto a template you already picked** — `persistent-progress` is the full taxonomy,
99
+ but the grammar is small enough to bolt onto any world above: `read_doc({ name: "multiplayer-logic" })` §18 covers
100
+ `persistent: true` playerVars + the `save` verb, `counters`/`increment`, and `leaderboards`/`submitScore`, each
101
+ with a worked config. The same is true of `world-shop` — selling is a layer, not a genre.
102
+
103
+ **Achievements ride on that same persistence** — §19 covers the `awardAchievement` rule effect (the unforgeable
104
+ `room` badge), server-evaluated `criteria` badges (which work single-player too), the register-the-key-first gotcha,
105
+ and `Helix.achievements` for reading them back.
106
+
107
+ **Shooting is a template now** — `shooter-range` above, with the client half (gun-control, weapon profiles,
108
+ FX/hit systems, ragdoll) in `read_doc({ name: "shooter-worlds" })`. Other combat shapes (melee, tag,
109
+ capture-the-flag, tower defense) still compose from **declared `actions`** (server-validated, the
110
+ cheat-resistant path — the same claim pattern the shooter uses) — see `multiplayer-logic` §14 + the
111
+ cross-player firewall in §17.
91
112
 
92
113
  **Nothing fits exactly?** Most real worlds are a blend (e.g. a collect-a-thon with teams, or a turn game with
93
114
  physics). Templates are starting points, not a menu — read `read_doc({ name: "multiplayer-logic" })` and compose
94
115
  from the primitives.
95
116
 
96
- ## The presence on-ramp — every multiplayer world starts here
97
-
98
- Presence is the universal substrate (and the whole of the `hangout` template). The pattern: build your local
99
- player **unconditionally**, then join + render remotes only `if (room)`.
117
+ ## The presence on-ramp — one call
100
118
 
101
- **1 — Init first, then join (guarded so guests/standalone fall back to single-player).** Call `Helix.init()`
102
- (and settle login) BEFORE loading character assets: the body is bind-once, and every player renders their
103
- equipped **universal avatar** — resolved at load time (see the `hangout` template §3a for the full body-selection
104
- code; the room replicates each player's `avatarUrl`, `''` = default body):
119
+ Presence is the universal substrate, and it is **one facade call** — `CharacterMultiplayer` (from
120
+ `@helix/humanoid-character` ≥ 0.2.4) owns the whole flow: `Helix.init()` → login prompt → your equipped
121
+ **universal avatar** (local + every remote, `''` = default body) → the local `Character` → the guarded room join
122
+ (guests/standalone fall back to single-player) → the **`LocalReconciler`** (server respawns/teleports land on
123
+ your body) → remote replicas with floating **nameplates** → the wire adapters + throttled send loop:
105
124
 
106
125
  ```ts
107
126
  import { Helix } from '@hypersoniclabs/helix-sdk';
108
- import type { HelixRoom, PlayerState, ReplicaInput } from '@hypersoniclabs/helix-sdk';
127
+ import { CharacterMultiplayer, RapierBody } from '@helix/humanoid-character';
128
+ import { SYSTEM_ASSET_BASE, TRANSCODER_PATH } from './helix.runtime';
109
129
 
110
- const { embedded, user } = await Helix.init();
111
- if (embedded && !user) {
112
- try { await Helix.auth.requestLogin(); } catch { /* declined → guest, default body, single-player */ }
113
- }
114
- // … load assets + build the local player (character recipe / hangout §3a: their universal avatar) …
130
+ // Physics + collision geometry are YOUR code — the facade never creates floors.
131
+ const body = await RapierBody.create({ position: SPAWN });
132
+ body.addStaticCuboid({ x: 24, y: 0.5, z: 24 }, { x: 0, y: -0.5, z: 0 });
115
133
 
116
- let room: HelixRoom | null = null;
117
- if (embedded) {
118
- try {
119
- room = await Helix.multiplayer.joinRoom(); // defaults to the current world; resolves + connects
120
- } catch (err) {
121
- console.info('multiplayer unavailable — running single-player:', err);
122
- room = null;
123
- }
124
- }
134
+ const mp = await CharacterMultiplayer.create({
135
+ helix: Helix, renderer, scene, camera, body,
136
+ assetBase: SYSTEM_ASSET_BASE, transcoderPath: TRANSCODER_PATH, spawn: SPAWN,
137
+ });
138
+
139
+ renderer.setAnimationLoop(() => {
140
+ const dt = Math.min(clock.getDelta(), 0.1);
141
+ mp.update(dt); // local character → remote snapshots → replica anim → upload
142
+ // …your world's per-frame code…
143
+ renderer.render(scene, camera);
144
+ });
125
145
  ```
126
146
 
127
- The `HelixRoom` handle (Colyseus, re-exposed under `Helix.*` — you never import the Colyseus client):
128
- `room.state` (authoritative shared state: `players`, plus `entities` once you declare them) · `room.sessionId`
129
- (**your** id — skip it in `onAdd`) · `room.onAdd('players', …)` / `onRemove` (fires for present players too) ·
130
- `room.sendState(input)` (your per-frame state, throttled + seq-tagged for you) · `room.sendAbility(id, active)` ·
131
- `room.sendAction(name, args)` (declared actions — see `multiplayer-logic`) · `room.onStateChange` /
132
- `onMessage(type, cb)` (broadcasts) / `leave` · `room.onDrop` / `onReconnect` / `onLeave` (SDK auto-reconnects).
147
+ `mp.room` is your live `HelixRoom` handle (null = single-player: not embedded, guest, or join failed) —
148
+ `sendAction`, `onMessage`, the typed accessors, everything. `mp.count` = rendered remotes. `mp.local` = your
149
+ `Character`. `mp.entities(cfg)` renders synced server entities (see the entity templates).
133
150
 
134
- > Player objects from `onAdd` are **live references** Colyseus mutates in place each patch — stash them and read
135
- > per frame. Don't iterate `room.state.players` as a plain object (it's a `MapSchema`, not a `Record`).
151
+ **Every option has a default — pass only what you change:**
136
152
 
137
- **2 — Render remotes with `ReplicaScene` + `NetworkDriver`** (each remote is a headless `Character` driven off
138
- the wire, wearing **their** avatar via the shared `AvatarModelCache`; the fallback clone source is the DEFAULT
139
- body — see hangout §3a for `io`/`avatarCache`/`baseModel`):
153
+ | Option | Default | Why you'd touch it |
154
+ |---|---|---|
155
+ | `nameplates` | `true` | `false` to hide names (PvP anonymity); or `{ color, width, occludable }` to restyle |
156
+ | `universalAvatars` | `true` | `false` = platform default body only (bespoke player models) |
157
+ | `reconciler` | `true` | leave it — disabling desyncs server respawns/teleports |
158
+ | `join` | `true` | `false` for a deliberately single-player character world |
159
+ | `maxReplicas` | `8` | a real perf budget (each replica is a full character) — keep = `maxPlayers` |
160
+ | `abilities` | locomotion | `(clips) => [new LocomotionAbility(clips), new FlyAbility(...)]` — applied to local AND replicas so anim graphs match |
161
+ | `replica.decorate` | — | additive per-replica extras (health bar, team tint) WITHOUT ejecting: return `{ dispose }` |
162
+ | `replica.build` | — | full replica override; the facade keeps join/loop/adapters |
163
+ | `character` | — | passed through to `Character.create` (camera/body/input config) |
164
+ | `input` | — | a world-owned `InputService` forwarded to the inner `Character.create` — the world owns the DOM attach + the menu context stack; register world keys on it instead of `addEventListener` (character recipe §8c). Omitted ⇒ the character self-creates a private router |
165
+ | `debug` | `false` | narrate body/avatar/replica decisions to the console |
166
+
167
+ **If the facade fights you, drop to the primitives** — `ReplicaScene`, `NetworkDriver`, `Nameplate`,
168
+ `LocalReconciler`, `EntityScene` are all exported and composable; the `hangout` template's **"under the hood"
169
+ appendix** is the expanded manual equivalent (it's also where the wire-level rules live: copy live schema values
170
+ by value, skip your own sessionId, degrees on the wire). `physics-bumper` is the canonical facade-free world
171
+ (the player IS a physics body — no humanoid replicas at all).
172
+
173
+ ## Voice chat — ON by default, four lines
174
+
175
+ Players talk to each other — **every HELIX multiplayer world ships voice unless it has a reason not to.**
176
+ `scaffold_world` already declares `voice.proximity` (distance-attenuated, the natural default; `voice.room` =
177
+ flat room-wide) and writes the join block below; a deliberately silent world (a focused puzzle, a music
178
+ experience) OPTS OUT by removing both. The join runs after the room join succeeds:
140
179
 
141
180
  ```ts
142
- import { clone as cloneSkinned } from 'three/examples/jsm/utils/SkeletonUtils.js';
143
- import { Character, LocomotionAbility, NetworkDriver, ReplicaBody, ReplicaScene,
144
- type ReplicaHandle, type ReplicatedParams } from '@helix/humanoid-character';
145
-
146
- const remote = new Map<string, PlayerState>();
147
-
148
- async function buildReplica(id: string): Promise<ReplicaHandle> {
149
- const avatarUrl = remote.get(id)?.avatarUrl; // room-replicated, backend-resolved ('' = none)
150
- const model = (avatarUrl ? await avatarCache.load(avatarUrl) : null)?.model ?? cloneSkinned(baseModel);
151
- scene.add(model);
152
- const rbody = new ReplicaBody(SPAWN); // inert, no-physics body — pose comes from the wire
153
- const character = await Character.create({ model, body: rbody });
154
- character.abilities.register(new LocomotionAbility(clips)); // same anim graph as the local player
155
- const driver = new NetworkDriver({ body: rbody, blackboard: character.blackboard });
156
- character.setDriver(driver);
157
- return { pushSnapshot: (p) => driver.pushSnapshot(p), update: (dt) => character.update(dt),
158
- dispose: () => { character.dispose(); scene.remove(model); } };
159
- }
160
-
161
- const replicas = new ReplicaScene({ build: buildReplica, maxReplicas: 8 }); // maxReplicas is a real perf budget
162
- if (room) {
163
- room.onAdd('players', (player, id) => {
164
- if (id === room!.sessionId) return; // that's me — I render my own local player
165
- remote.set(id, player); replicas.add(id);
166
- });
167
- room.onRemove('players', (_p, id) => { remote.delete(id); replicas.remove(id); });
181
+ if (mp.room) {
182
+ try {
183
+ if (await Helix.voice.join()) mp.attachVoice(Helix.voice, manifest.multiplayer.voice);
184
+ } catch (err) { console.info('voice unavailable:', err); } // fail-soft — never blocks play
168
185
  }
169
186
  ```
170
187
 
171
- **3 — The adapter + send loop.** The wire is all-degrees (`*Deg`); the body wants `facingYaw` in radians (the one
172
- conversion). **Copy `position`/`activeAbilities` BY VALUE** — `p` is a live schema object mutated in place, so
173
- passing it by reference collapses the jitter buffer and the remote snaps instead of interpolating.
188
+ That's the whole integration: the facade drives per-player volume off synced positions and lights the
189
+ nameplate mic glyph while someone talks. Mic behavior (push-to-talk **N**, pad: hold modifier+RB / open / muted, device, volumes,
190
+ per-player mutes) belongs to the PLAYER via the platform tablet — a world never renders mic UI.
191
+
192
+ **Tuning + channels live in the manifest** (`multiplayer.voice`, all optional — see `read_doc({ name:
193
+ "manifest" })`): ambient `mode` + `refDistance`/`maxDistance` falloff, `"spatial": true` for directional
194
+ (HRTF) ambient voice — hear WHERE a speaker stands, loudness untouched — and declared `channels` with a
195
+ render policy each.
196
+
197
+ **When to declare `"spatial": true`** (opt-in, and cheap — panning is client-side, zero bandwidth/server
198
+ cost, degrades silently on clients without the audio hook; headphones make it shine): reach for it whenever
199
+ *presence* is the product — realistic social spaces (cafes, bars, campfires, hangout worlds — the casual
200
+ hang-with-friends genre), **horror** (a voice behind you IS the scare), hide-and-seek / stealth / tactical
201
+ play where *locating* a speaker is gameplay, and any world already using positional sound design (it makes
202
+ player voice match the world's audio). Skip it when voice is announcer/broadcast-shaped (a host addressing
203
+ the room), when competitive callout clarity matters more than immersion, or when play happens mostly inside
204
+ channels (radio semantics — channels never spatialize regardless). It composes with either ambient mode:
205
+ `proximity` + spatial = full positional presence; `global` + spatial = everyone stays audible but still
206
+ directional. **Channels are exclusive**: `Helix.voice.setChannel('freq-1')` moves the player out of ambient
207
+ into that channel (walkie-talkie semantics); `setChannel(null)` returns; dynamic undeclared ids work and
208
+ render flat — a private phone call is just `` setChannel(`call:${[a, b].sort().join(':')}`) `` from both
209
+ sides. The worked world: `read_template({ name: "voice-radio" })`. Server-driven membership (teams → voice)
210
+ is a rules recipe: a rule sets a `voiceChannel` playerVar, the client reads `room.me.str('voiceChannel')` and
211
+ calls `setChannel` — no server code, no extra machinery.
212
+
213
+ ## Gestures — replicated by default
214
+
215
+ World-authored JSON gestures (the pose DSL — authoring reference and worked examples in the
216
+ **character-animation doc**; the axis table is deliberately in no doc, read it live with
217
+ `gesture_axis_table`) replicate without any opt-in: `mp.playAnimation('wave')` plays locally the same
218
+ frame and every other client's replica plays it too. Clips never cross the wire — every client registered the
219
+ same JSON from the same bundle, so only NAMES replicate. A **oneshot** relays as a transient event (a late
220
+ joiner correctly misses a wave already in flight); a **loop** enters synced state (`loopGesture`), so late
221
+ joiners start it at replica spawn; `mp.stopAnimation()` clears it. `replicate: false` keeps a play local
222
+ (first-person flourishes, previews). One rule: trigger gestures from input/interaction handlers — never from
223
+ replicated-state observers, or every client fires its own copy.
224
+
225
+ ## Reading game state — the typed accessors
226
+
227
+ Declared state (room vars, player vars, phase, turns) reads through **guarded, live views** on the room handle —
228
+ no casts, no "did the first patch land yet" checks, safe defaults when a var is missing:
174
229
 
175
230
  ```ts
176
- function toReplicatedParams(p: PlayerState): ReplicatedParams {
177
- return {
178
- position: { x: p.position.x, y: p.position.y, z: p.position.z }, // copy, don't alias
179
- facingYaw: (p.facingYawDeg * Math.PI) / 180, // the one unit conversion
180
- speed: p.speed, moveDirectionDeg: p.moveDirectionDeg, verticalVelocity: p.verticalVelocity,
181
- grounded: p.grounded, crouched: p.crouched, aimYawDeg: p.aimYawDeg, aimPitchDeg: p.aimPitchDeg,
182
- activeAbilities: [...p.activeAbilities],
183
- };
184
- }
185
-
186
- const clock = new THREE.Clock();
187
- renderer.setAnimationLoop(() => {
188
- const dt = Math.min(clock.getDelta(), 0.1);
189
- local.update(dt); // local player always runs (single-player safe)
190
- if (room) {
191
- for (const [id, player] of remote) replicas.pushSnapshot(id, toReplicatedParams(player));
192
- replicas.update(dt);
193
- room.sendState(localState()); // build from the blackboard in DEGREES; throttled for you
194
- hud.textContent = `${replicas.count + 1} here (you + ${replicas.count})`;
195
- }
196
- renderer.render(scene, camera);
197
- });
231
+ mp.room.vars.num('control') // room var (default 0) · .str / .bool / .vec3 / .list / .raw
232
+ mp.room.me.num('score') // MY player var
233
+ mp.room.player(id).str('team') // any player's var by session id
234
+ mp.room.phase() // the state machine's phase ('' when the world declares no states)
235
+ mp.room.isMyTurn() // the turnOrder/turnIndex convention (always false when unused)
198
236
  ```
199
237
 
200
- `localState()` reads your live params off the blackboard (`speed`, `direction`, `cameraYaw`/`cameraPitch` as
201
- `aimYawDeg`/`aimPitchDeg`, `grounded`, …) + the body, in **degrees**. **Never send bone transforms** — you
202
- replicate the *inputs* to the animation system; each remote animates client-side. The complete runnable version
203
- is the **`hangout` template** (`read_template({ name: "hangout" })`); use it as the reference `src/main.ts`.
238
+ Views re-read the live schema on every call — call them fresh each frame instead of caching results.
239
+ `list()` hands back a real Array (a Colyseus list is iterable but NOT an Array); elements are the live records.
204
240
 
205
241
  ## Beyond presence — the declarative game logic
206
242
 
@@ -210,22 +246,79 @@ collections, or ownership, declare them in the `multiplayer` block and read the
210
246
  no server code — declared rules run on the platform room; clients read `room.state` and send `sendAction` /
211
247
  `sendState`.
212
248
 
249
+ ## Converting an existing single-player world to multiplayer
250
+
251
+ A world that already works solo becomes multiplayer in two moves: add the presence on-ramp (above), then move
252
+ every piece of **authority** out of the client. The second move is where conversions go wrong — single-player
253
+ code is full of self-authority that multiplayer must not keep. Work through this sequence:
254
+
255
+ 1. **Manifest.** Add the `multiplayer` block to `helix.json` (start with `{ "authoritative": true }` + a modest
256
+ `maxPlayers`; grow `state`/`rules` as you convert logic per `multiplayer-logic`).
257
+ 2. **Presence on-ramp** (§ above): the `CharacterMultiplayer.create` call against your existing scene/body —
258
+ it brings the reconciler, replicas, avatars, and nameplates with it. Your single-player setup stays
259
+ UNCONDITIONAL (the golden rule) — the facade layers multiplayer on only when a join succeeds.
260
+ 3. **Audit every write to your own position — the #1 conversion bug.** Search the source for `.teleport(`,
261
+ `.respawn(`, and anything else that sets the local character's transform outside normal movement: kill-planes,
262
+ checkpoints, round resets, "play again" flows, portals, out-of-bounds handlers. With a room, EACH of those must
263
+ become a server-side rule using the `respawn`/`teleport` effect — triggered by a declared zone, timer, phase,
264
+ or an explicit action (client sends `requestRespawn`, a rule respawns `self`). The facade's reconciler lands the
265
+ server's move on your body; keep the direct local teleport ONLY on the single-player path (`if (!mp.room)`).
266
+ **Why this is not optional:** the room's movement gate rejects any implausible position jump and holds the
267
+ seat's last plausible position — and it never self-heals. A client-side teleport therefore freezes your avatar
268
+ for every OTHER player (you keep playing locally; they see you stuck or vanished at the old spot). Solo testing
269
+ cannot surface this — it only breaks with a second client connected.
270
+ 4. **Round/lifecycle resets reset everyone server-side, in one rule.** "New round → everyone back to start" is
271
+ `{"do":"forEachPlayer","as":"p","then":[{"do":"respawn","player":"p","to":<spawn>}]}` on the round timer/phase —
272
+ never a broadcast that each client answers by teleporting itself (that's one copy of the step-3 bug per player).
273
+ 5. **Client-detected events may stay client-detected** (a finish line crossed, a collectible touched): send a
274
+ declared `action`; the *consequences* (score, phase change, respawn) happen in rules — guarded by an `if` on a
275
+ room var (or use a declared zone) so a re-fired action can't double-apply.
276
+ 6. **Shared state replaces local state.** Any value both players must agree on (score, round, winner) moves from a
277
+ JS variable into declared `state` mutated by rules; clients render it via the typed accessors and never mutate
278
+ their copy.
279
+ 7. **Verify with TWO clients** (two browser windows). Authority bugs are invisible solo — specifically: finish +
280
+ respawn in one window and CONFIRM the other window sees that avatar move to spawn (not freeze or vanish).
281
+
282
+ > Converting a world built OUTSIDE HELIX entirely (a plain three.js/vite game)? Read
283
+ > **`read_doc({ name: "bring-your-world" })`** first — it stages the platform adoption (become a world → adopt
284
+ > the character → go multiplayer) and hands you this section for the final step.
285
+
213
286
  ## Build, validate, publish
214
287
 
215
- Same as the character recipe §9: `npm install` → **`install_world_packages`** (MCP tool) (resolves the system pin into `helix_modules/`)
216
- → `npm run build` → `validate_world` on `dist/` (fix every problem; **heed the `maxPlayers` clamp warning**) →
217
- `whoami` → `publish_world`. The build contains no `.glb`/`.ktx2` — character assets stream from the CDN.
288
+ **`scaffold_world`** returns the CLI scaffold command plus the applicable recipe. Run it, then:
289
+ `npm install` → **`install_world_packages`** (MCP tool) (resolves the system pin into
290
+ `helix_modules/`) → `npm run build` → `validate_world` on `dist/` (fix every problem; **heed the `maxPlayers`
291
+ 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 no `.glb`/`.ktx2` — character assets stream
292
+ from the CDN.
293
+
294
+ ## Verify placement and zones by measurement — see the world-inspect doc
295
+
296
+ Before publishing, `inspect_world` on `dist/` measures the layout as data: placement checks
297
+ (floating/sunk/intersecting geometry, collider-vs-visual mismatches) PLUS the zone checks — it reads the
298
+ declared `multiplayer.json` zones and verifies each against real geometry (a kill band the pit floor misses,
299
+ a checkpoint volume floating in empty space — zones never render, so nothing else can catch these).
300
+ `focusNear: [x, y, z]` checks a single object; `world_metrics` sizes gaps/steps/doorways before you place
301
+ geometry. Findings are measurements to check against your intent, never orders to change the world:
302
+
303
+ ```
304
+ read_doc("world-inspect")
305
+ ```
218
306
 
219
307
  ## The rules that matter (multiplayer)
220
308
 
221
- - **The server is authoritative and platform-owned.** You send input (`sendState`, `sendAction`); the room owns
222
- the truth. **No server code** — multiplayer is declarative (the manifest opts in; game logic is data).
223
- - **Replicate PARAMETERS, not bones.** Position/facing/speed/anim-inputs go on the wire; animation runs
224
- client-side. Never send skeletons.
225
- - **Login required; single-player must still work.** Build local + scene unconditionally; layer multiplayer on
226
- only when `joinRoom` succeeds.
227
- - **Skip your own `sessionId`** in `onAdd` — the server includes you in `players`; render your own local player.
228
- - **Copy live schema values by value** before caching them (don't alias) — the #1 presence bug.
229
- - **`maxReplicas` is a real budget** (each replica is a full character) — cap it to `maxPlayers`.
309
+ - **The server is authoritative and platform-owned.** You send input (`sendState` — the facade does this —
310
+ and `sendAction`); the room owns the truth. **No server code** — multiplayer is declarative.
311
+ - **Never move your own body across the map client-side.** Teleports/respawns/round resets are server rules (the
312
+ `respawn`/`teleport` effects) that the facade's reconciler lands locally; a client-side teleport trips the
313
+ movement gate and freezes your avatar for everyone else. Converting a single-player world? Read the conversion
314
+ section.
315
+ - **Keep the top-center clear for platform chrome.** The player shell overlays a bar (Exit / Save Progress /
316
+ helixOS) across the top-center of every world. Put your score/HUD in a corner or along the bottom, never
317
+ top-center (it renders behind the chrome) — the templates anchor `#hud` top-left; see the character recipe.
318
+ - **Read state through the typed accessors, fresh each frame** (`room.vars` / `room.me` / `room.player(id)`);
319
+ never cache a read across frames and never mutate your copy.
320
+ - **`maxReplicas` is a real budget** (each replica is a full character) — keep it = your `maxPlayers`.
321
+ - **Replicate PARAMETERS, not bones.** The facade uploads position/facing/speed/anim-inputs; animation runs
322
+ client-side. (Only relevant if you eject — never send skeletons.)
230
323
  - **For shared game state, read `multiplayer-logic`** and start from the closest template — don't invent a wire
231
324
  format; declare it.