@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,98 @@
|
|
|
1
|
+
# Multiplayer template — `team-control`
|
|
2
|
+
|
|
3
|
+
**What it is.** Two teams (red/blue) fight for a hill — **no combat, just presence**: whichever team has more
|
|
4
|
+
players standing on the hill drags a tug-of-war control meter their way; hit the end and they score a point, then
|
|
5
|
+
it resets. **Capability: non-combat teams + zone-presence scoring + a win condition.** Teams are a `playerVar`
|
|
6
|
+
enum; scoring is a per-team `aggregate count` over a zone; the meter is a `varReached` threshold. Use this for
|
|
7
|
+
king-of-the-hill, domination, territory control, capture points, objective races. Lifted from the verified
|
|
8
|
+
`multiplayer-team-control` world.
|
|
9
|
+
|
|
10
|
+
> The competitive scaffold **without** combat (combat templates are deferred to the character pass). Grammar:
|
|
11
|
+
> `read_doc({ name: "multiplayer-logic" })` §2 (vars), §6 (aggregate), §4 (`varReached`).
|
|
12
|
+
|
|
13
|
+
## 1. DSL used
|
|
14
|
+
|
|
15
|
+
- **Teams** (§2/§14) — `playerVars.team` (a `string`, `red`/`blue`); `joinRed`/`joinBlue` actions set it. (You
|
|
16
|
+
could auto-balance on `playerJoin` instead.)
|
|
17
|
+
- **Zone-presence scoring** (§6/§10) — a `hill` zone + `aggregate count … where team == "red"/"blue"` each tick to
|
|
18
|
+
count each team on the hill.
|
|
19
|
+
- **The tug-of-war idiom** — a `control` number in `[-100, 100]`; every 10 ticks the team with more players on the
|
|
20
|
+
hill pushes it ±5 (and sets `holder`).
|
|
21
|
+
- **Win condition** (§4) — `varReached room.control >= 100` → red scores + reset; `<= -100` → blue scores. (Swap
|
|
22
|
+
for a first-to-N + `transitionTo "results"` if you want a match end — see `turn-arena`.)
|
|
23
|
+
|
|
24
|
+
## 2. The manifest — `public/helix.json`
|
|
25
|
+
|
|
26
|
+
```json
|
|
27
|
+
{
|
|
28
|
+
"helixVersion": "0.3",
|
|
29
|
+
"title": "Team Control",
|
|
30
|
+
"slug": "team-control",
|
|
31
|
+
"entry": "index.html",
|
|
32
|
+
"maxPlayers": 8,
|
|
33
|
+
"permissions": ["auth.profile", "multiplayer"],
|
|
34
|
+
"multiplayer": {
|
|
35
|
+
"authoritative": true,
|
|
36
|
+
"state": {
|
|
37
|
+
"roomVars": {
|
|
38
|
+
"redOnHill": { "type": "number", "default": 0 },
|
|
39
|
+
"blueOnHill": { "type": "number", "default": 0 },
|
|
40
|
+
"control": { "type": "number", "default": 0 },
|
|
41
|
+
"redScore": { "type": "number", "default": 0 },
|
|
42
|
+
"blueScore": { "type": "number", "default": 0 },
|
|
43
|
+
"holder": { "type": "string", "default": "none" }
|
|
44
|
+
},
|
|
45
|
+
"playerVars": { "team": { "type": "string", "default": "blue", "enum": ["red", "blue"] } }
|
|
46
|
+
},
|
|
47
|
+
"zones": [{ "id": "hill", "shape": "sphere", "center": [0, 0, 0], "radius": 6 }],
|
|
48
|
+
"states": { "initial": "playing", "phases": ["playing"] },
|
|
49
|
+
"actions": { "joinRed": {}, "joinBlue": {} },
|
|
50
|
+
"rules": [
|
|
51
|
+
{ "when": { "on": "action", "name": "joinRed" }, "then": [{ "do": "set", "target": "self.team", "to": "red" }] },
|
|
52
|
+
{ "when": { "on": "action", "name": "joinBlue" }, "then": [{ "do": "set", "target": "self.team", "to": "blue" }] },
|
|
53
|
+
{
|
|
54
|
+
"when": { "on": "tick", "everyN": 10 },
|
|
55
|
+
"then": [
|
|
56
|
+
{ "do": "set", "target": "room.redOnHill", "to": { "op": "aggregate", "scope": "zone:hill", "agg": "count", "as": "p", "where": { "op": "==", "a": { "ref": "p", "var": "team" }, "b": "red" } } },
|
|
57
|
+
{ "do": "set", "target": "room.blueOnHill", "to": { "op": "aggregate", "scope": "zone:hill", "agg": "count", "as": "p", "where": { "op": "==", "a": { "ref": "p", "var": "team" }, "b": "blue" } } }
|
|
58
|
+
]
|
|
59
|
+
},
|
|
60
|
+
{ "when": { "on": "tick", "everyN": 10 }, "if": { "op": ">", "a": { "var": "room.redOnHill" }, "b": { "var": "room.blueOnHill" } }, "then": [{ "do": "add", "target": "room.control", "by": 5 }, { "do": "set", "target": "room.holder", "to": "red" }] },
|
|
61
|
+
{ "when": { "on": "tick", "everyN": 10 }, "if": { "op": ">", "a": { "var": "room.blueOnHill" }, "b": { "var": "room.redOnHill" } }, "then": [{ "do": "add", "target": "room.control", "by": -5 }, { "do": "set", "target": "room.holder", "to": "blue" }] },
|
|
62
|
+
{ "when": { "on": "varReached", "scope": "room", "var": "control", "cmp": ">=", "value": 100 }, "then": [{ "do": "add", "target": "room.redScore", "by": 1 }, { "do": "set", "target": "room.control", "to": 0 }, { "do": "set", "target": "room.holder", "to": "none" }] },
|
|
63
|
+
{ "when": { "on": "varReached", "scope": "room", "var": "control", "cmp": "<=", "value": -100 }, "then": [{ "do": "add", "target": "room.blueScore", "by": 1 }, { "do": "set", "target": "room.control", "to": 0 }, { "do": "set", "target": "room.holder", "to": "none" }] }
|
|
64
|
+
]
|
|
65
|
+
},
|
|
66
|
+
"supportsMobile": true,
|
|
67
|
+
"contentRating": "everyone",
|
|
68
|
+
"systems": { "humanoid-character": "^0.2" }
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
The whole contest is `aggregate count … where team == X` over the hill + a tug-of-war number — **no hit detection,
|
|
73
|
+
no health**. `varReached` fires once on the edge (false→true), so each point scores exactly once before the reset.
|
|
74
|
+
|
|
75
|
+
## 3. The client — `src/main.ts` (delta from `hangout`)
|
|
76
|
+
|
|
77
|
+
Presence + team selection + a control-meter HUD (read the `roomVars`):
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
addEventListener('keydown', (e) => {
|
|
81
|
+
if (e.key === '1') room.sendAction('joinRed');
|
|
82
|
+
else if (e.key === '2') room.sendAction('joinBlue');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// each frame: tint your character by your team, draw the meter from room.state.roomVars
|
|
86
|
+
const myTeam = String((room.state.players[room.sessionId]?.vars as Record<string, unknown> | undefined)?.team ?? 'blue');
|
|
87
|
+
const control = numRoomVar('control'); // -100 (blue) … +100 (red)
|
|
88
|
+
hud.innerHTML = `Hill: ${numRoomVar('redOnHill')}🔴 vs ${numRoomVar('blueOnHill')}🔵 · control ${control} · ${numRoomVar('redScore')}–${numRoomVar('blueScore')}`;
|
|
89
|
+
drawControlBar(control);
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
**Footguns:** read `roomVars` live each frame (guard until the first patch); team is a server-written `playerVar`
|
|
93
|
+
(the client sends the `joinRed`/`joinBlue` intent, the server sets it); `enum` on `team` makes the validator reject
|
|
94
|
+
any value other than `red`/`blue`.
|
|
95
|
+
|
|
96
|
+
## 4. Build, validate, publish
|
|
97
|
+
|
|
98
|
+
`npm install` → `helix install` → `npm run build` → `validate_world` on `dist/` → `whoami` → `publish_world`.
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# Multiplayer template — `turn-arena`
|
|
2
|
+
|
|
3
|
+
**What it is.** A co-op turn-based siege: players take turns striking a pit of monsters; run out of health or
|
|
4
|
+
surrender and you're eliminated; clear the monsters to win, lose everyone to lose, then reset. **Capability: a state
|
|
5
|
+
machine + turn order + elimination.** Phases (`play → won/defeat`), a **turn queue** (`advanceTurn` over a
|
|
6
|
+
list-of-ref, gated by "is it my turn?"), `eliminate`/`revive`, and broadcast events. Use this for board games,
|
|
7
|
+
co-op boss fights, round-based battlers. Lifted from the verified `multiplayer-turn-arena` world.
|
|
8
|
+
|
|
9
|
+
> A character world (presence) **plus** turn/phase logic. Grammar: `read_doc({ name: "multiplayer-logic" })` §8
|
|
10
|
+
> (turns/elimination), §12 (state machine), §13 (broadcasts), §6 (aggregate).
|
|
11
|
+
|
|
12
|
+
## 1. DSL used
|
|
13
|
+
|
|
14
|
+
- **State machine** (§12) — `states.phases: ["play","won","defeat"]`; `transitionTo` moves between them; rules read
|
|
15
|
+
`room.phase`.
|
|
16
|
+
- **Turn order** (§2/§8) — `roomVars.turnOrder` (a **`list` of player refs**) + `turnIndex`. `advanceTurn` bumps
|
|
17
|
+
the index (skipping eliminated players, wrapping). A rule gates an action to the current player with
|
|
18
|
+
`sameRef(self, listAt(turnOrder, turnIndex))`.
|
|
19
|
+
- **Elimination** (§8) — `eliminate` / `revive`; `forEachPlayer … includeEliminated: true` to reset everyone.
|
|
20
|
+
- **Broadcast events** (§13) — `turnChanged` / `gameOver`, fired with `broadcast` (client reads via `onMessage`).
|
|
21
|
+
- **Aggregate** (§6) — `sum`/`count` over `zone:pit` (monster HP + count), `argmax` over players (the winner by
|
|
22
|
+
health), `playerCount`.
|
|
23
|
+
- **`varReached`** (§4) — entity/self/room scopes drive monster death, elimination, and the win/lose transitions.
|
|
24
|
+
- **Entities + a zone** — `monster` (hp) inside the `pit` zone (`tracks:'entity:monster'`).
|
|
25
|
+
|
|
26
|
+
## 2. The manifest — `public/helix.json`
|
|
27
|
+
|
|
28
|
+
```json
|
|
29
|
+
{
|
|
30
|
+
"helixVersion": "0.3",
|
|
31
|
+
"title": "Turn-Based Siege",
|
|
32
|
+
"slug": "turn-arena",
|
|
33
|
+
"entry": "index.html",
|
|
34
|
+
"maxPlayers": 8,
|
|
35
|
+
"permissions": ["auth.profile", "multiplayer"],
|
|
36
|
+
"multiplayer": {
|
|
37
|
+
"authoritative": true,
|
|
38
|
+
"state": {
|
|
39
|
+
"roomVars": {
|
|
40
|
+
"turnOrder": { "type": "list", "maxLen": 8, "of": { "type": "ref", "of": "player" } },
|
|
41
|
+
"turnIndex": { "type": "number", "default": 0 },
|
|
42
|
+
"monstersSpawned": { "type": "number", "default": 0 },
|
|
43
|
+
"monstersAlive": { "type": "number", "default": 0 },
|
|
44
|
+
"zoneHp": { "type": "number", "default": 0 },
|
|
45
|
+
"alive": { "type": "number", "default": 0 },
|
|
46
|
+
"killTarget": { "type": "number", "default": 0 },
|
|
47
|
+
"winner": { "type": "ref", "of": "player" }
|
|
48
|
+
},
|
|
49
|
+
"playerVars": { "health": { "type": "number", "default": 100 } }
|
|
50
|
+
},
|
|
51
|
+
"entities": { "monster": { "vars": { "hp": { "type": "number", "default": 64 } } } },
|
|
52
|
+
"zones": [{ "id": "pit", "shape": "sphere", "center": [0, 0, 0], "radius": 8, "tracks": "entity:monster" }],
|
|
53
|
+
"states": { "initial": "play", "phases": ["play", "won", "defeat"], "joinPolicy": { "play": { "joinable": true } } },
|
|
54
|
+
"actions": { "strike": {}, "surrender": {}, "reset": {} },
|
|
55
|
+
"events": {
|
|
56
|
+
"turnChanged": { "payload": { "current": { "type": "ref", "of": "player" } } },
|
|
57
|
+
"gameOver": { "payload": { "winner": { "type": "ref", "of": "player" } } }
|
|
58
|
+
},
|
|
59
|
+
"rules": [
|
|
60
|
+
{ "when": { "on": "playerJoin" }, "then": [{ "do": "append", "target": "room.turnOrder", "value": "self" }] },
|
|
61
|
+
{
|
|
62
|
+
"when": { "on": "tick" },
|
|
63
|
+
"if": { "op": "<", "a": { "var": "room.monstersSpawned" }, "b": 6 },
|
|
64
|
+
"then": [
|
|
65
|
+
{ "do": "spawnEntity", "kind": "monster", "at": { "op": "randomPoint", "min": [-5, 0, -5], "max": [5, 0, 5] } },
|
|
66
|
+
{ "do": "add", "target": "room.monstersSpawned", "by": 1 }
|
|
67
|
+
]
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
"when": { "on": "tick" },
|
|
71
|
+
"then": [
|
|
72
|
+
{ "do": "set", "target": "room.zoneHp", "to": { "op": "aggregate", "scope": "zone:pit", "agg": "sum", "field": "hp" } },
|
|
73
|
+
{ "do": "set", "target": "room.monstersAlive", "to": { "op": "aggregate", "scope": "zone:pit", "agg": "count" } },
|
|
74
|
+
{ "do": "set", "target": "room.alive", "to": { "op": "playerCount" } }
|
|
75
|
+
]
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
"when": { "on": "action", "name": "strike" },
|
|
79
|
+
"if": { "op": "sameRef", "a": "self", "b": { "op": "listAt", "list": "room.turnOrder", "index": { "var": "room.turnIndex" } } },
|
|
80
|
+
"then": [
|
|
81
|
+
{ "do": "forEachEntity", "kind": "monster", "as": "m", "then": [{ "do": "add", "target": { "ref": "m", "var": "hp" }, "by": -8 }] },
|
|
82
|
+
{ "do": "add", "target": "self.health", "by": -6 },
|
|
83
|
+
{ "do": "advanceTurn", "order": "room.turnOrder", "index": "room.turnIndex" },
|
|
84
|
+
{ "do": "broadcast", "event": "turnChanged", "to": "all", "payload": { "current": { "op": "listAt", "list": "room.turnOrder", "index": { "var": "room.turnIndex" } } } }
|
|
85
|
+
]
|
|
86
|
+
},
|
|
87
|
+
{ "when": { "on": "varReached", "scope": "entity", "kind": "monster", "var": "hp", "cmp": "<=", "value": { "var": "room.killTarget" } }, "then": [{ "do": "destroyEntity", "entity": "self" }] },
|
|
88
|
+
{ "when": { "on": "action", "name": "surrender" }, "then": [{ "do": "eliminate", "player": "self" }, { "do": "advanceTurn", "order": "room.turnOrder", "index": "room.turnIndex" }] },
|
|
89
|
+
{ "when": { "on": "varReached", "scope": "self", "var": "health", "cmp": "<=", "value": 0 }, "then": [{ "do": "eliminate", "player": "self" }, { "do": "advanceTurn", "order": "room.turnOrder", "index": "room.turnIndex" }] },
|
|
90
|
+
{
|
|
91
|
+
"when": { "on": "varReached", "scope": "room", "var": "monstersAlive", "cmp": "<=", "value": 0 },
|
|
92
|
+
"if": { "op": ">=", "a": { "var": "room.monstersSpawned" }, "b": 6 },
|
|
93
|
+
"then": [
|
|
94
|
+
{ "do": "setRef", "target": "room.winner", "to": { "op": "aggregate", "scope": "players", "agg": "argmax", "field": "health" } },
|
|
95
|
+
{ "do": "broadcast", "event": "gameOver", "to": "all", "payload": { "winner": { "var": "room.winner" } } },
|
|
96
|
+
{ "do": "transitionTo", "phase": "won" }
|
|
97
|
+
]
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
"when": { "on": "varReached", "scope": "room", "var": "alive", "cmp": "<=", "value": 0 },
|
|
101
|
+
"if": { "op": ">=", "a": { "var": "room.monstersSpawned" }, "b": 6 },
|
|
102
|
+
"then": [{ "do": "broadcast", "event": "gameOver", "to": "all", "payload": { "winner": { "var": "room.winner" } } }, { "do": "transitionTo", "phase": "defeat" }]
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
"when": { "on": "action", "name": "reset" },
|
|
106
|
+
"if": { "op": "!=", "a": { "var": "room.phase" }, "b": "play" },
|
|
107
|
+
"then": [
|
|
108
|
+
{ "do": "set", "target": "room.monstersSpawned", "to": 0 },
|
|
109
|
+
{ "do": "setRef", "target": "room.winner", "to": null },
|
|
110
|
+
{ "do": "forEachPlayer", "as": "p", "includeEliminated": true, "then": [{ "do": "revive", "player": "p" }, { "do": "set", "target": { "ref": "p", "var": "health" }, "to": 100 }] },
|
|
111
|
+
{ "do": "transitionTo", "phase": "play" }
|
|
112
|
+
]
|
|
113
|
+
}
|
|
114
|
+
]
|
|
115
|
+
},
|
|
116
|
+
"supportsMobile": true,
|
|
117
|
+
"contentRating": "everyone",
|
|
118
|
+
"systems": { "humanoid-character": "^0.2" }
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
The turn gate is the key idiom: `if sameRef(self, listAt(turnOrder, turnIndex))` — only the current player's
|
|
123
|
+
`strike` applies. `advanceTurn` then rotates (skipping eliminated). Eliminated players stay connected + rendered
|
|
124
|
+
(`active:false`) but drop out of the turn rotation.
|
|
125
|
+
|
|
126
|
+
## 3. The client — `src/main.ts` (delta from `hangout`)
|
|
127
|
+
|
|
128
|
+
Presence + a turn/phase HUD. Read `room.state.phase` + the current player, bind the three actions, and react to
|
|
129
|
+
the broadcasts:
|
|
130
|
+
|
|
131
|
+
```ts
|
|
132
|
+
// whose turn? — read turnOrder[turnIndex] off room.state
|
|
133
|
+
function currentTurnId(): string {
|
|
134
|
+
const order = (room.state.roomVars as { turnOrder?: { length: number; [i: number]: { toString(): string } } } | undefined)?.turnOrder;
|
|
135
|
+
const idx = numRoomVar('turnIndex');
|
|
136
|
+
return order && order.length ? String(order[idx]) : '';
|
|
137
|
+
}
|
|
138
|
+
const myTurn = () => currentTurnId() === room.sessionId;
|
|
139
|
+
|
|
140
|
+
addEventListener('keydown', (e) => {
|
|
141
|
+
if (e.key === ' ' && myTurn()) room.sendAction('strike'); // only on your turn
|
|
142
|
+
else if (e.key === 'x') room.sendAction('surrender');
|
|
143
|
+
else if (e.key === 'r') room.sendAction('reset'); // after won/defeat
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
room.onMessage('turnChanged', (m) => showBanner(`Turn: ${nameOf(String(m.current))}`));
|
|
147
|
+
room.onMessage('gameOver', (m) => showBanner(m.winner ? `🏆 ${nameOf(String(m.winner))} wins` : 'Defeat'));
|
|
148
|
+
|
|
149
|
+
// each frame: render room.state.phase, currentTurnId(), your health.
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
**Footguns:** read `room.state.phase` (a reserved built-in) + `roomVars` (incl. `turnIndex`) live each frame; gate
|
|
153
|
+
`strike` on `myTurn()` client-side for UX, but the **server** re-checks `sameRef` (never trust the client). Refs
|
|
154
|
+
on the wire stringify to a session id — compare with `room.sessionId`.
|
|
155
|
+
|
|
156
|
+
## 4. Build, validate, publish
|
|
157
|
+
|
|
158
|
+
`npm install` → `helix install` → `npm run build` → `validate_world` on `dist/` (watch the cascade depth + the
|
|
159
|
+
per-tick budget — `forEachEntity` × monsters) → `whoami` → `publish_world`.
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# Multiplayer template — `wave-survival`
|
|
2
|
+
|
|
3
|
+
**What it is.** A co-op horde: every few seconds a wave of enemies spawns and chases the players; touch one and you
|
|
4
|
+
take damage; shoot them to clear the wave. **Capability: SHARED (game-owned) entities with DISTRIBUTED,
|
|
5
|
+
host-migrated local-authority AI** — the enemies belong to the *game*, not a player; the server hands **each enemy
|
|
6
|
+
to the least-loaded client at spawn**, so the swarm's simulation **spreads across the whole room** (no single
|
|
7
|
+
machine bears the entire horde), and any enemy whose host drops is **re-elected** to another client (so it never
|
|
8
|
+
stalls). That distribution is exactly why it suits a co-op / Vampire-Survivors-style horde. Lifted from the
|
|
9
|
+
verified `multiplayer-wave-survival-2` world.
|
|
10
|
+
|
|
11
|
+
> **The lesson here is shared-entity host migration + client-driven AI**, *not* combat — the damage/shoot is
|
|
12
|
+
> ordinary rule logic (a contact zone + a validated action), independent of the character system. Read
|
|
13
|
+
> `relic-bearers` first (this builds on `EntityScene`); grammar: `read_doc({ name: "multiplayer-logic" })` §9.
|
|
14
|
+
|
|
15
|
+
## 1. DSL used
|
|
16
|
+
|
|
17
|
+
- **Entities** (§9) — one `enemy` kind, **`shared: true`** + `authority:'owner'` + `ownerLifecycle:'hostMigrate'`:
|
|
18
|
+
each enemy is hosted by the **least-loaded connected client at spawn**, so a swarm of them **distributes across
|
|
19
|
+
the clients** (not all on one host) — and any enemy **re-elects** to another client if its host leaves.
|
|
20
|
+
`idleTimeout: 4` despawns an enemy left hostless for 4 s. `maxSpeed` (required for owner kinds) bounds the upload;
|
|
21
|
+
an **attached zone** damages players on contact.
|
|
22
|
+
- **Timers** (§11) — `wave`, **self-rearming** (a `timerElapsed` rule restarts it) → a steady spawn cadence.
|
|
23
|
+
- **Actions** (§14) — `shoot` (arg `target`: an enemy ref); the server validates range before destroying.
|
|
24
|
+
- **Declared state** (§2) — `roomVars.wave` + `playerVars.health`.
|
|
25
|
+
- **Rules** (§3) — arm the wave timer on `stateEnter`; on `timerElapsed` re-arm **and** (if `aggregate count < 6`)
|
|
26
|
+
`spawnEntity`; enemy-zone `zoneEnter` (binds `self` = the player) subtracts health; `shoot` validates `distance`
|
|
27
|
+
then `destroyEntity`; `varReached self.health <= 0` respawns + heals.
|
|
28
|
+
|
|
29
|
+
## 2. The manifest — `public/helix.json`
|
|
30
|
+
|
|
31
|
+
```json
|
|
32
|
+
{
|
|
33
|
+
"helixVersion": "0.3",
|
|
34
|
+
"title": "Wave Survival",
|
|
35
|
+
"slug": "wave-survival",
|
|
36
|
+
"entry": "index.html",
|
|
37
|
+
"maxPlayers": 8,
|
|
38
|
+
"permissions": ["auth.profile", "multiplayer"],
|
|
39
|
+
"multiplayer": {
|
|
40
|
+
"authoritative": true,
|
|
41
|
+
"state": {
|
|
42
|
+
"roomVars": { "wave": { "type": "number", "default": 0 } },
|
|
43
|
+
"playerVars": { "health": { "type": "number", "default": 100 } }
|
|
44
|
+
},
|
|
45
|
+
"entities": {
|
|
46
|
+
"enemy": {
|
|
47
|
+
"authority": "owner",
|
|
48
|
+
"shared": true,
|
|
49
|
+
"ownerLifecycle": "hostMigrate",
|
|
50
|
+
"idleTimeout": 4,
|
|
51
|
+
"maxSpeed": 6,
|
|
52
|
+
"zone": { "shape": "sphere", "radius": 1.2 }
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
"timers": { "wave": {} },
|
|
56
|
+
"actions": { "shoot": { "args": { "target": { "type": "ref", "of": "entity:enemy" } } } },
|
|
57
|
+
"states": { "initial": "playing", "phases": ["playing"] },
|
|
58
|
+
"rules": [
|
|
59
|
+
{ "when": { "on": "stateEnter", "phase": "playing" }, "then": [{ "do": "startTimer", "timer": "wave", "seconds": 3 }] },
|
|
60
|
+
{ "when": { "on": "timerElapsed", "timer": "wave" }, "then": [{ "do": "startTimer", "timer": "wave", "seconds": 3 }] },
|
|
61
|
+
{
|
|
62
|
+
"when": { "on": "timerElapsed", "timer": "wave" },
|
|
63
|
+
"if": { "op": "<", "a": { "op": "aggregate", "scope": "entities:enemy", "agg": "count" }, "b": 6 },
|
|
64
|
+
"then": [
|
|
65
|
+
{ "do": "spawnEntity", "kind": "enemy", "at": { "vec3": [0, 0, 14] } },
|
|
66
|
+
{ "do": "add", "target": "room.wave", "by": 1 }
|
|
67
|
+
]
|
|
68
|
+
},
|
|
69
|
+
{ "when": { "on": "zoneEnter", "zone": "enemy" }, "then": [{ "do": "add", "target": "self.health", "by": -10 }] },
|
|
70
|
+
{
|
|
71
|
+
"when": { "on": "action", "name": "shoot" },
|
|
72
|
+
"if": { "op": "<", "a": { "op": "distance", "a": { "var": "self.position" }, "b": { "ref": { "var": "action.args.target" }, "var": "position" } }, "b": 50 },
|
|
73
|
+
"then": [{ "do": "destroyEntity", "entity": { "var": "action.args.target" } }]
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
"when": { "on": "varReached", "scope": "self", "var": "health", "cmp": "<=", "value": 0 },
|
|
77
|
+
"then": [
|
|
78
|
+
{ "do": "respawn", "player": "self", "to": { "vec3": [0, 1, 0] } },
|
|
79
|
+
{ "do": "set", "target": "self.health", "to": 100 }
|
|
80
|
+
]
|
|
81
|
+
}
|
|
82
|
+
]
|
|
83
|
+
},
|
|
84
|
+
"supportsMobile": true,
|
|
85
|
+
"contentRating": "everyone",
|
|
86
|
+
"systems": { "humanoid-character": "^0.2" }
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
The two `timerElapsed` rules fire in declared order: the first re-arms `wave`, the second spawns. `shoot` is the
|
|
91
|
+
cheat-resistant input — the **server** checks range, so a client can't destroy an enemy across the map.
|
|
92
|
+
|
|
93
|
+
## 3. The client — `src/main.ts` (delta from `relic-bearers`)
|
|
94
|
+
|
|
95
|
+
Same `EntityScene`, but the entities are **shared**: `EntityScene` runs your `motion` fn only for enemies **you
|
|
96
|
+
host** (`controller === sessionId`), interpolates the rest, and freezes orphans (`controller === ''`) until the
|
|
97
|
+
server re-elects a host. The motion fn is the **AI** — chase the nearest player:
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
import { EntityScene } from '@helix/humanoid-character';
|
|
101
|
+
const ENEMY_MAX_SPEED = 6; // MUST mirror the DSL maxSpeed (reconcile clamp)
|
|
102
|
+
|
|
103
|
+
const entities = new EntityScene({
|
|
104
|
+
room,
|
|
105
|
+
maxSpeed: { enemy: ENEMY_MAX_SPEED },
|
|
106
|
+
motion: {
|
|
107
|
+
// Chase the nearest player. ctx.seekNearest(speed, stopAt) returns the gate-safe next position: it caps the
|
|
108
|
+
// per-frame step internally (≤ speed × dt) so it stays inside the reconcile clamp, and stops ~0.9m short.
|
|
109
|
+
// Runs only for enemies YOU host. (ctx also has nearestPlayer(from): Vec3 | null if you want the raw target.)
|
|
110
|
+
enemy: (_e, _dt, ctx) => ctx.seekNearest(ENEMY_MAX_SPEED * 0.83, 0.9),
|
|
111
|
+
},
|
|
112
|
+
build: () => {
|
|
113
|
+
const mesh = makeEnemy(); scene.add(mesh);
|
|
114
|
+
return {
|
|
115
|
+
object3d: mesh,
|
|
116
|
+
onUpdate: (e) => { mesh.visible = true; tintByController(mesh, e.controller, room!.sessionId); }, // host / orphan tint
|
|
117
|
+
dispose: () => scene.remove(mesh),
|
|
118
|
+
};
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
// frame loop (inside `if (room)`): entities.update(dt);
|
|
122
|
+
|
|
123
|
+
// Shoot the nearest enemy in front of you — client picks the id, server validates range + destroys:
|
|
124
|
+
function shootNearest() {
|
|
125
|
+
let best = '', bestD = Infinity;
|
|
126
|
+
for (const { id, object3d, state } of entities.entries()) {
|
|
127
|
+
if (state.kind !== 'enemy') continue;
|
|
128
|
+
const d2 = object3d.position.distanceToSquared(body.position);
|
|
129
|
+
if (d2 < bestD) { bestD = d2; best = id; }
|
|
130
|
+
}
|
|
131
|
+
if (best) room.sendAction('shoot', { target: best });
|
|
132
|
+
}
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Read your `health` off `room.state.players[sessionId].vars.health`. **Footguns:** mirror `maxSpeed`; keep each
|
|
136
|
+
motion step `≤ maxSpeed × 0.83 × dt`; you won't host every enemy (shared) — never assume you control one, check
|
|
137
|
+
`e.controller`.
|
|
138
|
+
|
|
139
|
+
## 4. Build, validate, publish
|
|
140
|
+
|
|
141
|
+
`npm install` → `helix install` → `npm run build` → `validate_world` on `dist/` → `whoami` → `publish_world`.
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
# HELIX Instant — Multiplayer (the hub)
|
|
2
|
+
|
|
3
|
+
Build worlds where players share a space **and** a game. HELIX multiplayer is **server-authoritative and
|
|
4
|
+
declarative**: a platform-owned room holds the shared state and runs your rules — **you write no server code.**
|
|
5
|
+
Every multiplayer world builds on the **`humanoid-character`** system (read `get_started({ kind: "character" })`
|
|
6
|
+
first — same project layout, build config, loading screen, character config) plus **`@hypersoniclabs/helix-sdk`**
|
|
7
|
+
for `Helix.multiplayer`.
|
|
8
|
+
|
|
9
|
+
> **Discover versions first.** The replica primitives (`ReplicaScene`, `NetworkDriver`, `ReplicaBody`) ship inside
|
|
10
|
+
> the **`humanoid-character`** system; `Helix.multiplayer` is in **`@hypersoniclabs/helix-sdk`**. Run
|
|
11
|
+
> `get_package_manifest("humanoid-character")` + `check_for_updates` before you build — if a pinned version
|
|
12
|
+
> predates multiplayer, those imports won't exist; don't guess around it.
|
|
13
|
+
|
|
14
|
+
## The model — five facts
|
|
15
|
+
|
|
16
|
+
1. **The server is authoritative and platform-owned.** A generic room (the "HelixRoom") holds shared state and
|
|
17
|
+
validates input. **There is no server file in your project** — you opt in via the manifest; the platform runs
|
|
18
|
+
the room.
|
|
19
|
+
2. **Multiplayer is declarative — game logic is DATA.** You declare state + a `when`/`if`/`then` rule pipeline in
|
|
20
|
+
your `helix.json` `multiplayer` block; the room interprets it at a fixed **20 Hz**. The full grammar is its own
|
|
21
|
+
reference: **`read_doc({ name: "multiplayer-logic" })`.**
|
|
22
|
+
3. **Two layers.** (a) **Presence** — players see each other move/animate (the on-ramp below; this alone is a full
|
|
23
|
+
hangout). (b) **Declarative game logic** — shared scores/teams/state, server- and client-hosted entities, zones,
|
|
24
|
+
timers, a state machine, collections, ownership. Most worlds are presence **plus** some logic.
|
|
25
|
+
4. **Login required; single-player must still work.** Set up your local player + scene **unconditionally**, then
|
|
26
|
+
*layer* multiplayer on only when a room join succeeds (**the golden rule** — the standalone/guest path never
|
|
27
|
+
breaks).
|
|
28
|
+
5. **`maxPlayers` is a platform-enforced cap.** The room locks at `maxPlayers` and spills extras into a fresh
|
|
29
|
+
instance; the platform also clamps it to its per-room ceiling. Keep it modest (2–8 is the sweet spot;
|
|
30
|
+
`validate_world` warns if you exceed the cap). Note: the `uploadHz: 20` fast tier caps `maxPlayers` at **12**.
|
|
31
|
+
|
|
32
|
+
## Opt in — the manifest (`public/helix.json`, v0.3)
|
|
33
|
+
|
|
34
|
+
Bump `helixVersion` to `"0.3"`, set `maxPlayers > 1`, and add the **`multiplayer`** permission (keep `auth.profile`
|
|
35
|
+
— you need the player identity). Cross-field rules are enforced at validate/publish: `maxPlayers > 1` **requires**
|
|
36
|
+
`multiplayer`, and `multiplayer` **forces** `requiresAuth: true` (coerced for you).
|
|
37
|
+
|
|
38
|
+
```json
|
|
39
|
+
{
|
|
40
|
+
"helixVersion": "0.3",
|
|
41
|
+
"title": "My Hangout",
|
|
42
|
+
"slug": "my-hangout",
|
|
43
|
+
"entry": "index.html",
|
|
44
|
+
"maxPlayers": 8,
|
|
45
|
+
"permissions": ["auth.profile", "multiplayer"],
|
|
46
|
+
"multiplayer": { "authoritative": true },
|
|
47
|
+
"supportsMobile": true,
|
|
48
|
+
"contentRating": "everyone",
|
|
49
|
+
"systems": { "humanoid-character": "^0.2" }
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
The `multiplayer` block above is the **presence minimum**: `authoritative` (leave `true`) + an optional advisory
|
|
54
|
+
`interestRadius`. The same block also carries the full declarative game-logic surface (`state`, `rules`,
|
|
55
|
+
`entities`, `zones`, `timers`, `states`, `events`, `actions` — see `multiplayer-logic`), and an optional
|
|
56
|
+
`uploadHz: 20` (the fast client→server tier for twitchy/physics worlds; default is 10, and 20 caps `maxPlayers`
|
|
57
|
+
at 12). There is **no `tickRate` knob** — the platform owns the room tick.
|
|
58
|
+
|
|
59
|
+
> **Two independent version axes — don't try to "align" them.** `helixVersion: "0.3"` is the **manifest schema**
|
|
60
|
+
> version; `@hypersoniclabs/helix-sdk` is an **npm package** on its own line. A mismatch in the numbers is
|
|
61
|
+
> expected, not a bug.
|
|
62
|
+
|
|
63
|
+
## Pick your starting point — the template index
|
|
64
|
+
|
|
65
|
+
Every multiplayer world is **presence + (optionally) declarative game logic.** Find the closest template, read it
|
|
66
|
+
with **`read_template({ name })`**, and adapt it — each template is a complete, publish-shaped world that links
|
|
67
|
+
into `multiplayer-logic` per construct. Templates 2→7 walk the full entity-**authority** spectrum (static →
|
|
68
|
+
server-moving → client-owned → shared → physics), which is the most footgun-prone area — skim them in order.
|
|
69
|
+
|
|
70
|
+
Each template's §3 client code is a **delta** from the presence on-ramp below; unshown helpers (`makeMesh()`,
|
|
71
|
+
`showBanner()`, `numRoomVar()`, your `body`/`scene`) are **your own code**, not SDK APIs.
|
|
72
|
+
|
|
73
|
+
| Building… | Template | What it teaches |
|
|
74
|
+
|---|---|---|
|
|
75
|
+
| a hangout / social space / co-op room — players just see each other | `hangout` | **presence**: join flow, `ReplicaScene`/`NetworkDriver` replicas, `sendState`. The substrate every other template builds on (it's the on-ramp below). |
|
|
76
|
+
| a collectible / scavenger world — grab items for points | `collect-a-thon` | declared room/player state + server-spawned **static** pickups (`spawnEntity`) + zones + scoring. |
|
|
77
|
+
| 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
|
+
| 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
|
+
| co-op survival / horde / Vampire-Survivors-style enemy swarms | `wave-survival` | **shared** game-owned entities **distributed** across clients (each enemy hosted by the least-loaded client, re-elected if its host leaves) — the swarm's sim spreads across the room — plus timed waves. |
|
|
80
|
+
| a ball/puck sport — soccer, hockey, pool | `physics-football` | networked physics, **authority-transfer**: one shared dynamic body whose control hands off to whoever touches it (`claimOnContact`) and reverts to the server at rest. |
|
|
81
|
+
| bumper cars / derby / sumo — players **are** physics bodies | `physics-bumper` | networked physics, **dual-sim**: each player owns their own body and collisions reconcile favor-local, on the `uploadHz: 20` fast tier (so `maxPlayers ≤ 12`). |
|
|
82
|
+
| a card game / hand / inventory / crafting | `collections` | structured **collections** — lists of records (a hand of cards), counterMaps, `forEachInList`, `append`/`removeWhere`. |
|
|
83
|
+
| a turn-based / board game / co-op boss fight | `turn-arena` | a **state machine** (play → win/lose) + **turn order** (`advanceTurn`) + **elimination**. |
|
|
84
|
+
| 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
|
+
| an obstacle course / parkour / race | `obby` | ordered zone **checkpoints** + **respawn**-to-checkpoint on a fall + a per-player saved checkpoint + a finish line. |
|
|
86
|
+
| king-of-the-hill / domination / territory / capture points | `team-control` | **teams** (a player var) + zone-**presence** scoring (red vs blue on the hill) + a tug-of-war capture/scoring loop — **no combat**. |
|
|
87
|
+
|
|
88
|
+
**Combat is not yet a template** (shooting, melee, tag, capture-the-flag, tower defense) — it depends on
|
|
89
|
+
character abilities still in progress. Until then, players affect each other through **declared `actions`**
|
|
90
|
+
(server-validated, the cheat-resistant path) — see `multiplayer-logic` §14 + the cross-player firewall in §17.
|
|
91
|
+
|
|
92
|
+
**Nothing fits exactly?** Most real worlds are a blend (e.g. a collect-a-thon with teams, or a turn game with
|
|
93
|
+
physics). Templates are starting points, not a menu — read `read_doc({ name: "multiplayer-logic" })` and compose
|
|
94
|
+
from the primitives.
|
|
95
|
+
|
|
96
|
+
## The presence on-ramp — every multiplayer world starts here
|
|
97
|
+
|
|
98
|
+
Presence is the universal substrate (and the whole of the `hangout` template). The pattern: build your local
|
|
99
|
+
player **unconditionally**, then join + render remotes only `if (room)`.
|
|
100
|
+
|
|
101
|
+
**1 — Init first, then join (guarded so guests/standalone fall back to single-player).** Call `Helix.init()`
|
|
102
|
+
(and settle login) BEFORE loading character assets: the body is bind-once, and every player renders their
|
|
103
|
+
equipped **universal avatar** — resolved at load time (see the `hangout` template §3a for the full body-selection
|
|
104
|
+
code; the room replicates each player's `avatarUrl`, `''` = default body):
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
import { Helix } from '@hypersoniclabs/helix-sdk';
|
|
108
|
+
import type { HelixRoom, PlayerState, ReplicaInput } from '@hypersoniclabs/helix-sdk';
|
|
109
|
+
|
|
110
|
+
const { embedded, user } = await Helix.init();
|
|
111
|
+
if (embedded && !user) {
|
|
112
|
+
try { await Helix.auth.requestLogin(); } catch { /* declined → guest, default body, single-player */ }
|
|
113
|
+
}
|
|
114
|
+
// … load assets + build the local player (character recipe / hangout §3a: their universal avatar) …
|
|
115
|
+
|
|
116
|
+
let room: HelixRoom | null = null;
|
|
117
|
+
if (embedded) {
|
|
118
|
+
try {
|
|
119
|
+
room = await Helix.multiplayer.joinRoom(); // defaults to the current world; resolves + connects
|
|
120
|
+
} catch (err) {
|
|
121
|
+
console.info('multiplayer unavailable — running single-player:', err);
|
|
122
|
+
room = null;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
The `HelixRoom` handle (Colyseus, re-exposed under `Helix.*` — you never import the Colyseus client):
|
|
128
|
+
`room.state` (authoritative shared state: `players`, plus `entities` once you declare them) · `room.sessionId`
|
|
129
|
+
(**your** id — skip it in `onAdd`) · `room.onAdd('players', …)` / `onRemove` (fires for present players too) ·
|
|
130
|
+
`room.sendState(input)` (your per-frame state, throttled + seq-tagged for you) · `room.sendAbility(id, active)` ·
|
|
131
|
+
`room.sendAction(name, args)` (declared actions — see `multiplayer-logic`) · `room.onStateChange` /
|
|
132
|
+
`onMessage(type, cb)` (broadcasts) / `leave` · `room.onDrop` / `onReconnect` / `onLeave` (SDK auto-reconnects).
|
|
133
|
+
|
|
134
|
+
> Player objects from `onAdd` are **live references** Colyseus mutates in place each patch — stash them and read
|
|
135
|
+
> per frame. Don't iterate `room.state.players` as a plain object (it's a `MapSchema`, not a `Record`).
|
|
136
|
+
|
|
137
|
+
**2 — Render remotes with `ReplicaScene` + `NetworkDriver`** (each remote is a headless `Character` driven off
|
|
138
|
+
the wire, wearing **their** avatar via the shared `AvatarModelCache`; the fallback clone source is the DEFAULT
|
|
139
|
+
body — see hangout §3a for `io`/`avatarCache`/`baseModel`):
|
|
140
|
+
|
|
141
|
+
```ts
|
|
142
|
+
import { clone as cloneSkinned } from 'three/examples/jsm/utils/SkeletonUtils.js';
|
|
143
|
+
import { Character, LocomotionAbility, NetworkDriver, ReplicaBody, ReplicaScene,
|
|
144
|
+
type ReplicaHandle, type ReplicatedParams } from '@helix/humanoid-character';
|
|
145
|
+
|
|
146
|
+
const remote = new Map<string, PlayerState>();
|
|
147
|
+
|
|
148
|
+
async function buildReplica(id: string): Promise<ReplicaHandle> {
|
|
149
|
+
const avatarUrl = remote.get(id)?.avatarUrl; // room-replicated, backend-resolved ('' = none)
|
|
150
|
+
const model = (avatarUrl ? await avatarCache.load(avatarUrl) : null)?.model ?? cloneSkinned(baseModel);
|
|
151
|
+
scene.add(model);
|
|
152
|
+
const rbody = new ReplicaBody(SPAWN); // inert, no-physics body — pose comes from the wire
|
|
153
|
+
const character = await Character.create({ model, body: rbody });
|
|
154
|
+
character.abilities.register(new LocomotionAbility(clips)); // same anim graph as the local player
|
|
155
|
+
const driver = new NetworkDriver({ body: rbody, blackboard: character.blackboard });
|
|
156
|
+
character.setDriver(driver);
|
|
157
|
+
return { pushSnapshot: (p) => driver.pushSnapshot(p), update: (dt) => character.update(dt),
|
|
158
|
+
dispose: () => { character.dispose(); scene.remove(model); } };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const replicas = new ReplicaScene({ build: buildReplica, maxReplicas: 8 }); // maxReplicas is a real perf budget
|
|
162
|
+
if (room) {
|
|
163
|
+
room.onAdd('players', (player, id) => {
|
|
164
|
+
if (id === room!.sessionId) return; // that's me — I render my own local player
|
|
165
|
+
remote.set(id, player); replicas.add(id);
|
|
166
|
+
});
|
|
167
|
+
room.onRemove('players', (_p, id) => { remote.delete(id); replicas.remove(id); });
|
|
168
|
+
}
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
**3 — The adapter + send loop.** The wire is all-degrees (`*Deg`); the body wants `facingYaw` in radians (the one
|
|
172
|
+
conversion). **Copy `position`/`activeAbilities` BY VALUE** — `p` is a live schema object mutated in place, so
|
|
173
|
+
passing it by reference collapses the jitter buffer and the remote snaps instead of interpolating.
|
|
174
|
+
|
|
175
|
+
```ts
|
|
176
|
+
function toReplicatedParams(p: PlayerState): ReplicatedParams {
|
|
177
|
+
return {
|
|
178
|
+
position: { x: p.position.x, y: p.position.y, z: p.position.z }, // copy, don't alias
|
|
179
|
+
facingYaw: (p.facingYawDeg * Math.PI) / 180, // the one unit conversion
|
|
180
|
+
speed: p.speed, moveDirectionDeg: p.moveDirectionDeg, verticalVelocity: p.verticalVelocity,
|
|
181
|
+
grounded: p.grounded, crouched: p.crouched, aimYawDeg: p.aimYawDeg, aimPitchDeg: p.aimPitchDeg,
|
|
182
|
+
activeAbilities: [...p.activeAbilities],
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const clock = new THREE.Clock();
|
|
187
|
+
renderer.setAnimationLoop(() => {
|
|
188
|
+
const dt = Math.min(clock.getDelta(), 0.1);
|
|
189
|
+
local.update(dt); // local player always runs (single-player safe)
|
|
190
|
+
if (room) {
|
|
191
|
+
for (const [id, player] of remote) replicas.pushSnapshot(id, toReplicatedParams(player));
|
|
192
|
+
replicas.update(dt);
|
|
193
|
+
room.sendState(localState()); // build from the blackboard in DEGREES; throttled for you
|
|
194
|
+
hud.textContent = `${replicas.count + 1} here (you + ${replicas.count})`;
|
|
195
|
+
}
|
|
196
|
+
renderer.render(scene, camera);
|
|
197
|
+
});
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
`localState()` reads your live params off the blackboard (`speed`, `direction`, `cameraYaw`/`cameraPitch` as
|
|
201
|
+
`aimYawDeg`/`aimPitchDeg`, `grounded`, …) + the body, in **degrees**. **Never send bone transforms** — you
|
|
202
|
+
replicate the *inputs* to the animation system; each remote animates client-side. The complete runnable version
|
|
203
|
+
is the **`hangout` template** (`read_template({ name: "hangout" })`); use it as the reference `src/main.ts`.
|
|
204
|
+
|
|
205
|
+
## Beyond presence — the declarative game logic
|
|
206
|
+
|
|
207
|
+
When the world needs shared state (scores, teams), server/owned objects, zones, timers, a state machine,
|
|
208
|
+
collections, or ownership, declare them in the `multiplayer` block and read the reference:
|
|
209
|
+
**`read_doc({ name: "multiplayer-logic" })`.** Then start from whichever template above is closest. You still write
|
|
210
|
+
no server code — declared rules run on the platform room; clients read `room.state` and send `sendAction` /
|
|
211
|
+
`sendState`.
|
|
212
|
+
|
|
213
|
+
## Build, validate, publish
|
|
214
|
+
|
|
215
|
+
Same as the character recipe §9: `npm install` → `helix install` (resolves the system pin into `helix_modules/`)
|
|
216
|
+
→ `npm run build` → `validate_world` on `dist/` (fix every problem; **heed the `maxPlayers` clamp warning**) →
|
|
217
|
+
`whoami` → `publish_world`. The build contains no `.glb`/`.ktx2` — character assets stream from the CDN.
|
|
218
|
+
|
|
219
|
+
## The rules that matter (multiplayer)
|
|
220
|
+
|
|
221
|
+
- **The server is authoritative and platform-owned.** You send input (`sendState`, `sendAction`); the room owns
|
|
222
|
+
the truth. **No server code** — multiplayer is declarative (the manifest opts in; game logic is data).
|
|
223
|
+
- **Replicate PARAMETERS, not bones.** Position/facing/speed/anim-inputs go on the wire; animation runs
|
|
224
|
+
client-side. Never send skeletons.
|
|
225
|
+
- **Login required; single-player must still work.** Build local + scene unconditionally; layer multiplayer on
|
|
226
|
+
only when `joinRoom` succeeds.
|
|
227
|
+
- **Skip your own `sessionId`** in `onAdd` — the server includes you in `players`; render your own local player.
|
|
228
|
+
- **Copy live schema values by value** before caching them (don't alias) — the #1 presence bug.
|
|
229
|
+
- **`maxReplicas` is a real budget** (each replica is a full character) — cap it to `maxPlayers`.
|
|
230
|
+
- **For shared game state, read `multiplayer-logic`** and start from the closest template — don't invent a wire
|
|
231
|
+
format; declare it.
|