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