@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
|
@@ -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.
|
|
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
|
|
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
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
//
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
room
|
|
115
|
-
room
|
|
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.
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
(`authority:'owner'`),
|
|
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.
|
|
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
|
-
|
|
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
|
-
//
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
//
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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:**
|
|
110
|
-
|
|
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
|
|
5
|
-
|
|
6
|
-
|
|
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.
|
|
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)
|
|
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
|
|
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**
|
|
48
|
-
|
|
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
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
const
|
|
74
|
-
|
|
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
|
-
|
|
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
|
-
|
|
94
|
-
|
|
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
|
-
|
|
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);
|
|
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));
|
|
119
|
-
|
|
120
|
-
|
|
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
|
|
129
|
-
remote.set(id, player); replicas.add(id);
|
|
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
|
|
136
|
-
conversion
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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.
|