@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.
@@ -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.