@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,153 @@
1
+ # Multiplayer template — `physics-bumper`
2
+
3
+ **What it is.** Each player drives a physics **car** and bashes into everyone else's. **Capability: networked
4
+ physics — the DUAL-SIM model.** Each player **permanently owns their own body** (`transferPolicy:'fixed'` — you
5
+ never lose your car); on contact both clients **simulate both cars and reconcile favor-local** (`dualSimOnContact`)
6
+ so ramming feels instant on both screens. Runs on the **`uploadHz: 20`** fast tier. Use this when **every player is
7
+ a physics body** (bumper cars, derby, sumo). Lifted from the verified `multiplayer-bumper-cars-20hz` world.
8
+
9
+ > Two things differ from the other templates: (1) the **player IS the car** — a physics entity, **no humanoid
10
+ > avatar** (you still pin `humanoid-character` for the physics + networking primitives). (2) `uploadHz: 20`
11
+ > **caps `maxPlayers` at 12** (publish enforces it). Read `physics-football` first (shared `SharedPhysicsWorld` +
12
+ > `EntityScene` physics). Grammar: `read_doc({ name: "multiplayer-logic" })` §9 + physics.
13
+
14
+ ## 1. DSL used
15
+
16
+ - **`uploadHz: 20`** (§1) — the fast client→server tier for twitchy physics. **Hard rule: `maxPlayers ≤ 12`** (the
17
+ validator rejects more). Default is 10; opt to 20 only when motion is fast *and* the room is small.
18
+ - **A dual-sim `physics` entity** (§9) — `car` is `authority:'owner'` + `maxSpeed` + a `physics` block with
19
+ **`dualSimOnContact: true`**. **Cross-field rule:** `dualSimOnContact` **requires `transferPolicy:'fixed'`**
20
+ (the default — a controlled body never transfers). `ownerLifecycle:'despawnWithOwner'` removes a player's car
21
+ when they leave.
22
+ - **(Reused) an authority-transfer `physics` entity** — `puck` is the `physics-football` pattern
23
+ (`claimOnContact` + `revertOnRest` + `transferPolicy:'takeover'`): one shared object anyone can grab. A world can
24
+ mix both physics models.
25
+ - **Declared state** (§2) — `playerVars.car` (a ref to my car) + `bumps`; `roomVars.puckRef`.
26
+ - **Rules** (§3) — `playerJoin` spawns a `car` and stores it in `self.car`; `tick` keeps one `puck` alive; the
27
+ `claim` action takes over the puck.
28
+
29
+ ## 2. The manifest — `public/helix.json`
30
+
31
+ ```json
32
+ {
33
+ "helixVersion": "0.3",
34
+ "title": "Bumper Cars",
35
+ "slug": "bumper-cars",
36
+ "entry": "index.html",
37
+ "maxPlayers": 8,
38
+ "permissions": ["auth.profile", "multiplayer"],
39
+ "multiplayer": {
40
+ "authoritative": true,
41
+ "uploadHz": 20,
42
+ "state": {
43
+ "roomVars": { "puckRef": { "type": "ref", "of": "entity:puck" } },
44
+ "playerVars": { "car": { "type": "ref", "of": "entity:car" }, "bumps": { "type": "number", "default": 0 } }
45
+ },
46
+ "entities": {
47
+ "car": {
48
+ "authority": "owner",
49
+ "maxSpeed": 20,
50
+ "ownerLifecycle": "despawnWithOwner",
51
+ "physics": {
52
+ "bodyType": "dynamic",
53
+ "shape": { "type": "box", "halfExtents": [0.9, 0.5, 1.6] },
54
+ "mass": 4, "restitution": 0.8, "friction": 0.7,
55
+ "dualSimOnContact": true
56
+ }
57
+ },
58
+ "puck": {
59
+ "authority": "owner",
60
+ "maxSpeed": 20,
61
+ "transferPolicy": "takeover",
62
+ "physics": {
63
+ "bodyType": "dynamic",
64
+ "shape": { "type": "box", "halfExtents": [0.9, 0.5, 1.6] },
65
+ "mass": 4, "restitution": 0.8, "friction": 0.7,
66
+ "claimOnContact": true, "revertOnRest": true
67
+ }
68
+ }
69
+ },
70
+ "states": { "initial": "play", "phases": ["play"], "joinPolicy": { "play": { "joinable": true } } },
71
+ "actions": { "claim": { "args": { "target": { "type": "ref", "of": "entity:puck" } } } },
72
+ "rules": [
73
+ {
74
+ "when": { "on": "playerJoin" },
75
+ "then": [
76
+ { "do": "spawnEntity", "kind": "car", "at": { "op": "randomPoint", "min": [-6, 0.5, -6], "max": [6, 0.5, 6] }, "bind": "spawned" },
77
+ { "do": "setRef", "target": "self.car", "to": "spawned" }
78
+ ]
79
+ },
80
+ { "when": { "on": "action", "name": "claim" }, "then": [{ "do": "takeover", "entity": { "var": "action.args.target" }, "to": "self" }] },
81
+ {
82
+ "when": { "on": "tick" },
83
+ "if": { "op": "<", "a": { "op": "aggregate", "scope": "entities:puck", "agg": "count" }, "b": 1 },
84
+ "then": [
85
+ { "do": "spawnEntity", "kind": "puck", "at": { "vec3": [0, 0.5, 0] }, "bind": "spawned" },
86
+ { "do": "setRef", "target": "room.puckRef", "to": "spawned" }
87
+ ]
88
+ }
89
+ ]
90
+ },
91
+ "supportsMobile": true,
92
+ "contentRating": "everyone",
93
+ "systems": { "humanoid-character": "^0.2" }
94
+ }
95
+ ```
96
+
97
+ ## 3. The client — `src/main.ts` (delta from `physics-football`)
98
+
99
+ Same `SharedPhysicsWorld` + `EntityScene` physics build (a `DynamicBody` per car/puck). New for dual-sim: a
100
+ **`DualSimController`** bound to *your* car — each frame it flips the dual-sim flag on peer cars you're touching so
101
+ they're locally predicted + reconciled. You **drive** your car directly with a mass-aware impulse (the player IS the
102
+ car — no humanoid `Character`/`ReplicaScene`):
103
+
104
+ ```ts
105
+ import { EntityScene, SharedPhysicsWorld, DynamicBody, DualSimController, type EntityHandle } from '@helix/humanoid-character';
106
+ const CAR_MAX_SPEED = 20, CAR_MASS = 4, ACCEL = 26; // mirror the DSL maxSpeed + mass
107
+
108
+ const shared = await SharedPhysicsWorld.create();
109
+ const carBodies = new Map<string, DynamicBody>(); // entity id → its DynamicBody
110
+ const entities = new EntityScene({
111
+ room, maxSpeed: { car: CAR_MAX_SPEED, puck: CAR_MAX_SPEED },
112
+ build: (kind, id): EntityHandle => {
113
+ const dyn = new DynamicBody(shared,
114
+ { id, shape: { type: 'box', halfExtents: [0.9, 0.5, 1.6] }, mass: CAR_MASS, restitution: 0.8, friction: 0.7,
115
+ linearDamping: 0.6, angularDamping: 0.8, ccd: true,
116
+ enabledRotations: [false, true, false], // yaw-only: the car stays flat, only spins about Y
117
+ proximityMargin: 0.6 }, // REQUIRED for dual-sim — the engagement sensor; OMIT IT AND DUAL-SIM NEVER FIRES
118
+ { mode: 'remote', maxSpeed: CAR_MAX_SPEED, maxAngularSpeed: 8 });
119
+ carBodies.set(id, dyn);
120
+ return { object3d: makeCarMesh(kind), physics: dyn, onUpdate: () => {}, dispose: () => { dyn.dispose(); carBodies.delete(id); } };
121
+ },
122
+ });
123
+
124
+ let dualSim: DualSimController | null = null, dualSimId: string | null = null;
125
+ // frame loop:
126
+ let myId: string | null = null; const others: DynamicBody[] = [];
127
+ for (const { id, state } of entities.entries()) {
128
+ if (state.kind !== 'car') continue;
129
+ if (state.controller === room.sessionId) myId = id; else { const b = carBodies.get(id); if (b) others.push(b); }
130
+ }
131
+ const mine = myId ? carBodies.get(myId) : undefined;
132
+ if (mine) { // WASD → mass-aware impulse (consistent accel regardless of mass)
133
+ // `keys` is your own Set<string> filled from keydown/keyup; screen-relative (the camera looks down −Z)
134
+ const dx = (keys.has('d') ? 1 : 0) - (keys.has('a') ? 1 : 0);
135
+ const dz = (keys.has('s') ? 1 : 0) - (keys.has('w') ? 1 : 0);
136
+ if (dx || dz) mine.applyImpulse({ x: dx * ACCEL * CAR_MASS * dt, y: 0, z: dz * ACCEL * CAR_MASS * dt });
137
+ }
138
+ if (myId && dualSimId !== myId) { dualSim = new DualSimController(shared, myId, entities); dualSimId = myId; }
139
+ shared.step(dt); // ORDER MATTERS: step → dualSim.update (reads fresh contacts) → entities.update (renders the flag)
140
+ dualSim?.update(others);
141
+ entities.update(dt);
142
+ room.sendState(carPose(mine)); // the player IS the car: carPose() = a ReplicaInput from the car body's transform (no humanoid)
143
+ ```
144
+
145
+ **Footguns:** the car body **must** declare `proximityMargin` (the dual-sim engagement sensor) — omit it and dual-sim
146
+ silently never fires; mirror `maxSpeed`/`mass`/`shape`; keep `maxPlayers ≤ 12` (uploadHz 20); the step →
147
+ `dualSim.update` → `entities.update` order is load-bearing; one `DualSimController` bound to *your* car (rebind if
148
+ your car id changes); `carPose(mine)`, `wasdDir`/`keys`, and `makeCarMesh` are your own helpers, not SDK APIs.
149
+
150
+ ## 4. Build, validate, publish
151
+
152
+ `npm install` → `helix install` → `npm run build` → `validate_world` on `dist/` (enforces `dualSimOnContact`
153
+ requires `transferPolicy:'fixed'`, and `uploadHz:20` ⇒ `maxPlayers ≤ 12`) → `whoami` → `publish_world`.
@@ -0,0 +1,133 @@
1
+ # Multiplayer template — `physics-football`
2
+
3
+ **What it is.** A shared ball with real physics: players bump it around an arena and into goals to score.
4
+ **Capability: networked physics — the AUTHORITY-TRANSFER model.** One **shared dynamic body** (the ball) sits
5
+ server-held between touches; the instant a player contacts it the server **auto-hands control to them**
6
+ (`claimOnContact`), that client simulates the collision locally, and when the ball comes to rest it **reverts to
7
+ the server** (`revertOnRest`). Use this for **one contested object** (soccer, hockey, pool, air-hockey). Lifted from
8
+ the verified `multiplayer-football` world (contract v16).
9
+
10
+ > Networked physics is the newest, churniest part of the platform — lift this verbatim. The *other* physics model
11
+ > (each player owns their own body) is `physics-bumper`. Grammar: `read_doc({ name: "multiplayer-logic" })` §9 +
12
+ > the physics section.
13
+
14
+ ## 1. DSL used
15
+
16
+ - **A `physics` entity** (§9) — `ball` is `authority:'owner'` + `maxSpeed` + `transferPolicy:'takeover'` + a
17
+ **`physics` block**: `bodyType:'dynamic'`, a `shape` (sphere/box/capsule), and material (`mass`, `restitution`,
18
+ `friction`, optional `linearDamping`/`angularDamping`). The two arbitration flags: **`claimOnContact: true`**
19
+ (server auto-takes-over the body to whoever touches it — **requires `transferPolicy:'takeover'`**) and
20
+ **`revertOnRest: true`** (release to the server when at rest). Validator rules: a `physics` block **requires**
21
+ `authority:'owner'`; `claimOnContact` **requires** `transferPolicy:'takeover'`. Cap: ≤ `physicsKinds` (8) per world.
22
+ - **Zones** (§10) — two goal volumes that `tracks:'entity:ball'` (the ball entering a goal fires `zoneEnter`).
23
+ - **Declared state** (§2) — `ballRef` (a ball ref) + two scores.
24
+ - **Actions/rules** (§3/§14) — spawn the ball when none exists; goal `zoneEnter` → score + `destroyEntity`; an
25
+ optional `claim` action lets a client *optimistically* request the takeover for instant response (the server's
26
+ `claimOnContact` is the authority).
27
+
28
+ ## 2. The manifest — `public/helix.json`
29
+
30
+ ```json
31
+ {
32
+ "helixVersion": "0.3",
33
+ "title": "Networked Football",
34
+ "slug": "networked-football",
35
+ "entry": "index.html",
36
+ "maxPlayers": 8,
37
+ "permissions": ["auth.profile", "multiplayer"],
38
+ "multiplayer": {
39
+ "authoritative": true,
40
+ "state": {
41
+ "roomVars": {
42
+ "ballRef": { "type": "ref", "of": "entity:ball" },
43
+ "westScore": { "type": "number", "default": 0 },
44
+ "eastScore": { "type": "number", "default": 0 }
45
+ }
46
+ },
47
+ "entities": {
48
+ "ball": {
49
+ "authority": "owner",
50
+ "maxSpeed": 18,
51
+ "transferPolicy": "takeover",
52
+ "physics": {
53
+ "bodyType": "dynamic",
54
+ "shape": { "type": "sphere", "radius": 0.6 },
55
+ "mass": 1,
56
+ "restitution": 0.6,
57
+ "friction": 0.6,
58
+ "claimOnContact": true,
59
+ "revertOnRest": true
60
+ }
61
+ }
62
+ },
63
+ "states": { "initial": "play", "phases": ["play"], "joinPolicy": { "play": { "joinable": true } } },
64
+ "zones": [
65
+ { "id": "westGoal", "shape": "box", "center": [-17.5, 1.75, 0], "size": [1.6, 3.5, 6], "tracks": "entity:ball" },
66
+ { "id": "eastGoal", "shape": "box", "center": [17.5, 1.75, 0], "size": [1.6, 3.5, 6], "tracks": "entity:ball" }
67
+ ],
68
+ "actions": { "claim": { "args": { "target": { "type": "ref", "of": "entity:ball" } } } },
69
+ "rules": [
70
+ { "when": { "on": "action", "name": "claim" }, "then": [{ "do": "takeover", "entity": { "var": "action.args.target" }, "to": "self" }] },
71
+ {
72
+ "when": { "on": "tick" },
73
+ "if": { "op": "<", "a": { "op": "aggregate", "scope": "entities:ball", "agg": "count" }, "b": 1 },
74
+ "then": [
75
+ { "do": "spawnEntity", "kind": "ball", "at": { "vec3": [0, 0.6, 0] }, "bind": "spawned" },
76
+ { "do": "setRef", "target": "room.ballRef", "to": "spawned" }
77
+ ]
78
+ },
79
+ { "when": { "on": "zoneEnter", "zone": "westGoal" }, "then": [{ "do": "add", "target": "room.eastScore", "by": 1 }, { "do": "destroyEntity", "entity": "self" }] },
80
+ { "when": { "on": "zoneEnter", "zone": "eastGoal" }, "then": [{ "do": "add", "target": "room.westScore", "by": 1 }, { "do": "destroyEntity", "entity": "self" }] }
81
+ ]
82
+ },
83
+ "supportsMobile": true,
84
+ "contentRating": "everyone",
85
+ "systems": { "humanoid-character": "^0.2" }
86
+ }
87
+ ```
88
+
89
+ ## 3. The client — `src/main.ts` (delta from `relic-bearers`)
90
+
91
+ Physics adds **one `SharedPhysicsWorld`** (the ball + a kinematic capsule proxy per player; the character's static
92
+ geometry is mirrored in so the ball rests on the same floor) and an **`EntityScene` `physics` kind** whose `build`
93
+ hands back a **`DynamicBody`**. `EntityScene` flips the body hosted (I touched it → I simulate) vs remote (a proxy
94
+ at the owner's stream) and owns the interp / gated upload.
95
+
96
+ ```ts
97
+ import { SharedPhysicsWorld, DynamicBody, EntityScene, type EntityHandle } from '@helix/humanoid-character';
98
+ const BALL_MAX_SPEED = 18, BALL_RADIUS = 0.6; // mirror the DSL maxSpeed + shape
99
+
100
+ const shared = await SharedPhysicsWorld.create(); // ONE shared world for the ball
101
+ // body.addStaticCuboid(...) on your character body mirrors the arena floor/walls into `shared` too.
102
+ const proxies = new PlayerProxies(shared); // a kinematic capsule per player (so the ball hits players)
103
+
104
+ const entities = new EntityScene({
105
+ room,
106
+ maxSpeed: { ball: BALL_MAX_SPEED },
107
+ build: (_kind, id): EntityHandle => {
108
+ const mesh = makeBallMesh(); scene.add(mesh);
109
+ const dyn = new DynamicBody(
110
+ shared,
111
+ { id, shape: { type: 'sphere', radius: BALL_RADIUS }, restitution: 0.6, friction: 0.6, mass: 1, linearDamping: 0.2, angularDamping: 0.4, ccd: true },
112
+ { mode: 'remote', maxSpeed: BALL_MAX_SPEED }, // EntityScene flips this to 'hosted' when I own it
113
+ );
114
+ return { object3d: mesh, physics: dyn, onUpdate: (e) => tintByOwner(mesh, e.controller, room!.sessionId), dispose: () => { dyn.dispose(); scene.remove(mesh); } };
115
+ },
116
+ });
117
+
118
+ // frame loop (inside `if (room && entities)`), AFTER replicas.update + sendState:
119
+ proxies.drive(room.sessionId, body.position); // drive every player's capsule to their feet…
120
+ for (const [id, p] of remote) proxies.drive(id, p.position);
121
+ shared.step(dt); // …then ONE shared-world step resolves ball ↔ floor/walls/players
122
+ entities.update(dt); // hosted ball: read post-step state → render + upload; remote: proxy at the stream
123
+ ```
124
+
125
+ `claimOnContact` is **server-automatic** — you don't have to call anything to get the handoff. (For zero-latency
126
+ feel you may optimistically host on contact + `room.sendAction('claim', { target: id })`; the server's claim is
127
+ authoritative.) **Footguns:** mirror `maxSpeed` + the `shape`; one `SharedPhysicsWorld`; step it once per frame
128
+ before `entities.update`; `tintByOwner`/`makeBallMesh`/`PlayerProxies` are your own helpers, not SDK APIs.
129
+
130
+ ## 4. Build, validate, publish
131
+
132
+ `npm install` → `helix install` → `npm run build` → `validate_world` on `dist/` (it enforces the physics
133
+ cross-field rules + `physicsKinds` cap) → `whoami` → `publish_world`.
@@ -0,0 +1,140 @@
1
+ # Multiplayer template — `relic-bearers`
2
+
3
+ **What it is.** Relics lie scattered; walk up and **claim** one and it floats around you; **pass** your relics to
4
+ the least-busy player; and each player gets a **pet** that trails behind them and survives brief disconnects.
5
+ **Capability: CLIENT-hosted single-owner entities** (`authority: "owner"`) — the **escape hatch** for motion the
6
+ server can't compute — driven through **`EntityScene`**, plus **ownership** reads/transfer (`controlledBy`,
7
+ `leastLoadedPlayer`, `takeover`) and owner-leave **migration**. Lifted from the verified `multiplayer-relic-bearers`
8
+ world.
9
+
10
+ > Prefer **server motion** (`server-motion` template) first; reach for `authority:'owner'` only when a client must
11
+ > simulate the entity. Hosting is **reachable only through `EntityScene`** — never call `room.uploadEntity`.
12
+ > Grammar: `read_doc({ name: "multiplayer-logic" })` §7–§9.
13
+
14
+ ## 1. DSL used
15
+
16
+ - **Entities** (§9) — `relic` (`authority:'owner'`, `maxSpeed:50`, `transferPolicy:'takeover'` so it can be
17
+ stolen) and `pet` (`authority:'owner'`, `maxSpeed:12`, `ownerLifecycle:'migrateToServer'` — it freezes during a
18
+ brief owner absence and the **same** owner resumes on reconnect). `maxSpeed` is **required** for owner kinds.
19
+ - **Declared state** (§2) — `relicsSpawned`, `heir` (a `ref` player), `handoffs`.
20
+ - **Actions** (§14) — `claim` (arg `target`: a relic ref) and `pass`. Actions are the **cheat-resistant** path;
21
+ the client picks a target id, the **server** decides the transfer.
22
+ - **Ownership ops** (§7/§8) — `takeover` (move authority), `controlledBy` (filter), `leastLoadedPlayer` (the
23
+ least-busy host — load balancing).
24
+ - **Rules** (§3) — spawn 5 relics; spawn a `pet` per `playerJoin`; `claim` → `takeover` to `self`; `pass` /
25
+ `playerLeave` → re-home this player's relics to `leastLoadedPlayer` via `forEachEntity … where controlledBy`.
26
+
27
+ > **Firewall (§17):** owner-entity logic can't write to other players. Cross-player effects (claiming, stealing)
28
+ > go through a server-validated **action**, never from the entity's own client.
29
+
30
+ ## 2. The manifest — `public/helix.json`
31
+
32
+ ```json
33
+ {
34
+ "helixVersion": "0.3",
35
+ "title": "Relic Bearers",
36
+ "slug": "relic-bearers",
37
+ "entry": "index.html",
38
+ "maxPlayers": 8,
39
+ "permissions": ["auth.profile", "multiplayer"],
40
+ "multiplayer": {
41
+ "authoritative": true,
42
+ "state": {
43
+ "roomVars": {
44
+ "relicsSpawned": { "type": "number", "default": 0 },
45
+ "heir": { "type": "ref", "of": "player" },
46
+ "handoffs": { "type": "number", "default": 0 }
47
+ }
48
+ },
49
+ "entities": {
50
+ "relic": { "authority": "owner", "maxSpeed": 50, "transferPolicy": "takeover" },
51
+ "pet": { "authority": "owner", "maxSpeed": 12, "ownerLifecycle": "migrateToServer" }
52
+ },
53
+ "states": { "initial": "play", "phases": ["play"], "joinPolicy": { "play": { "joinable": true } } },
54
+ "actions": {
55
+ "claim": { "args": { "target": { "type": "ref", "of": "entity:relic" } } },
56
+ "pass": {}
57
+ },
58
+ "rules": [
59
+ {
60
+ "when": { "on": "tick", "everyN": 1 },
61
+ "if": { "op": "<", "a": { "var": "room.relicsSpawned" }, "b": 5 },
62
+ "then": [
63
+ { "do": "spawnEntity", "kind": "relic", "at": { "op": "randomPoint", "min": [-14, 0, -14], "max": [14, 0, 14] } },
64
+ { "do": "add", "target": "room.relicsSpawned", "by": 1 }
65
+ ]
66
+ },
67
+ { "when": { "on": "playerJoin" }, "then": [{ "do": "spawnEntity", "kind": "pet", "at": { "var": "self.position" } }] },
68
+ { "when": { "on": "action", "name": "claim" }, "then": [{ "do": "takeover", "entity": { "var": "action.args.target" }, "to": "self" }] },
69
+ {
70
+ "when": { "on": "action", "name": "pass" },
71
+ "then": [
72
+ { "do": "setRef", "target": "room.heir", "to": { "op": "leastLoadedPlayer" } },
73
+ { "do": "forEachEntity", "kind": "relic", "as": "r", "where": { "op": "controlledBy", "entity": "r", "by": "self" }, "then": [{ "do": "takeover", "entity": "r", "to": { "var": "room.heir" } }] }
74
+ ]
75
+ },
76
+ {
77
+ "when": { "on": "playerLeave" },
78
+ "then": [
79
+ { "do": "setRef", "target": "room.heir", "to": { "op": "leastLoadedPlayer" } },
80
+ { "do": "forEachEntity", "kind": "relic", "as": "r", "where": { "op": "controlledBy", "entity": "r", "by": "self" }, "then": [{ "do": "takeover", "entity": "r", "to": { "var": "room.heir" } }, { "do": "add", "target": "room.handoffs", "by": 1 }] }
81
+ ]
82
+ }
83
+ ]
84
+ },
85
+ "supportsMobile": true,
86
+ "contentRating": "everyone",
87
+ "systems": { "humanoid-character": "^0.2" }
88
+ }
89
+ ```
90
+
91
+ ## 3. The client — `src/main.ts` (delta from `hangout`)
92
+
93
+ The new API is **`EntityScene`** (from `@helix/humanoid-character`) — the generic owner-entity transport. You give
94
+ it a `build(kind)` (a mesh + `onUpdate`) and a `motion(kind)` fn; it runs your motion + predicts/reconciles/uploads
95
+ for entities **you control** and interpolates the rest. **`maxSpeed` MUST mirror the DSL** (it drives the reconcile
96
+ clamp).
97
+
98
+ ```ts
99
+ import { EntityScene } from '@helix/humanoid-character';
100
+
101
+ const RELIC_MAX_SPEED = 50, PET_MAX_SPEED = 12; // MUST equal the DSL maxSpeed
102
+
103
+ const entities = new EntityScene({
104
+ room,
105
+ maxSpeed: { relic: RELIC_MAX_SPEED, pet: PET_MAX_SPEED },
106
+ motion: { // only invoked for entities I control
107
+ relic: (e, dt, ctx) => stepToward(ctx.position, { x: body.position.x + Math.cos(angleFor(e.id)), y: 1.45, z: body.position.z + Math.sin(angleFor(e.id)) }, RELIC_MAX_SPEED * 0.83 * dt),
108
+ pet: (_e, dt, ctx) => stepToward(ctx.position, { x: body.position.x - 1.4, y: 0.35, z: body.position.z - 1.4 }, PET_MAX_SPEED * 0.83 * dt),
109
+ },
110
+ build: (kind) => {
111
+ const mesh = kind === 'pet' ? makePet() : makeRelic(); scene.add(mesh);
112
+ const mat = mesh.material as THREE.MeshStandardMaterial;
113
+ return {
114
+ object3d: mesh,
115
+ onUpdate: (e) => { mat.color.setHex(e.controller === room!.sessionId ? COLOR_MINE : e.controller === '' ? COLOR_FREE : hueFor(e.controller)); }, // recolor by owner
116
+ dispose: () => { scene.remove(mesh); mesh.geometry.dispose(); mat.dispose(); },
117
+ };
118
+ },
119
+ });
120
+ // frame loop (inside `if (room)`): entities.update(dt);
121
+
122
+ // Claim the nearest relic that isn't already mine — the client only picks the id; the SERVER runs the takeover:
123
+ function claimNearest() {
124
+ let best = '', bestD = CLAIM_RADIUS ** 2;
125
+ for (const { id, object3d, state } of entities.entries()) {
126
+ if (state.kind !== 'relic' || state.controller === room!.sessionId) continue;
127
+ const d2 = (object3d.position.x - body.position.x) ** 2 + (object3d.position.z - body.position.z) ** 2;
128
+ if (d2 < bestD) { bestD = d2; best = id; }
129
+ }
130
+ if (best) room!.sendAction('claim', { target: best });
131
+ }
132
+ // bind `pass` to a key: room.sendAction('pass');
133
+ ```
134
+
135
+ **Footguns:** mirror `maxSpeed` (DSL ↔ `EntityScene`); never call `room.uploadEntity` — register through
136
+ `EntityScene`; recolor by `e.controller` (`''` = unowned/server) so claims + hand-offs are visible.
137
+
138
+ ## 4. Build, validate, publish
139
+
140
+ `npm install` → `helix install` → `npm run build` → `validate_world` on `dist/` → `whoami` → `publish_world`.
@@ -0,0 +1,128 @@
1
+ # Multiplayer template — `server-motion`
2
+
3
+ **What it is.** A roaming **bot vacuum** glides around the arena chasing the nearest player and hoovering up coins
4
+ it passes over — all computed **on the server**. **Capability: server-authoritative entity motion** (`seek` /
5
+ `waypoints` / `orbit` / `linear`). This is the **preferred entity default**: declare `motion` and the server moves
6
+ the entity deterministically — cheat-proof, never freezes, no client hosting. Reach for client hosting
7
+ (`relic-bearers`) only when the server genuinely can't compute the behavior. Lifted from the verified
8
+ `multiplayer-vacuum-collector` world.
9
+
10
+ > Read the **hub** + `collect-a-thon` first (this reuses its server-entity render-proxy). Grammar:
11
+ > `read_doc({ name: "multiplayer-logic" })` §9 (entities/motion).
12
+
13
+ ## 1. DSL used
14
+
15
+ - **Entities** (§9) — `coin` (a `value`) + `vacuum`, which has `motion: { type: "seek", target: "target", speed }`
16
+ (homes toward whatever player its `target` ref var holds) and an **attached zone** that `tracks: "entity:coin"`
17
+ (entity-vs-entity overlap — the vacuum's field absorbs coins, with `requireDwell`/`debounce`). Both default to
18
+ `authority: "server"` — **no client code drives them.**
19
+ - **Declared state** (§2) — `roomVars.collected` + `roomVars.bot` (a `ref` to the vacuum entity).
20
+ - **State machine** (§12) — a single `playing` phase, used as a `stateEnter` setup hook.
21
+ - **Rules** (§3) — spawn the bot + coins on `stateEnter`; **the "AI": every 10 ticks, re-point the bot's `target`
22
+ to `{op:"nearestPlayer"}`** (so `seek` chases the closest player); refill coins when the count hits 0
23
+ (`aggregate count`); on the vacuum-zone `zoneEnter` (binds `self` = the coin) add its value + `destroyEntity`.
24
+
25
+ The whole "chase" behavior is **two rules + a `seek` motion** — no pathfinding, no client sim. That's the lesson:
26
+ prefer server motion + a retargeting rule over hosting an entity on a client.
27
+
28
+ ## 2. The manifest — `public/helix.json`
29
+
30
+ ```json
31
+ {
32
+ "helixVersion": "0.3",
33
+ "title": "Vacuum Collector",
34
+ "slug": "vacuum-collector",
35
+ "entry": "index.html",
36
+ "maxPlayers": 8,
37
+ "permissions": ["auth.profile", "multiplayer"],
38
+ "multiplayer": {
39
+ "authoritative": true,
40
+ "state": {
41
+ "roomVars": {
42
+ "collected": { "type": "number", "default": 0 },
43
+ "bot": { "type": "ref", "of": "entity:vacuum" }
44
+ }
45
+ },
46
+ "entities": {
47
+ "coin": { "vars": { "value": { "type": "number", "default": 1 } } },
48
+ "vacuum": {
49
+ "vars": { "target": { "type": "ref", "of": "player" } },
50
+ "motion": { "type": "seek", "target": "target", "speed": 2.5 },
51
+ "zone": { "shape": "sphere", "radius": 2.5, "tracks": "entity:coin", "requireDwell": 0.8, "debounce": 0.3 }
52
+ }
53
+ },
54
+ "states": { "initial": "playing", "phases": ["playing"] },
55
+ "rules": [
56
+ {
57
+ "when": { "on": "stateEnter", "phase": "playing" },
58
+ "then": [
59
+ { "do": "spawnEntity", "kind": "vacuum", "at": { "vec3": [0, 0, 0] } },
60
+ { "do": "setRef", "target": "room.bot", "to": { "op": "nearestEntity", "from": { "vec3": [0, 0, 0] }, "kind": "vacuum" } },
61
+ { "do": "spawnEntity", "kind": "coin", "at": { "vec3": [8, 0, 0] } },
62
+ { "do": "spawnEntity", "kind": "coin", "at": { "vec3": [-8, 0, 0] } },
63
+ { "do": "spawnEntity", "kind": "coin", "at": { "vec3": [0, 0, 8] } },
64
+ { "do": "spawnEntity", "kind": "coin", "at": { "vec3": [0, 0, -8] } }
65
+ ]
66
+ },
67
+ {
68
+ "when": { "on": "tick", "everyN": 40 },
69
+ "if": { "op": "==", "a": { "op": "aggregate", "scope": "entities:coin", "agg": "count" }, "b": 0 },
70
+ "then": [
71
+ { "do": "spawnEntity", "kind": "coin", "at": { "vec3": [8, 0, 8] } },
72
+ { "do": "spawnEntity", "kind": "coin", "at": { "vec3": [-8, 0, -8] } },
73
+ { "do": "spawnEntity", "kind": "coin", "at": { "vec3": [8, 0, -8] } },
74
+ { "do": "spawnEntity", "kind": "coin", "at": { "vec3": [-8, 0, 8] } }
75
+ ]
76
+ },
77
+ {
78
+ "when": { "on": "tick", "everyN": 10 },
79
+ "then": [
80
+ { "do": "setRef", "target": { "ref": { "var": "room.bot" }, "var": "target" }, "to": { "op": "nearestPlayer", "from": { "ref": { "var": "room.bot" }, "var": "position" } } }
81
+ ]
82
+ },
83
+ {
84
+ "when": { "on": "zoneEnter", "zone": "vacuum" },
85
+ "then": [
86
+ { "do": "add", "target": "room.collected", "by": { "ref": "self", "var": "value" } },
87
+ { "do": "destroyEntity", "entity": "self" }
88
+ ]
89
+ }
90
+ ]
91
+ },
92
+ "supportsMobile": true,
93
+ "contentRating": "everyone",
94
+ "systems": { "humanoid-character": "^0.2" }
95
+ }
96
+ ```
97
+
98
+ `motion.target` names a `ref`-typed var **on the entity**; the server integrates `seek` toward that member each
99
+ tick. The retarget rule writes that var through `{ ref: room.bot, var: "target" }`. The vacuum's attached zone
100
+ `tracks: "entity:coin"`, so its `zoneEnter` binds `self` = the **coin** (entity-vs-entity).
101
+
102
+ ## 3. The client — `src/main.ts` (delta from `collect-a-thon`)
103
+
104
+ Identical *server-only render-proxy* — the client never drives entity motion; the **server** owns the position and
105
+ the client just **interpolates** toward each entity's live `position`. The only new bit is switching the mesh on
106
+ `entity.kind`:
107
+
108
+ ```ts
109
+ room.onAdd('entities', (entity, id) => {
110
+ if (entity.kind === 'vacuum') { const g = makeVacuum(); scene.add(g); bots.set(id, { g, state: entity }); }
111
+ else { const m = makeCoin(); scene.add(m); coins.set(id, { m, state: entity }); }
112
+ });
113
+ room.onRemove('entities', (_e, id) => {
114
+ const b = bots.get(id); if (b) { scene.remove(b.g); bots.delete(id); return; }
115
+ const c = coins.get(id); if (c) { scene.remove(c.m); coins.delete(id); }
116
+ });
117
+
118
+ // frame loop (inside `if (room)`): lerp each proxy toward its synced position — the bot glides because the
119
+ // SERVER moves it; the client only smooths the sparse updates.
120
+ for (const [, b] of bots) if (b.state.position) b.g.position.lerp(tmp.copy(b.state.position).setY(b.state.position.y + 1), 0.3);
121
+ for (const [, c] of coins) if (c.state.position) c.m.position.lerp(tmp.copy(c.state.position).setY(c.state.position.y + 0.8), 0.25);
122
+ ```
123
+
124
+ No `EntityScene`, no upload — server-authoritative motion needs neither. (That's the whole appeal.)
125
+
126
+ ## 4. Build, validate, publish
127
+
128
+ `npm install` → `helix install` → `npm run build` → `validate_world` on `dist/` → `whoami` → `publish_world`.