@hypersoniclabs/helix-mcp 0.2.4 → 0.2.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +81 -11
- package/dist/continuumCanary.d.ts +17 -0
- package/dist/continuumCanary.js +17 -0
- package/dist/continuumCanary.js.map +1 -0
- package/dist/server.d.ts +14 -1
- package/dist/server.js +4058 -162
- package/dist/server.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/dist/vehicleTools.d.ts +86 -0
- package/dist/vehicleTools.js +229 -0
- package/dist/vehicleTools.js.map +1 -0
- package/docs/avatar-face.md +115 -0
- package/docs/bridge.md +98 -0
- package/docs/bring-your-world.md +117 -0
- package/docs/catalog.md +69 -1
- package/docs/character-animation.md +442 -0
- package/docs/character-attachments.md +166 -0
- package/docs/character-world.md +785 -130
- package/docs/continuum.md +153 -0
- package/docs/items.md +73 -0
- package/docs/lighting-world.md +667 -0
- package/docs/locomotion-clip-spec.md +294 -0
- package/docs/manifest.md +31 -6
- package/docs/multiplayer-logic.md +460 -17
- package/docs/multiplayer-templates/chrono-orchard.md +36 -22
- package/docs/multiplayer-templates/collect-a-thon.md +28 -38
- package/docs/multiplayer-templates/collections.md +24 -24
- package/docs/multiplayer-templates/hangout.md +124 -111
- package/docs/multiplayer-templates/npc-wave.md +310 -0
- package/docs/multiplayer-templates/obby.md +13 -16
- package/docs/multiplayer-templates/persistent-progress.md +218 -0
- package/docs/multiplayer-templates/physics-bumper.md +27 -9
- package/docs/multiplayer-templates/physics-football.md +22 -8
- package/docs/multiplayer-templates/relic-bearers.md +12 -15
- package/docs/multiplayer-templates/server-motion.md +16 -19
- package/docs/multiplayer-templates/shooter-range.md +275 -0
- package/docs/multiplayer-templates/team-control.md +28 -15
- package/docs/multiplayer-templates/turn-arena.md +31 -22
- package/docs/multiplayer-templates/voice-radio.md +166 -0
- package/docs/multiplayer-templates/wave-survival.md +7 -8
- package/docs/multiplayer-templates/world-shop.md +240 -0
- package/docs/multiplayer-world.md +222 -129
- package/docs/npc-world.md +623 -0
- package/docs/publishing.md +108 -28
- package/docs/purchases.md +223 -0
- package/docs/scene-performance.md +64 -0
- package/docs/screenshots.md +140 -0
- package/docs/sdk.md +324 -5
- package/docs/shooter-worlds.md +537 -0
- package/docs/terrain.md +173 -0
- package/docs/upgrades.md +324 -0
- package/docs/vehicles.md +727 -0
- package/docs/world-inspect.md +156 -0
- package/docs/world-look.md +241 -0
- package/docs/world-recipe.md +65 -6
- package/package.json +15 -4
- package/skills/README.md +91 -0
- package/skills/helix-assets/SKILL.md +491 -0
- package/skills/helix-assets/references/asset-sources.md +143 -0
- package/skills/helix-assets/references/vault-api.md +105 -0
- package/skills/helix-avatar-qa/SKILL.md +85 -0
- package/skills/helix-avatars/SKILL.md +206 -0
- package/skills/helix-avatars/references/contract.md +166 -0
- package/skills/helix-avatars/references/dynamics.md +367 -0
- package/skills/helix-avatars/references/face.md +50 -0
- package/skills/helix-avatars/references/publish.md +76 -0
- package/skills/helix-avatars/references/qa.md +251 -0
- package/skills/helix-avatars/references/rigging.md +88 -0
- package/skills/helix-avatars/references/source-generated.md +190 -0
- package/skills/helix-avatars/references/source-model.md +90 -0
- package/skills/helix-avatars/references/source-rigid.md +90 -0
- package/skills/helix-avatars/references/source-vrm.md +61 -0
- package/skills/helix-gauntlet/SKILL.md +128 -0
- package/skills/helix-multiplayer/SKILL.md +150 -0
- package/skills/helix-multiplayer/references/dsl-capability-map.md +107 -0
- package/skills/helix-vehicles/SKILL.md +218 -0
- package/skills/helix-vehicles/references/addons.md +212 -0
- package/skills/helix-vehicles/references/appearance.md +339 -0
- package/skills/helix-vehicles/references/audio-import.md +138 -0
- package/skills/helix-vehicles/references/audio.md +580 -0
- package/skills/helix-vehicles/references/cabin.md +225 -0
- package/skills/helix-vehicles/references/host-manifest.md +174 -0
- package/skills/helix-vehicles/references/physics-from-specs.md +463 -0
- package/skills/helix-vehicles/references/publish.md +214 -0
- package/skills/helix-vehicles/references/qa.md +177 -0
- package/skills/helix-vehicles/references/reference-package.json +3481 -0
- package/skills/helix-vehicles/references/reference-package.md +69 -0
- package/skills/helix-vehicles/references/source-beamng.md +167 -0
- package/skills/helix-vehicles/references/source-concept.md +38 -0
- package/skills/helix-vehicles/references/source-model.md +100 -0
- package/skills/helix-vehicles/references/source-scratch.md +60 -0
- package/skills/helix-world-build/SKILL.md +376 -0
- package/skills/helix-world-build/references/config-gates.md +104 -0
- package/skills/helix-world-director/SKILL.md +210 -0
- package/skills/helix-world-qa/SKILL.md +371 -0
- package/skills/helix-world-qa/references/perf-budgets.md +240 -0
- package/skills/helix-world-qa/references/perf-handle.md +125 -0
- package/skills/helix-world-qa/references/visual-scorecard.md +85 -0
|
@@ -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
|
|
10
|
-
>
|
|
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
|
|
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
|
|
23
|
-
hangout). (b) **Declarative game logic** — shared
|
|
24
|
-
timers, a state machine, collections, ownership.
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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).
|
|
36
|
-
|
|
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.
|
|
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.**
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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()`,
|
|
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**:
|
|
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
|
-
|
|
|
81
|
-
|
|
|
82
|
-
|
|
|
83
|
-
| a
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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 —
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
|
127
|
+
import { CharacterMultiplayer, RapierBody } from '@helix/humanoid-character';
|
|
128
|
+
import { SYSTEM_ASSET_BASE, TRANSCODER_PATH } from './helix.runtime';
|
|
109
129
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
128
|
-
`
|
|
129
|
-
|
|
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
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
201
|
-
`
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
`
|
|
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
|
|
222
|
-
the truth. **No server code** — multiplayer is declarative
|
|
223
|
-
- **
|
|
224
|
-
client-side
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
- **
|
|
228
|
-
-
|
|
229
|
-
-
|
|
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.
|