@helix3/helix-mcp 0.2.2-helix3.14
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/LICENSE +21 -0
- package/README.md +46 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.js +353 -0
- package/dist/server.js.map +1 -0
- package/dist/tsconfig.build.tsbuildinfo +1 -0
- package/docs/catalog.md +28 -0
- package/docs/character-world.md +506 -0
- package/docs/manifest.md +51 -0
- package/docs/multiplayer-logic.md +394 -0
- package/docs/multiplayer-templates/chrono-orchard.md +123 -0
- package/docs/multiplayer-templates/collect-a-thon.md +126 -0
- package/docs/multiplayer-templates/collections.md +116 -0
- package/docs/multiplayer-templates/hangout.md +184 -0
- package/docs/multiplayer-templates/obby.md +84 -0
- package/docs/multiplayer-templates/physics-bumper.md +153 -0
- package/docs/multiplayer-templates/physics-football.md +133 -0
- package/docs/multiplayer-templates/relic-bearers.md +140 -0
- package/docs/multiplayer-templates/server-motion.md +128 -0
- package/docs/multiplayer-templates/team-control.md +98 -0
- package/docs/multiplayer-templates/turn-arena.md +159 -0
- package/docs/multiplayer-templates/wave-survival.md +141 -0
- package/docs/multiplayer-world.md +231 -0
- package/docs/publishing.md +42 -0
- package/docs/sdk.md +117 -0
- package/docs/world-recipe.md +137 -0
- package/package.json +33 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# Multiplayer template — `collect-a-thon`
|
|
2
|
+
|
|
3
|
+
**What it is.** A 2–8 player scramble: the server spawns coins at fixed spots; run into one to score its value
|
|
4
|
+
and it hides, then respawns a few seconds later. **Capability: declared state + server-authoritative *static*
|
|
5
|
+
entities + attached zones + keyed timers** — the first world with real game logic, and the simplest
|
|
6
|
+
*server-only* entity pattern (the client is a **pure renderer**, no client authority → cheat-proof). Lifted from
|
|
7
|
+
the verified `multiplayer-collect-a-thon` world.
|
|
8
|
+
|
|
9
|
+
> Read the **hub** (`get_started({ kind: "multiplayer" })`) and the **`hangout`** template first — this is
|
|
10
|
+
> `hangout` (presence) **plus** the declarative layer below. Grammar reference: `read_doc({ name: "multiplayer-logic" })`.
|
|
11
|
+
|
|
12
|
+
## 1. DSL used
|
|
13
|
+
|
|
14
|
+
A small slice of the DSL (each links into `multiplayer-logic`):
|
|
15
|
+
- **Declared state** (§2) — `roomVars.spawned` (a spawn guard) + `playerVars.score`.
|
|
16
|
+
- **Entities** (§9) — one `coin` kind with `vars` (`value`, `active`) and an **attached `zone`** (§10) — a sphere
|
|
17
|
+
centered on each coin that fires `zoneEnter` when a player walks in. `authority` defaults to **server** (static,
|
|
18
|
+
cheat-proof — no `motion`, no client hosting).
|
|
19
|
+
- **Timers** (§11) — `respawn`, **keyed `entity:coin`** so each coin has its own independent countdown.
|
|
20
|
+
- **Rules** (§3) — three: a `tick` rule that spawns the coins **once** (guarded by `spawned < 1`); a
|
|
21
|
+
`zoneEnter` rule (binds `self` = the player, `source` = the coin) that scores + deactivates + arms the per-coin
|
|
22
|
+
timer; a `timerElapsed` rule that re-activates the coin (`self` = the keyed coin).
|
|
23
|
+
|
|
24
|
+
## 2. The manifest — `public/helix.json`
|
|
25
|
+
|
|
26
|
+
```json
|
|
27
|
+
{
|
|
28
|
+
"helixVersion": "0.3",
|
|
29
|
+
"title": "Collect-a-thon",
|
|
30
|
+
"slug": "collect-a-thon",
|
|
31
|
+
"entry": "index.html",
|
|
32
|
+
"maxPlayers": 8,
|
|
33
|
+
"permissions": ["auth.profile", "multiplayer"],
|
|
34
|
+
"multiplayer": {
|
|
35
|
+
"authoritative": true,
|
|
36
|
+
"state": {
|
|
37
|
+
"roomVars": { "spawned": { "type": "number", "default": 0 } },
|
|
38
|
+
"playerVars": { "score": { "type": "number", "default": 0 } }
|
|
39
|
+
},
|
|
40
|
+
"entities": {
|
|
41
|
+
"coin": {
|
|
42
|
+
"vars": { "value": { "type": "number", "default": 10 }, "active": { "type": "boolean", "default": true } },
|
|
43
|
+
"zone": { "shape": "sphere", "radius": 1.5 }
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
"timers": { "respawn": { "keyed": "entity:coin" } },
|
|
47
|
+
"rules": [
|
|
48
|
+
{
|
|
49
|
+
"when": { "on": "tick", "everyN": 1 },
|
|
50
|
+
"if": { "op": "<", "a": { "var": "room.spawned" }, "b": 1 },
|
|
51
|
+
"then": [
|
|
52
|
+
{ "do": "spawnEntity", "kind": "coin", "at": { "vec3": [7, 0, 0] } },
|
|
53
|
+
{ "do": "spawnEntity", "kind": "coin", "at": { "vec3": [-7, 0, 0] } },
|
|
54
|
+
{ "do": "spawnEntity", "kind": "coin", "at": { "vec3": [0, 0, 7] } },
|
|
55
|
+
{ "do": "spawnEntity", "kind": "coin", "at": { "vec3": [0, 0, -7] } },
|
|
56
|
+
{ "do": "add", "target": "room.spawned", "by": 1 }
|
|
57
|
+
]
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
"when": { "on": "zoneEnter", "zone": "coin" },
|
|
61
|
+
"if": { "op": "==", "a": { "ref": "source", "var": "active" }, "b": true },
|
|
62
|
+
"then": [
|
|
63
|
+
{ "do": "add", "target": "self.score", "by": { "ref": "source", "var": "value" } },
|
|
64
|
+
{ "do": "set", "target": { "ref": "source", "var": "active" }, "to": false },
|
|
65
|
+
{ "do": "startTimer", "timer": "respawn", "seconds": 5, "key": "source" }
|
|
66
|
+
]
|
|
67
|
+
},
|
|
68
|
+
{ "when": { "on": "timerElapsed", "timer": "respawn" }, "then": [{ "do": "set", "target": "self.active", "to": true }] }
|
|
69
|
+
]
|
|
70
|
+
},
|
|
71
|
+
"supportsMobile": true,
|
|
72
|
+
"contentRating": "everyone",
|
|
73
|
+
"systems": { "humanoid-character": "^0.2" }
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
`zoneEnter` binds `self` = the entering player and `source` = the coin carrying the zone. `startTimer … "key":
|
|
78
|
+
"source"` arms that specific coin's instance; `timerElapsed` then binds `self` = that coin. The validator rejects
|
|
79
|
+
a typo'd verb/var with a did-you-mean — run `validate_world` and read the path.
|
|
80
|
+
|
|
81
|
+
## 3. The client — `src/main.ts` (delta from `hangout`)
|
|
82
|
+
|
|
83
|
+
Same presence wiring as `hangout` (join, replicas, `sendState`). The **new part** is the *entity render-proxy*:
|
|
84
|
+
spawn a mesh per synced server entity and render it off its **live** state each frame — the client never writes
|
|
85
|
+
entity state (the server owns it).
|
|
86
|
+
|
|
87
|
+
```ts
|
|
88
|
+
import type { EntityState } from '@hypersoniclabs/helix-sdk';
|
|
89
|
+
|
|
90
|
+
const coins = new Map<string, { mesh: THREE.Mesh; state: EntityState }>();
|
|
91
|
+
const isActive = (e: EntityState) => (e.vars as Record<string, unknown> | undefined)?.active !== false;
|
|
92
|
+
|
|
93
|
+
if (room) {
|
|
94
|
+
// Spawn a coin mesh on add, drop it on destroy. `entity` is a LIVE ref colyseus mutates in place —
|
|
95
|
+
// stash it and read .position / .vars each frame (no per-entity subscription needed).
|
|
96
|
+
room.onAdd('entities', (entity, id) => {
|
|
97
|
+
const mesh = makeCoin(); scene.add(mesh); // makeCoin(): your spinning-disc THREE.Mesh
|
|
98
|
+
coins.set(id, { mesh, state: entity });
|
|
99
|
+
});
|
|
100
|
+
room.onRemove('entities', (_entity, id) => {
|
|
101
|
+
const c = coins.get(id);
|
|
102
|
+
if (c) { scene.remove(c.mesh); c.mesh.geometry.dispose(); coins.delete(id); }
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// …in the frame loop, inside `if (room) { … }`, after replicas.update(dt):
|
|
107
|
+
for (const [, c] of coins) {
|
|
108
|
+
const active = isActive(c.state);
|
|
109
|
+
c.mesh.visible = active; // hidden after pickup until the respawn timer fires
|
|
110
|
+
if (active && c.state.position) {
|
|
111
|
+
const p = c.state.position;
|
|
112
|
+
c.mesh.position.lerp(target.set(p.x, p.y + 0.8, p.z), 0.25); // interpolate toward the synced position
|
|
113
|
+
c.mesh.rotation.y += dt * 2.6;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Read each player's `score` off `room.state.players[id].vars.score` for a HUD/leaderboard. **Footguns:** read the
|
|
119
|
+
live `state` each frame (don't snapshot `.vars` once); guard `room.state.entities` until the first patch (empty on
|
|
120
|
+
frame 0). This *server-only* proxy is for entities the **server** drives — for entities a **client** simulates
|
|
121
|
+
(`authority:'owner'`), use `EntityScene` instead (see the `relic-bearers` / physics templates).
|
|
122
|
+
|
|
123
|
+
## 4. Build, validate, publish
|
|
124
|
+
|
|
125
|
+
`npm install` → `helix install` → `npm run build` → `validate_world` on `dist/` (fix every problem; watch the
|
|
126
|
+
per-tick budget + entity caps) → `whoami` → `publish_world`.
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# Multiplayer template — `collections`
|
|
2
|
+
|
|
3
|
+
**What it is.** Each player holds a **hand of cards**: walk onto a draw pad to add a card, onto the tally pad to
|
|
4
|
+
score your hand, press keys to mark/discard, and brush past someone to peek at their hand size. **Capability:
|
|
5
|
+
structured collections** — a **`list` of `record`s** with the full collection sublanguage: `append`,
|
|
6
|
+
`forEachInList` (+ `setField`), `listCount`, `listIndexOf`, `removeWhere`, and a ref-addressed `listLength`. Use
|
|
7
|
+
this for hands, inventories, decks, crafting, order books. Lifted from the verified `multiplayer-card-showdown`
|
|
8
|
+
world.
|
|
9
|
+
|
|
10
|
+
> A normal character world (presence) **plus** the collection logic — the client is a pure renderer of the synced
|
|
11
|
+
> hand. Grammar: `read_doc({ name: "multiplayer-logic" })` §2 (var types), §5/§6 (collection ops), §8 (effects).
|
|
12
|
+
|
|
13
|
+
## 1. DSL used
|
|
14
|
+
|
|
15
|
+
- **Declared state** (§2) — `playerVars.hand` is a **`list`** whose element is a flat **`record`**
|
|
16
|
+
(`suit`/`rank`/`played`), `maxLen: 12`; plus scalar `playerVars` the rules compute (`handValue`, `redCount`,
|
|
17
|
+
`topIdx`, `peeked`).
|
|
18
|
+
- **Zones** (§10) — `drawRed`/`drawBlack` (draw a card) + a `tally` box (score the hand).
|
|
19
|
+
- **Actions** (§14) — `mark`, `discard`.
|
|
20
|
+
- **Collection ops + effects** (§5/§6/§8) — `append` a record literal; `forEachInList … where … setField` (mutate
|
|
21
|
+
matching elements); `listCount`/`listIndexOf` (count/find by predicate); `removeWhere`; and `listLength` read
|
|
22
|
+
through a **ref** (`{ ref: "other", var: "hand" }`) for the peek.
|
|
23
|
+
- **`playerContact`** (§4) — brushing another player binds `other`; read their hand length.
|
|
24
|
+
|
|
25
|
+
## 2. The manifest — `public/helix.json`
|
|
26
|
+
|
|
27
|
+
```json
|
|
28
|
+
{
|
|
29
|
+
"helixVersion": "0.3",
|
|
30
|
+
"title": "Card Showdown",
|
|
31
|
+
"slug": "card-showdown",
|
|
32
|
+
"entry": "index.html",
|
|
33
|
+
"maxPlayers": 8,
|
|
34
|
+
"permissions": ["auth.profile", "multiplayer"],
|
|
35
|
+
"multiplayer": {
|
|
36
|
+
"authoritative": true,
|
|
37
|
+
"state": {
|
|
38
|
+
"playerVars": {
|
|
39
|
+
"hand": { "type": "list", "maxLen": 12, "of": { "type": "record", "fields": { "suit": { "type": "string" }, "rank": { "type": "number", "default": 0 }, "played": { "type": "boolean", "default": false } } } },
|
|
40
|
+
"handValue": { "type": "number", "default": 0 },
|
|
41
|
+
"redCount": { "type": "number", "default": 0 },
|
|
42
|
+
"topIdx": { "type": "number", "default": -1 },
|
|
43
|
+
"peeked": { "type": "number", "default": -1 }
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
"zones": [
|
|
47
|
+
{ "id": "drawRed", "shape": "sphere", "center": [10, 0, 0], "radius": 2.2 },
|
|
48
|
+
{ "id": "drawBlack", "shape": "sphere", "center": [-10, 0, 0], "radius": 2.2 },
|
|
49
|
+
{ "id": "tally", "shape": "box", "center": [0, 0, -10], "size": [3.5, 4, 3.5] }
|
|
50
|
+
],
|
|
51
|
+
"states": { "initial": "play", "phases": ["play"], "joinPolicy": { "play": { "joinable": true } } },
|
|
52
|
+
"actions": { "mark": {}, "discard": {} },
|
|
53
|
+
"rules": [
|
|
54
|
+
{ "when": { "on": "zoneEnter", "zone": "drawRed" }, "then": [{ "do": "append", "target": "self.hand", "value": { "suit": "red", "rank": { "op": "+", "a": 1, "b": { "op": "*", "a": { "op": "random" }, "b": 12 } }, "played": false } }] },
|
|
55
|
+
{ "when": { "on": "zoneEnter", "zone": "drawBlack" }, "then": [{ "do": "append", "target": "self.hand", "value": { "suit": "black", "rank": { "op": "+", "a": 1, "b": { "op": "*", "a": { "op": "random" }, "b": 12 } }, "played": false } }] },
|
|
56
|
+
{
|
|
57
|
+
"when": { "on": "zoneEnter", "zone": "tally" },
|
|
58
|
+
"then": [
|
|
59
|
+
{ "do": "set", "target": "self.handValue", "to": 0 },
|
|
60
|
+
{ "do": "forEachInList", "list": "self.hand", "as": "h", "then": [{ "do": "add", "target": "self.handValue", "by": { "ref": "h", "var": "rank" } }] },
|
|
61
|
+
{ "do": "set", "target": "self.redCount", "to": { "op": "listCount", "list": "self.hand", "as": "h", "where": { "op": "==", "a": { "ref": "h", "var": "suit" }, "b": "red" } } },
|
|
62
|
+
{ "do": "set", "target": "self.topIdx", "to": { "op": "listIndexOf", "list": "self.hand", "as": "h", "where": { "op": ">=", "a": { "ref": "h", "var": "rank" }, "b": 8 } } }
|
|
63
|
+
]
|
|
64
|
+
},
|
|
65
|
+
{ "when": { "on": "action", "name": "mark" }, "then": [{ "do": "forEachInList", "list": "self.hand", "as": "h", "where": { "op": "==", "a": { "ref": "h", "var": "suit" }, "b": "red" }, "then": [{ "do": "setField", "as": "h", "field": "played", "to": true }] }] },
|
|
66
|
+
{ "when": { "on": "action", "name": "discard" }, "then": [{ "do": "removeWhere", "target": "self.hand", "as": "h", "where": { "op": "==", "a": { "ref": "h", "var": "played" }, "b": true } }] },
|
|
67
|
+
{ "when": { "on": "playerContact", "radius": 2.5 }, "then": [{ "do": "set", "target": "self.peeked", "to": { "op": "listLength", "list": { "ref": "other", "var": "hand" } } }] }
|
|
68
|
+
]
|
|
69
|
+
},
|
|
70
|
+
"supportsMobile": true,
|
|
71
|
+
"contentRating": "everyone",
|
|
72
|
+
"systems": { "humanoid-character": "^0.2" }
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
`append`'s `value` is a record literal whose fields are expressions (here a random rank). `forEachInList … as:"h"`
|
|
77
|
+
binds each element; inside, read fields with `{ ref: "h", var: "rank" }` and mutate with `setField … as:"h"`.
|
|
78
|
+
`listCount`/`listIndexOf` take a `where` predicate over the same `as` binding. Stay within `maxLen` (12) and the
|
|
79
|
+
per-tick budget — list scans are charged at the declared `maxLen`.
|
|
80
|
+
|
|
81
|
+
## 3. The client — `src/main.ts` (delta from `hangout`)
|
|
82
|
+
|
|
83
|
+
A normal presence world; the new part is **reading the synced hand** and binding the actions. The hand is a
|
|
84
|
+
Colyseus list of record objects — read it off `room.state.players[id].vars.hand` and render a HUD:
|
|
85
|
+
|
|
86
|
+
```ts
|
|
87
|
+
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
|
+
|
|
98
|
+
// each frame, render your hand + the computed scalars:
|
|
99
|
+
const me = room.state.players[room.sessionId];
|
|
100
|
+
renderHandHud(handOf(me), numVar(me, 'handValue'), numVar(me, 'redCount'));
|
|
101
|
+
|
|
102
|
+
// input → actions (the server mutates the list authoritatively):
|
|
103
|
+
addEventListener('keydown', (e) => {
|
|
104
|
+
if (e.key === 'm') room.sendAction('mark');
|
|
105
|
+
else if (e.key === 'g') room.sendAction('discard');
|
|
106
|
+
});
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
**Footguns:** the hand is a live `ArraySchema` (read length + index per frame, don't cache the array); guard
|
|
110
|
+
`vars` until the first patch; drawing/marking/discarding are all server writes via zones/actions — the client only
|
|
111
|
+
reads + sends intents.
|
|
112
|
+
|
|
113
|
+
## 4. Build, validate, publish
|
|
114
|
+
|
|
115
|
+
`npm install` → `helix install` → `npm run build` → `validate_world` on `dist/` (watch `listMaxLen`/`recordFields`
|
|
116
|
+
caps + the per-tick budget) → `whoami` → `publish_world`.
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# Multiplayer template — `hangout`
|
|
2
|
+
|
|
3
|
+
**What it is.** A 2–8 player social space: everyone sees everyone else move, animate, jump, and look around in
|
|
4
|
+
one shared world. **Capability: presence** — the see-each-other-move substrate every other multiplayer template
|
|
5
|
+
builds on. Lifted from the verified `multiplayer-hangout` world; this is the reference `src/main.ts` the other
|
|
6
|
+
templates *delta from*.
|
|
7
|
+
|
|
8
|
+
> Read the multiplayer **hub** first (`get_started({ kind: "multiplayer" })`) for the model + the manifest opt-in,
|
|
9
|
+
> 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
|
+
|
|
12
|
+
## 1. DSL used
|
|
13
|
+
|
|
14
|
+
**None — `hangout` is pure presence.** The `multiplayer` block is just `{ "authoritative": true }`: no `state`,
|
|
15
|
+
`rules`, `entities`, `zones`, `timers`, `states`, `events`, or `actions`. Players replicate automatically (the
|
|
16
|
+
fixed character contract — position, facing, speed, move dir, grounded/crouched, aim, active abilities). When you
|
|
17
|
+
want shared game state (scores, teams, objects, rules), read `read_doc({ name: "multiplayer-logic" })` and start
|
|
18
|
+
from a logic-bearing template — `collect-a-thon` is the simplest next step.
|
|
19
|
+
|
|
20
|
+
## 2. The manifest — `public/helix.json`
|
|
21
|
+
|
|
22
|
+
```json
|
|
23
|
+
{
|
|
24
|
+
"helixVersion": "0.3",
|
|
25
|
+
"title": "My Hangout",
|
|
26
|
+
"slug": "my-hangout",
|
|
27
|
+
"entry": "index.html",
|
|
28
|
+
"maxPlayers": 8,
|
|
29
|
+
"permissions": ["auth.profile", "multiplayer"],
|
|
30
|
+
"multiplayer": { "authoritative": true },
|
|
31
|
+
"supportsMobile": true,
|
|
32
|
+
"contentRating": "everyone",
|
|
33
|
+
"systems": { "humanoid-character": "^0.2" }
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
`maxPlayers > 1` **requires** the `multiplayer` permission; `multiplayer` **forces** login (coerced for you). The
|
|
38
|
+
`humanoid-character` pin is resolved by `helix install` into `helix_modules/` (the `@helix/humanoid-character`
|
|
39
|
+
vite alias from the character recipe). Keep `maxPlayers` modest — `validate_world` warns if you exceed the
|
|
40
|
+
per-room cap.
|
|
41
|
+
|
|
42
|
+
## 3. The client — `src/main.ts`
|
|
43
|
+
|
|
44
|
+
Add `@hypersoniclabs/helix-sdk` to `package.json` for `Helix.multiplayer`; the replica primitives
|
|
45
|
+
(`ReplicaScene`, `NetworkDriver`, `ReplicaBody`) come from the `humanoid-character` system you already pin.
|
|
46
|
+
`index.html`, `vite.config.ts`, `tsconfig.json`, `src/loading.ts`, `src/helix.runtime.ts` are **identical to the
|
|
47
|
+
character recipe** — only `src/main.ts` + `public/helix.json` differ. Build your local player exactly as in the
|
|
48
|
+
character recipe, then layer multiplayer on:
|
|
49
|
+
|
|
50
|
+
**a. Init FIRST, then load bodies — every player renders THEIR universal avatar.** The character body is
|
|
51
|
+
bind-once, so the session (and with it the local player's equipped avatar) must resolve *before* assets load:
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
import { Helix } from '@hypersoniclabs/helix-sdk';
|
|
55
|
+
import type { HelixRoom, PlayerState, ReplicaInput } from '@hypersoniclabs/helix-sdk';
|
|
56
|
+
import { AvatarModelCache, createCharacterAssetIO, loadCharacterAssets,
|
|
57
|
+
UNIVERSAL_AVATAR_SKELETON } from '@helix/humanoid-character';
|
|
58
|
+
import { SYSTEM_ASSET_BASE, TRANSCODER_PATH } from './helix.runtime';
|
|
59
|
+
|
|
60
|
+
const { embedded, user } = await Helix.init();
|
|
61
|
+
if (embedded && !user) {
|
|
62
|
+
try { await Helix.auth.requestLogin(); } catch { /* declined → guest, default body, single-player */ }
|
|
63
|
+
}
|
|
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).
|
|
67
|
+
const io = createCharacterAssetIO({ renderer, transcoderPath: TRANSCODER_PATH });
|
|
68
|
+
const avatarCache = new AvatarModelCache({ io });
|
|
69
|
+
const { model: baseModel, clips } = await loadCharacterAssets(SYSTEM_ASSET_BASE, { io }); // the DEFAULT body
|
|
70
|
+
|
|
71
|
+
// Local player: my equipped avatar when it's converted for this rig, else a clone of the default body.
|
|
72
|
+
// Worlds that opt OUT of universal avatars delete these three lines (see character.universalAvatar.enabled).
|
|
73
|
+
const equipped = await Helix.avatar.getEquipped(); // null for guests / no avatar / any failure
|
|
74
|
+
const ownAvatarUrl = equipped?.glbUrl && equipped.skeleton === UNIVERSAL_AVATAR_SKELETON ? equipped.glbUrl : null;
|
|
75
|
+
const localModel = (ownAvatarUrl !== null ? await avatarCache.load(ownAvatarUrl) : null)?.model ?? cloneSkinned(baseModel);
|
|
76
|
+
// …scene.add(localModel) and Character.create({ model: localModel, … }) exactly as the character recipe.
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
**b. Join — guarded, so guests / standalone fall back to single-player:**
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
let room: HelixRoom | null = null;
|
|
83
|
+
if (embedded) {
|
|
84
|
+
try {
|
|
85
|
+
room = await Helix.multiplayer.joinRoom(); // defaults to the current world; resolves + connects
|
|
86
|
+
} catch (err) {
|
|
87
|
+
console.info('multiplayer unavailable — running single-player:', err);
|
|
88
|
+
room = null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
The `HelixRoom` handle (Colyseus, re-exposed under `Helix.*`): `room.state` (`players`) · `room.sessionId` (yours
|
|
94
|
+
— skip it in `onAdd`) · `room.onAdd('players', …)` / `onRemove` (fires for present players too) ·
|
|
95
|
+
`room.sendState(input)` (throttled + seq-tagged for you) · `room.sendAbility(id, active)` · `room.onStateChange` /
|
|
96
|
+
`onMessage` / `leave` · `room.onDrop` / `onReconnect` / `onLeave` (auto-reconnect).
|
|
97
|
+
|
|
98
|
+
> Player objects from `onAdd` are **live references** Colyseus mutates in place each patch — stash them and read
|
|
99
|
+
> per frame. Don't iterate `room.state.players` as a plain object (it's a `MapSchema`, not a `Record`).
|
|
100
|
+
|
|
101
|
+
**c. Render remotes — `ReplicaScene` + `NetworkDriver`** (each remote is a headless `Character` driven off the
|
|
102
|
+
wire, wearing **their** avatar — the room replicates each player's backend-resolved `avatarUrl`, `''` = none):
|
|
103
|
+
|
|
104
|
+
```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
|
+
const remote = new Map<string, PlayerState>();
|
|
110
|
+
|
|
111
|
+
async function buildReplica(id: string): Promise<ReplicaHandle> {
|
|
112
|
+
// THEIR avatar via the shared cache; any failure (or '') falls back to the DEFAULT body — never yours.
|
|
113
|
+
const avatarUrl = remote.get(id)?.avatarUrl;
|
|
114
|
+
const model = (avatarUrl ? await avatarCache.load(avatarUrl) : null)?.model ?? cloneSkinned(baseModel);
|
|
115
|
+
scene.add(model);
|
|
116
|
+
const rbody = new ReplicaBody(SPAWN); // inert, no-physics body — pose comes from the wire
|
|
117
|
+
const character = await Character.create({ model, body: rbody });
|
|
118
|
+
character.abilities.register(new LocomotionAbility(clips)); // same anim graph as the local player
|
|
119
|
+
const driver = new NetworkDriver({ body: rbody, blackboard: character.blackboard });
|
|
120
|
+
character.setDriver(driver); // the driver feeds the blackboard from snapshots
|
|
121
|
+
return { pushSnapshot: (p) => driver.pushSnapshot(p), update: (dt) => character.update(dt),
|
|
122
|
+
dispose: () => { character.dispose(); scene.remove(model); } };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const replicas = new ReplicaScene({ build: buildReplica, maxReplicas: 8 }); // maxReplicas is a real perf budget
|
|
126
|
+
if (room) {
|
|
127
|
+
room.onAdd('players', (player, id) => {
|
|
128
|
+
if (id === room!.sessionId) return; // that's me — I render my own local player
|
|
129
|
+
remote.set(id, player); replicas.add(id); // async build; the pool buffers snapshots until ready
|
|
130
|
+
});
|
|
131
|
+
room.onRemove('players', (_p, id) => { remote.delete(id); replicas.remove(id); });
|
|
132
|
+
}
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
**d. The adapter + send loop.** The wire is all-degrees (`*Deg`); the body wants `facingYaw` in radians (the one
|
|
136
|
+
conversion). **Copy `position`/`activeAbilities` BY VALUE** — `p` is a live schema object mutated in place, so
|
|
137
|
+
aliasing it collapses the `NetworkDriver` jitter buffer and the remote snaps instead of interpolating.
|
|
138
|
+
|
|
139
|
+
```ts
|
|
140
|
+
function toReplicatedParams(p: PlayerState): ReplicatedParams {
|
|
141
|
+
return {
|
|
142
|
+
position: { x: p.position.x, y: p.position.y, z: p.position.z }, // copy, don't alias
|
|
143
|
+
facingYaw: (p.facingYawDeg * Math.PI) / 180, // the one unit conversion
|
|
144
|
+
speed: p.speed, moveDirectionDeg: p.moveDirectionDeg, verticalVelocity: p.verticalVelocity,
|
|
145
|
+
grounded: p.grounded, crouched: p.crouched, aimYawDeg: p.aimYawDeg, aimPitchDeg: p.aimPitchDeg,
|
|
146
|
+
activeAbilities: [...p.activeAbilities],
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Build your local wire state from the blackboard (the engine's live param bus) + body, in DEGREES.
|
|
151
|
+
function localState(): ReplicaInput {
|
|
152
|
+
const bb = local.blackboard;
|
|
153
|
+
return {
|
|
154
|
+
position: body.position,
|
|
155
|
+
facingYawDeg: (body.facingYaw * 180) / Math.PI,
|
|
156
|
+
speed: bb.get<number>('speed'), moveDirectionDeg: bb.get<number>('direction'),
|
|
157
|
+
verticalVelocity: bb.get<number>('verticalVelocity'), grounded: bb.get<boolean>('isGrounded'),
|
|
158
|
+
crouched: body.isCrouched, aimYawDeg: bb.get<number>('cameraYaw'), aimPitchDeg: bb.get<number>('cameraPitch'),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const clock = new THREE.Clock();
|
|
163
|
+
renderer.setAnimationLoop(() => {
|
|
164
|
+
const dt = Math.min(clock.getDelta(), 0.1);
|
|
165
|
+
local.update(dt); // local player always runs (single-player safe)
|
|
166
|
+
if (room) {
|
|
167
|
+
for (const [id, player] of remote) replicas.pushSnapshot(id, toReplicatedParams(player));
|
|
168
|
+
replicas.update(dt);
|
|
169
|
+
room.sendState(localState()); // coalesced + throttled to the room rate
|
|
170
|
+
hud.textContent = `${replicas.count + 1} here (you + ${replicas.count})`;
|
|
171
|
+
}
|
|
172
|
+
renderer.render(scene, camera);
|
|
173
|
+
});
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
That's the whole presence surface: build local **unconditionally** → join when logged in → spawn replicas off
|
|
177
|
+
`onAdd` → each frame, snapshot the remotes + `sendState` yourself. **Never send bone transforms** — you replicate
|
|
178
|
+
the *inputs* to the animation system; each remote animates client-side.
|
|
179
|
+
|
|
180
|
+
## 4. Build, validate, publish
|
|
181
|
+
|
|
182
|
+
`npm install` → `helix install` (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.
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# Multiplayer template — `obby`
|
|
2
|
+
|
|
3
|
+
**What it is.** An obstacle course: run/jump across platforms, touch a checkpoint to save your spot, fall off and
|
|
4
|
+
you **respawn at your last checkpoint**, reach the end to win — everyone racing in the same space. **Capability:
|
|
5
|
+
zone checkpoints + the `respawn` effect (the server re-anchors a player) + a fall/kill zone + a finish broadcast.**
|
|
6
|
+
Use this for parkour, races, platformers, time trials. Lifted from the verified `multiplayer-obby` world.
|
|
7
|
+
|
|
8
|
+
> The smallest logic world: presence + a few zones + `respawn`. Grammar:
|
|
9
|
+
> `read_doc({ name: "multiplayer-logic" })` §10 (zones), §8 (respawn/teleport).
|
|
10
|
+
|
|
11
|
+
## 1. DSL used
|
|
12
|
+
|
|
13
|
+
- **Declared state** (§2) — `playerVars.checkpoint` (a **`vec3`**) holds each player's saved respawn point.
|
|
14
|
+
- **Zones** (§10) — **checkpoint** boxes (one per stage), a wide low **`kill`** box under the course (the
|
|
15
|
+
fall-catcher), and a **`finish`** box.
|
|
16
|
+
- **`respawn`** (§8) — a server-authoritative move that **re-anchors the player's move gate** to a `vec3` (use it,
|
|
17
|
+
not `teleport`, for death/reset). The fall zone respawns to the saved `checkpoint`.
|
|
18
|
+
- **Broadcast** (§13) — `win` when someone reaches `finish`.
|
|
19
|
+
- **Rules** (§3) — checkpoint `zoneEnter` saves `self.position` into `self.checkpoint`; `kill` `zoneEnter`
|
|
20
|
+
respawns to `self.checkpoint`; `finish` `zoneEnter` broadcasts `win`.
|
|
21
|
+
|
|
22
|
+
## 2. The manifest — `public/helix.json`
|
|
23
|
+
|
|
24
|
+
```json
|
|
25
|
+
{
|
|
26
|
+
"helixVersion": "0.3",
|
|
27
|
+
"title": "Multiplayer Obby",
|
|
28
|
+
"slug": "obby",
|
|
29
|
+
"entry": "index.html",
|
|
30
|
+
"maxPlayers": 8,
|
|
31
|
+
"permissions": ["auth.profile", "multiplayer"],
|
|
32
|
+
"multiplayer": {
|
|
33
|
+
"authoritative": true,
|
|
34
|
+
"state": { "playerVars": { "checkpoint": { "type": "vec3", "default": [0, 1, 0] } } },
|
|
35
|
+
"zones": [
|
|
36
|
+
{ "id": "cp1", "shape": "box", "center": [10, 0, 0], "size": [4, 4, 4] },
|
|
37
|
+
{ "id": "cp2", "shape": "box", "center": [45, 0, 0], "size": [4, 4, 4] },
|
|
38
|
+
{ "id": "kill", "shape": "box", "center": [0, -10, 0], "size": [200, 2, 200] },
|
|
39
|
+
{ "id": "finish", "shape": "box", "center": [90, 0, 0], "size": [4, 8, 4] }
|
|
40
|
+
],
|
|
41
|
+
"events": { "win": { "payload": { "who": { "type": "ref", "of": "player" } } } },
|
|
42
|
+
"rules": [
|
|
43
|
+
{ "when": { "on": "zoneEnter", "zone": "cp1" }, "then": [{ "do": "set", "target": "self.checkpoint", "to": { "var": "self.position" } }] },
|
|
44
|
+
{ "when": { "on": "zoneEnter", "zone": "cp2" }, "then": [{ "do": "set", "target": "self.checkpoint", "to": { "var": "self.position" } }] },
|
|
45
|
+
{ "when": { "on": "zoneEnter", "zone": "kill" }, "then": [{ "do": "respawn", "player": "self", "to": { "var": "self.checkpoint" } }] },
|
|
46
|
+
{ "when": { "on": "zoneEnter", "zone": "finish" }, "then": [{ "do": "broadcast", "event": "win", "to": "all", "payload": { "who": "self" } }] }
|
|
47
|
+
]
|
|
48
|
+
},
|
|
49
|
+
"supportsMobile": true,
|
|
50
|
+
"contentRating": "everyone",
|
|
51
|
+
"systems": { "humanoid-character": "^0.2" }
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Add a checkpoint zone per stage — each one's rule is the same `set self.checkpoint = self.position`. The fall zone
|
|
56
|
+
is a single huge thin box well below the course; `respawn` re-anchors cleanly (the SDK's reconciler accepts the
|
|
57
|
+
server correction). The platforms themselves are your **client-side geometry** (static colliders on the character
|
|
58
|
+
body) — only the *zones* are declared.
|
|
59
|
+
|
|
60
|
+
## 3. The client — `src/main.ts` (delta from `hangout`)
|
|
61
|
+
|
|
62
|
+
Almost none — presence, a win banner, and **one line to wire respawn**. The respawn is server-authoritative, but to
|
|
63
|
+
land it on your *local* (client-predicted) body you construct a **`LocalReconciler`** once after joining — it
|
|
64
|
+
self-hooks the server's position ack and snaps your body, with no per-frame work. Build the course geometry as
|
|
65
|
+
static colliders on your character body (as in the character recipe), aligned with the declared zone centers.
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
import { LocalReconciler } from '@helix/humanoid-character';
|
|
69
|
+
|
|
70
|
+
// once, after joinRoom() succeeds — `body` is your local character's RapierBody:
|
|
71
|
+
new LocalReconciler({ room, body }); // snaps the local player to the server's anchor on a respawn/teleport ack
|
|
72
|
+
|
|
73
|
+
room.onMessage('win', (m) => showBanner(`🏁 ${nameOf(String(m.who))} finished!`));
|
|
74
|
+
// (otherwise identical to hangout: local player + ReplicaScene + sendState)
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
**Footguns:** use `respawn` (not a client-side teleport) for death; you **must** wire `LocalReconciler` once —
|
|
78
|
+
without it the server re-anchors the seat but your predicted body keeps falling; keep the platform colliders
|
|
79
|
+
aligned with the declared zone centers so checkpoints fire where players land; `checkpoint` is a `vec3` default
|
|
80
|
+
`[0,1,0]` (the start), so a fresh player respawns at spawn.
|
|
81
|
+
|
|
82
|
+
## 4. Build, validate, publish
|
|
83
|
+
|
|
84
|
+
`npm install` → `helix install` → `npm run build` → `validate_world` on `dist/` → `whoami` → `publish_world`.
|