@helix3/helix-mcp 0.2.2-helix3.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +46 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.js +353 -0
- package/dist/server.js.map +1 -0
- package/dist/tsconfig.build.tsbuildinfo +1 -0
- package/docs/catalog.md +28 -0
- package/docs/character-world.md +506 -0
- package/docs/manifest.md +51 -0
- package/docs/multiplayer-logic.md +394 -0
- package/docs/multiplayer-templates/chrono-orchard.md +123 -0
- package/docs/multiplayer-templates/collect-a-thon.md +126 -0
- package/docs/multiplayer-templates/collections.md +116 -0
- package/docs/multiplayer-templates/hangout.md +184 -0
- package/docs/multiplayer-templates/obby.md +84 -0
- package/docs/multiplayer-templates/physics-bumper.md +153 -0
- package/docs/multiplayer-templates/physics-football.md +133 -0
- package/docs/multiplayer-templates/relic-bearers.md +140 -0
- package/docs/multiplayer-templates/server-motion.md +128 -0
- package/docs/multiplayer-templates/team-control.md +98 -0
- package/docs/multiplayer-templates/turn-arena.md +159 -0
- package/docs/multiplayer-templates/wave-survival.md +141 -0
- package/docs/multiplayer-world.md +231 -0
- package/docs/publishing.md +42 -0
- package/docs/sdk.md +117 -0
- package/docs/world-recipe.md +137 -0
- package/package.json +33 -0
|
@@ -0,0 +1,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`.
|