@hypersoniclabs/helix-mcp 0.2.4 → 0.2.12

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.
Files changed (98) hide show
  1. package/README.md +81 -11
  2. package/dist/continuumCanary.d.ts +17 -0
  3. package/dist/continuumCanary.js +17 -0
  4. package/dist/continuumCanary.js.map +1 -0
  5. package/dist/server.d.ts +14 -1
  6. package/dist/server.js +4058 -162
  7. package/dist/server.js.map +1 -1
  8. package/dist/tsconfig.build.tsbuildinfo +1 -1
  9. package/dist/vehicleTools.d.ts +86 -0
  10. package/dist/vehicleTools.js +229 -0
  11. package/dist/vehicleTools.js.map +1 -0
  12. package/docs/avatar-face.md +115 -0
  13. package/docs/bridge.md +98 -0
  14. package/docs/bring-your-world.md +117 -0
  15. package/docs/catalog.md +69 -1
  16. package/docs/character-animation.md +442 -0
  17. package/docs/character-attachments.md +166 -0
  18. package/docs/character-world.md +785 -130
  19. package/docs/continuum.md +153 -0
  20. package/docs/items.md +73 -0
  21. package/docs/lighting-world.md +667 -0
  22. package/docs/locomotion-clip-spec.md +294 -0
  23. package/docs/manifest.md +31 -6
  24. package/docs/multiplayer-logic.md +460 -17
  25. package/docs/multiplayer-templates/chrono-orchard.md +36 -22
  26. package/docs/multiplayer-templates/collect-a-thon.md +28 -38
  27. package/docs/multiplayer-templates/collections.md +24 -24
  28. package/docs/multiplayer-templates/hangout.md +124 -111
  29. package/docs/multiplayer-templates/npc-wave.md +310 -0
  30. package/docs/multiplayer-templates/obby.md +13 -16
  31. package/docs/multiplayer-templates/persistent-progress.md +218 -0
  32. package/docs/multiplayer-templates/physics-bumper.md +27 -9
  33. package/docs/multiplayer-templates/physics-football.md +22 -8
  34. package/docs/multiplayer-templates/relic-bearers.md +12 -15
  35. package/docs/multiplayer-templates/server-motion.md +16 -19
  36. package/docs/multiplayer-templates/shooter-range.md +275 -0
  37. package/docs/multiplayer-templates/team-control.md +28 -15
  38. package/docs/multiplayer-templates/turn-arena.md +31 -22
  39. package/docs/multiplayer-templates/voice-radio.md +166 -0
  40. package/docs/multiplayer-templates/wave-survival.md +7 -8
  41. package/docs/multiplayer-templates/world-shop.md +240 -0
  42. package/docs/multiplayer-world.md +222 -129
  43. package/docs/npc-world.md +623 -0
  44. package/docs/publishing.md +108 -28
  45. package/docs/purchases.md +223 -0
  46. package/docs/scene-performance.md +64 -0
  47. package/docs/screenshots.md +140 -0
  48. package/docs/sdk.md +324 -5
  49. package/docs/shooter-worlds.md +537 -0
  50. package/docs/terrain.md +173 -0
  51. package/docs/upgrades.md +324 -0
  52. package/docs/vehicles.md +727 -0
  53. package/docs/world-inspect.md +156 -0
  54. package/docs/world-look.md +241 -0
  55. package/docs/world-recipe.md +65 -6
  56. package/package.json +15 -4
  57. package/skills/README.md +91 -0
  58. package/skills/helix-assets/SKILL.md +491 -0
  59. package/skills/helix-assets/references/asset-sources.md +143 -0
  60. package/skills/helix-assets/references/vault-api.md +105 -0
  61. package/skills/helix-avatar-qa/SKILL.md +85 -0
  62. package/skills/helix-avatars/SKILL.md +206 -0
  63. package/skills/helix-avatars/references/contract.md +166 -0
  64. package/skills/helix-avatars/references/dynamics.md +367 -0
  65. package/skills/helix-avatars/references/face.md +50 -0
  66. package/skills/helix-avatars/references/publish.md +76 -0
  67. package/skills/helix-avatars/references/qa.md +251 -0
  68. package/skills/helix-avatars/references/rigging.md +88 -0
  69. package/skills/helix-avatars/references/source-generated.md +190 -0
  70. package/skills/helix-avatars/references/source-model.md +90 -0
  71. package/skills/helix-avatars/references/source-rigid.md +90 -0
  72. package/skills/helix-avatars/references/source-vrm.md +61 -0
  73. package/skills/helix-gauntlet/SKILL.md +128 -0
  74. package/skills/helix-multiplayer/SKILL.md +150 -0
  75. package/skills/helix-multiplayer/references/dsl-capability-map.md +107 -0
  76. package/skills/helix-vehicles/SKILL.md +218 -0
  77. package/skills/helix-vehicles/references/addons.md +212 -0
  78. package/skills/helix-vehicles/references/appearance.md +339 -0
  79. package/skills/helix-vehicles/references/audio-import.md +138 -0
  80. package/skills/helix-vehicles/references/audio.md +580 -0
  81. package/skills/helix-vehicles/references/cabin.md +225 -0
  82. package/skills/helix-vehicles/references/host-manifest.md +174 -0
  83. package/skills/helix-vehicles/references/physics-from-specs.md +463 -0
  84. package/skills/helix-vehicles/references/publish.md +214 -0
  85. package/skills/helix-vehicles/references/qa.md +177 -0
  86. package/skills/helix-vehicles/references/reference-package.json +3481 -0
  87. package/skills/helix-vehicles/references/reference-package.md +69 -0
  88. package/skills/helix-vehicles/references/source-beamng.md +167 -0
  89. package/skills/helix-vehicles/references/source-concept.md +38 -0
  90. package/skills/helix-vehicles/references/source-model.md +100 -0
  91. package/skills/helix-vehicles/references/source-scratch.md +60 -0
  92. package/skills/helix-world-build/SKILL.md +376 -0
  93. package/skills/helix-world-build/references/config-gates.md +104 -0
  94. package/skills/helix-world-director/SKILL.md +210 -0
  95. package/skills/helix-world-qa/SKILL.md +371 -0
  96. package/skills/helix-world-qa/references/perf-budgets.md +240 -0
  97. package/skills/helix-world-qa/references/perf-handle.md +125 -0
  98. package/skills/helix-world-qa/references/visual-scorecard.md +85 -0
@@ -35,7 +35,7 @@ a physics body** (bumper cars, derby, sumo). Lifted from the verified `multiplay
35
35
  "slug": "bumper-cars",
36
36
  "entry": "index.html",
37
37
  "maxPlayers": 8,
38
- "permissions": ["auth.profile", "multiplayer"],
38
+ "permissions": ["auth.profile", "multiplayer", "voice.room"],
39
39
  "multiplayer": {
40
40
  "authoritative": true,
41
41
  "uploadHz": 20,
@@ -90,12 +90,22 @@ a physics body** (bumper cars, derby, sumo). Lifted from the verified `multiplay
90
90
  },
91
91
  "supportsMobile": true,
92
92
  "contentRating": "everyone",
93
- "systems": { "humanoid-character": "^0.2" }
93
+ "systems": { "humanoid-character": "^0.3" }
94
94
  }
95
95
  ```
96
96
 
97
+ > Voice here is **`voice.room`** (flat room-wide), not `voice.proximity`: this facade-free world has no
98
+ > `CharacterVoice` proximity driver, so after the room join just call `Helix.voice.join()` (fail-soft) and
99
+ > everyone hears everyone — which is what an arena wants anyway. There is no `attachVoice` without the facade.
100
+
97
101
  ## 3. The client — `src/main.ts` (delta from `physics-football`)
98
102
 
103
+ > **This is the facade-free template — when NOT to use `CharacterMultiplayer`.** The facade's whole substrate is
104
+ > humanoid presence: a local `Character` + humanoid replicas + avatars + nameplates. Here the player IS a physics
105
+ > body (a car) — there are no humanoids at all — so the facade has nothing to contribute. Worlds like this wire
106
+ > `Helix.init()` → guarded `joinRoom()` by hand (four lines — see the hangout appendix §a/§b) and build everything
107
+ > from the primitives. The decision rule: **no humanoid players ⇒ no facade.**
108
+
99
109
  Same `SharedPhysicsWorld` + `EntityScene` physics build (a `DynamicBody` per car/puck). New for dual-sim: a
100
110
  **`DualSimController`** bound to *your* car — each frame it flips the dual-sim flag on peer cars you're touching so
101
111
  they're locally predicted + reconciled. You **drive** your car directly with a mass-aware impulse (the player IS the
@@ -121,19 +131,26 @@ const entities = new EntityScene({
121
131
  },
122
132
  });
123
133
 
134
+ // No humanoid character here (the car IS the avatar), so the world owns AND PUMPS the router itself,
135
+ // driving everything off the standard `move` id — WASD + left stick for free (character-world §8c):
136
+ const input = new InputService();
137
+ input.attach(window as never);
138
+ input.attachPointer(renderer.domElement as never, undefined, { dragLook: true });
139
+ registerStandardActions(input, { only: ['move'] });
140
+ help.innerHTML = input.format('<b>{move}</b> drive · ram the other cars'); // re-run on activeDevice() change
141
+
124
142
  let dualSim: DualSimController | null = null, dualSimId: string | null = null;
125
143
  // frame loop:
144
+ input.update(dt); // NO character to pump the router — the world calls update(dt) itself, queries come AFTER
126
145
  let myId: string | null = null; const others: DynamicBody[] = [];
127
146
  for (const { id, state } of entities.entries()) {
128
147
  if (state.kind !== 'car') continue;
129
148
  if (state.controller === room.sessionId) myId = id; else { const b = carBodies.get(id); if (b) others.push(b); }
130
149
  }
131
150
  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 });
151
+ if (mine) { // move vec2 → mass-aware impulse (consistent accel regardless of mass)
152
+ const move = input.vec2('move'); // +y forward; screen-relative (the camera looks down −Z)
153
+ if (move.x || move.y) mine.applyImpulse({ x: move.x * ACCEL * CAR_MASS * dt, y: 0, z: -move.y * ACCEL * CAR_MASS * dt });
137
154
  }
138
155
  if (myId && dualSimId !== myId) { dualSim = new DualSimController(shared, myId, entities); dualSimId = myId; }
139
156
  shared.step(dt); // ORDER MATTERS: step → dualSim.update (reads fresh contacts) → entities.update (renders the flag)
@@ -145,9 +162,10 @@ room.sendState(carPose(mine)); // the player IS the car: carPose() = a Replica
145
162
  **Footguns:** the car body **must** declare `proximityMargin` (the dual-sim engagement sensor) — omit it and dual-sim
146
163
  silently never fires; mirror `maxSpeed`/`mass`/`shape`; keep `maxPlayers ≤ 12` (uploadHz 20); the step →
147
164
  `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.
165
+ your car id changes); character-less worlds must call `input.update(dt)` themselves every frame (with a character,
166
+ `character.update(dt)` pumps it); `carPose(mine)` and `makeCarMesh` are your own helpers, not SDK APIs.
149
167
 
150
168
  ## 4. Build, validate, publish
151
169
 
152
170
  `npm install` → **`install_world_packages`** (MCP tool) → `npm run build` → `validate_world` on `dist/` (enforces `dualSimOnContact`
153
- requires `transferPolicy:'fixed'`, and `uploadHz:20` ⇒ `maxPlayers ≤ 12`) → `whoami` → `publish_world`.
171
+ requires `transferPolicy:'fixed'`, and `uploadHz:20` ⇒ `maxPlayers ≤ 12`) → **`helix verify-subpath dist`** (REQUIRED — the only local gate that serves the bundle from a nested path, the way a published world is actually served; preview/validate/inspect all serve from the origin root and cannot see a root-absolute 404) → `whoami` → `publish_world`.
@@ -4,8 +4,10 @@
4
4
  **Capability: networked physics — the AUTHORITY-TRANSFER model.** One **shared dynamic body** (the ball) sits
5
5
  server-held between touches; the instant a player contacts it the server **auto-hands control to them**
6
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).
7
+ the server** (`revertOnRest`). Use this for **one contested object** (soccer, hockey, air-hockey). A MULTI-body set
8
+ — a pool rack, a bowling pin deck — is exactly what per-contact claiming cannot do atomically: declare a `group` on
9
+ the kind and a `claimGroup` action instead (multiplayer-logic §9a). Lifted from the verified `multiplayer-football`
10
+ world (contract v16).
9
11
 
10
12
  > Networked physics is the newest, churniest part of the platform — lift this verbatim. The *other* physics model
11
13
  > (each player owns their own body) is `physics-bumper`. Grammar: `read_doc({ name: "multiplayer-logic" })` §9 +
@@ -34,7 +36,7 @@ the verified `multiplayer-football` world (contract v16).
34
36
  "slug": "networked-football",
35
37
  "entry": "index.html",
36
38
  "maxPlayers": 8,
37
- "permissions": ["auth.profile", "multiplayer"],
39
+ "permissions": ["auth.profile", "multiplayer", "voice.proximity"],
38
40
  "multiplayer": {
39
41
  "authoritative": true,
40
42
  "state": {
@@ -82,7 +84,7 @@ the verified `multiplayer-football` world (contract v16).
82
84
  },
83
85
  "supportsMobile": true,
84
86
  "contentRating": "everyone",
85
- "systems": { "humanoid-character": "^0.2" }
87
+ "systems": { "humanoid-character": "^0.3" }
86
88
  }
87
89
  ```
88
90
 
@@ -93,14 +95,26 @@ geometry is mirrored in so the ball rests on the same floor) and an **`EntitySce
93
95
  hands back a **`DynamicBody`**. `EntityScene` flips the body hosted (I touched it → I simulate) vs remote (a proxy
94
96
  at the owner's stream) and owns the interp / gated upload.
95
97
 
98
+ > **Why `new EntityScene(...)` here instead of `mp.entities()`:** the shared-world step MUST run between the
99
+ > facade's upload and the entity update (drive proxies → `shared.step` → `entities.update`). `mp.entities()`
100
+ > updates inside `mp.update()`, which would step entities before the physics world — so a physics world creates
101
+ > the `EntityScene` directly (with `room: mp.room`) and owns its frame ordering. Presence still rides the facade.
102
+
96
103
  ```ts
97
104
  import { SharedPhysicsWorld, DynamicBody, EntityScene, type EntityHandle } from '@helix/humanoid-character';
105
+ import type { PlayerState } from '@hypersoniclabs/helix-sdk';
98
106
  const BALL_MAX_SPEED = 18, BALL_RADIUS = 0.6; // mirror the DSL maxSpeed + shape
107
+ const room = mp.room!;
99
108
 
100
109
  const shared = await SharedPhysicsWorld.create(); // ONE shared world for the ball
101
110
  // body.addStaticCuboid(...) on your character body mirrors the arena floor/walls into `shared` too.
102
111
  const proxies = new PlayerProxies(shared); // a kinematic capsule per player (so the ball hits players)
103
112
 
113
+ // Live remote refs for the proxy drive (the facade renders replicas; physics needs the raw positions too).
114
+ const others = new Map<string, PlayerState>();
115
+ room.onAdd('players', (p, id) => { if (id !== room.sessionId) others.set(id, p); });
116
+ room.onRemove('players', (_p, id) => others.delete(id));
117
+
104
118
  const entities = new EntityScene({
105
119
  room,
106
120
  maxSpeed: { ball: BALL_MAX_SPEED },
@@ -111,13 +125,13 @@ const entities = new EntityScene({
111
125
  { id, shape: { type: 'sphere', radius: BALL_RADIUS }, restitution: 0.6, friction: 0.6, mass: 1, linearDamping: 0.2, angularDamping: 0.4, ccd: true },
112
126
  { mode: 'remote', maxSpeed: BALL_MAX_SPEED }, // EntityScene flips this to 'hosted' when I own it
113
127
  );
114
- return { object3d: mesh, physics: dyn, onUpdate: (e) => tintByOwner(mesh, e.controller, room!.sessionId), dispose: () => { dyn.dispose(); scene.remove(mesh); } };
128
+ return { object3d: mesh, physics: dyn, onUpdate: (e) => tintByOwner(mesh, e.controller, room.sessionId), dispose: () => { dyn.dispose(); scene.remove(mesh); } };
115
129
  },
116
130
  });
117
131
 
118
- // frame loop (inside `if (room && entities)`), AFTER replicas.update + sendState:
132
+ // frame loop, AFTER mp.update(dt) (which already ran replicas + sendState):
119
133
  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);
134
+ for (const [id, p] of others) proxies.drive(id, p.position);
121
135
  shared.step(dt); // …then ONE shared-world step resolves ball ↔ floor/walls/players
122
136
  entities.update(dt); // hosted ball: read post-step state → render + upload; remote: proxy at the stream
123
137
  ```
@@ -130,4 +144,4 @@ before `entities.update`; `tintByOwner`/`makeBallMesh`/`PlayerProxies` are your
130
144
  ## 4. Build, validate, publish
131
145
 
132
146
  `npm install` → **`install_world_packages`** (MCP tool) → `npm run build` → `validate_world` on `dist/` (it enforces the physics
133
- cross-field rules + `physicsKinds` cap) → `whoami` → `publish_world`.
147
+ cross-field rules + `physicsKinds` cap) → **`helix verify-subpath dist`** (REQUIRED — the only local gate that serves the bundle from a nested path, the way a published world is actually served; preview/validate/inspect all serve from the origin root and cannot see a root-absolute 404) → `whoami` → `publish_world`.
@@ -36,7 +36,7 @@ world.
36
36
  "slug": "relic-bearers",
37
37
  "entry": "index.html",
38
38
  "maxPlayers": 8,
39
- "permissions": ["auth.profile", "multiplayer"],
39
+ "permissions": ["auth.profile", "multiplayer", "voice.proximity"],
40
40
  "multiplayer": {
41
41
  "authoritative": true,
42
42
  "state": {
@@ -84,24 +84,21 @@ world.
84
84
  },
85
85
  "supportsMobile": true,
86
86
  "contentRating": "everyone",
87
- "systems": { "humanoid-character": "^0.2" }
87
+ "systems": { "humanoid-character": "^0.3" }
88
88
  }
89
89
  ```
90
90
 
91
91
  ## 3. The client — `src/main.ts` (delta from `hangout`)
92
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).
93
+ The new part is the **owner-entity transport** — the same `mp.entities()` call as the render-only templates,
94
+ plus a `motion(kind)` fn: EntityScene runs your motion + predicts/reconciles/uploads for entities **you
95
+ control** and interpolates the rest. **`maxSpeed` MUST mirror the DSL** (it drives the reconcile clamp).
97
96
 
98
97
  ```ts
99
- import { EntityScene } from '@helix/humanoid-character';
100
-
101
98
  const RELIC_MAX_SPEED = 50, PET_MAX_SPEED = 12; // MUST equal the DSL maxSpeed
99
+ const room = mp.room!;
102
100
 
103
- const entities = new EntityScene({
104
- room,
101
+ const entities = mp.entities({
105
102
  maxSpeed: { relic: RELIC_MAX_SPEED, pet: PET_MAX_SPEED },
106
103
  motion: { // only invoked for entities I control
107
104
  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),
@@ -112,22 +109,22 @@ const entities = new EntityScene({
112
109
  const mat = mesh.material as THREE.MeshStandardMaterial;
113
110
  return {
114
111
  object3d: mesh,
115
- onUpdate: (e) => { mat.color.setHex(e.controller === room!.sessionId ? COLOR_MINE : e.controller === '' ? COLOR_FREE : hueFor(e.controller)); }, // recolor by owner
112
+ onUpdate: (e) => { mat.color.setHex(e.controller === room.sessionId ? COLOR_MINE : e.controller === '' ? COLOR_FREE : hueFor(e.controller)); }, // recolor by owner
116
113
  dispose: () => { scene.remove(mesh); mesh.geometry.dispose(); mat.dispose(); },
117
114
  };
118
115
  },
119
116
  });
120
- // frame loop (inside `if (room)`): entities.update(dt);
117
+ // mp.update(dt) drives the sim/interpolation — nothing to add in your frame loop.
121
118
 
122
119
  // Claim the nearest relic that isn't already mine — the client only picks the id; the SERVER runs the takeover:
123
120
  function claimNearest() {
124
121
  let best = '', bestD = CLAIM_RADIUS ** 2;
125
122
  for (const { id, object3d, state } of entities.entries()) {
126
- if (state.kind !== 'relic' || state.controller === room!.sessionId) continue;
123
+ if (state.kind !== 'relic' || state.controller === room.sessionId) continue;
127
124
  const d2 = (object3d.position.x - body.position.x) ** 2 + (object3d.position.z - body.position.z) ** 2;
128
125
  if (d2 < bestD) { bestD = d2; best = id; }
129
126
  }
130
- if (best) room!.sendAction('claim', { target: best });
127
+ if (best) room.sendAction('claim', { target: best });
131
128
  }
132
129
  // bind `pass` to a key: room.sendAction('pass');
133
130
  ```
@@ -137,4 +134,4 @@ function claimNearest() {
137
134
 
138
135
  ## 4. Build, validate, publish
139
136
 
140
- `npm install` → **`install_world_packages`** (MCP tool) → `npm run build` → `validate_world` on `dist/` → `whoami` → `publish_world`.
137
+ `npm install` → **`install_world_packages`** (MCP tool) → `npm run build` → `validate_world` on `dist/` → **`helix verify-subpath dist`** (REQUIRED — the only local gate that serves the bundle from a nested path, the way a published world is actually served; preview/validate/inspect all serve from the origin root and cannot see a root-absolute 404) → `whoami` → `publish_world`.
@@ -34,7 +34,7 @@ prefer server motion + a retargeting rule over hosting an entity on a client.
34
34
  "slug": "vacuum-collector",
35
35
  "entry": "index.html",
36
36
  "maxPlayers": 8,
37
- "permissions": ["auth.profile", "multiplayer"],
37
+ "permissions": ["auth.profile", "multiplayer", "voice.proximity"],
38
38
  "multiplayer": {
39
39
  "authoritative": true,
40
40
  "state": {
@@ -91,7 +91,7 @@ prefer server motion + a retargeting rule over hosting an entity on a client.
91
91
  },
92
92
  "supportsMobile": true,
93
93
  "contentRating": "everyone",
94
- "systems": { "humanoid-character": "^0.2" }
94
+ "systems": { "humanoid-character": "^0.3" }
95
95
  }
96
96
  ```
97
97
 
@@ -101,28 +101,25 @@ tick. The retarget rule writes that var through `{ ref: room.bot, var: "target"
101
101
 
102
102
  ## 3. The client — `src/main.ts` (delta from `collect-a-thon`)
103
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`:
104
+ Identical render-only `mp.entities()` — the client never drives entity motion; the **server** owns the position
105
+ and EntityScene interpolates each entity off source-time. The only new bit is switching the mesh on `kind`
106
+ (the `build` callback receives it):
107
107
 
108
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); }
109
+ const entities = mp.entities({
110
+ build: (kind) => {
111
+ const group = new THREE.Group();
112
+ const visual = kind === 'vacuum' ? makeVacuum() : makeCoin();
113
+ visual.position.y = kind === 'vacuum' ? 1 : 0.8; // visual float — a child of the positioned group
114
+ group.add(visual); scene.add(group);
115
+ return { object3d: group, dispose: () => scene.remove(group) };
116
+ },
116
117
  });
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);
118
+ // mp.update(dt) interpolates every entity — the bot glides because the SERVER moves it. No upload, no motion fn.
122
119
  ```
123
120
 
124
- No `EntityScene`, no upload — server-authoritative motion needs neither. (That's the whole appeal.)
121
+ No `motion` fn = server-authoritative — nothing to simulate, nothing to upload. (That's the whole appeal.)
125
122
 
126
123
  ## 4. Build, validate, publish
127
124
 
128
- `npm install` → **`install_world_packages`** (MCP tool) → `npm run build` → `validate_world` on `dist/` → `whoami` → `publish_world`.
125
+ `npm install` → **`install_world_packages`** (MCP tool) → `npm run build` → `validate_world` on `dist/` → **`helix verify-subpath dist`** (REQUIRED — the only local gate that serves the bundle from a nested path, the way a published world is actually served; preview/validate/inspect all serve from the origin root and cannot see a root-absolute 404) → `whoami` → `publish_world`.
@@ -0,0 +1,275 @@
1
+ # Multiplayer template — `shooter-range`
2
+
3
+ **What it is.** An arena shooter: players carry guns, fire hitscan shots with real spread, land server-
4
+ validated damage on each other and on destructible targets, go down into a physics ragdoll, and respawn —
5
+ plus gun-declared damage via published weapon definitions. **Capability: the combat loop** — `playerVars.
6
+ health` + claim actions + cooldown gates + kill credit + respawn, destructible slot-respawning entities, and
7
+ the `weaponItems`/`weaponDamage` lane (a gun's damage comes from its PUBLISHED definition, resolved
8
+ server-side — a client never names a number). Use this for deathmatch, team shooters, PvE ranges, horde
9
+ survival with guns. Lifted from the verified `multiplayer-shooter-range` world.
10
+
11
+ > The client half (installing `gun-control`, weapon profiles, FX/hit systems, the crosshair, ragdoll) is its
12
+ > own recipe: **`read_doc({ name: "shooter-worlds" })` — read it FIRST.** This page is the ROOM half plus a
13
+ > trimmed client starter. Grammar refs: `multiplayer-logic` §14 (actions), §8 (respawn), §6–7 (entities),
14
+ > §5 (timers).
15
+
16
+ ## 1. DSL used
17
+
18
+ - **Declared state** (§2) — `playerVars`: `health` (the room OWNS the number — rules mutate, clients read),
19
+ `kills`, `lastHitBy` (a player ref for kill credit).
20
+ - **Actions** (§14) — `claimHit` (target: a player) and `claimTargetHit` (target: an entity). The client's
21
+ hit chain sends EXACTLY ONE claim per trigger pull (the one-claim rule — over-rate senders are struck).
22
+ - **Timers** (§5) — a per-player `cooldown` (server-side fire-rate ceiling: claims inside 0.08 s are
23
+ ignored) and a per-player `respawnDelay`.
24
+ - **`weaponItems` + the `weaponDamage` op** — the world declares WHICH published gun definitions it hosts
25
+ (`"<vaultAssetId>@<version>"`, ≤16); the room fetches each pinned definition itself (server-trusted,
26
+ checksummed) and `{"op":"weaponDamage","of":<player>,"target":<player>,"default":n}` reads the damage of
27
+ the gun that player is HOLDING (base × pellets, distance falloff applied when `target` is given). The
28
+ world still owns consequences — clamp it, scale it, or ignore it in your own rule. `default` covers a
29
+ player holding no declared gun. Using the op without `weaponItems` fails validation.
30
+ - **Entities** (§6) — destructible `target` blocks with an `hp` var and a `slot` tag; a per-slot tick rule
31
+ respawns any missing slot (destroy → ~1 s later it is back).
32
+ - **Broadcast** (§13) — `down { who, by }`: the server-confirmed kill feed (drive kill markers off THIS,
33
+ never off the client's predicted hit).
34
+ - **`respawn`** (§8) — the death rule starts `respawnDelay`; its elapse respawns and restores health.
35
+
36
+ ## 2. The manifest — `public/helix.json` (the `multiplayer` block)
37
+
38
+ ```json
39
+ "multiplayer": {
40
+ "authoritative": true,
41
+ "weaponItems": ["11111111-2222-4333-8444-555555555555@1"],
42
+ "state": {
43
+ "playerVars": {
44
+ "health": { "type": "number", "default": 100 },
45
+ "kills": { "type": "number", "default": 0 },
46
+ "lastHitBy": { "type": "ref", "of": "player" }
47
+ }
48
+ },
49
+ "entities": {
50
+ "target": { "vars": { "hp": { "type": "number", "default": 5 }, "slot": { "type": "number", "default": 0 } } }
51
+ },
52
+ "actions": {
53
+ "claimHit": { "args": { "target": { "type": "ref", "of": "player" } } },
54
+ "claimTargetHit": { "args": { "target": { "type": "ref", "of": "entity:target" } } }
55
+ },
56
+ "timers": { "cooldown": { "keyed": "player" }, "respawnDelay": { "keyed": "player" } },
57
+ "events": { "down": { "payload": { "who": { "type": "ref", "of": "player" }, "by": { "type": "ref", "of": "player" } } } },
58
+ "rules": [
59
+ {
60
+ "when": { "on": "action", "name": "claimHit" },
61
+ "if": { "op": "and", "of": [
62
+ { "op": "<", "a": { "op": "distance", "a": { "var": "self.position" }, "b": { "ref": { "var": "action.args.target" }, "var": "position" } }, "b": 60 },
63
+ { "op": "==", "a": { "op": "timerRemaining", "timer": "cooldown", "key": "self" }, "b": 0 }
64
+ ] },
65
+ "then": [
66
+ { "do": "setRef", "target": { "ref": { "var": "action.args.target" }, "var": "lastHitBy" }, "to": "self" },
67
+ { "do": "add", "target": { "ref": { "var": "action.args.target" }, "var": "health" },
68
+ "by": { "op": "*", "a": -1, "b": { "op": "weaponDamage", "of": "self", "target": { "var": "action.args.target" }, "default": 20 } } },
69
+ { "do": "startTimer", "timer": "cooldown", "seconds": 0.08, "key": "self" }
70
+ ]
71
+ },
72
+ {
73
+ "when": { "on": "action", "name": "claimTargetHit" },
74
+ "if": { "op": "==", "a": { "op": "timerRemaining", "timer": "cooldown", "key": "self" }, "b": 0 },
75
+ "then": [
76
+ { "do": "add", "target": { "ref": { "var": "action.args.target" }, "var": "hp" }, "by": -1 },
77
+ { "do": "startTimer", "timer": "cooldown", "seconds": 0.08, "key": "self" }
78
+ ]
79
+ },
80
+ {
81
+ "when": { "on": "varReached", "scope": "self", "var": "health", "cmp": "<=", "value": 0 },
82
+ "then": [
83
+ { "do": "add", "target": { "ref": { "var": "self.lastHitBy" }, "var": "kills" }, "by": 1 },
84
+ { "do": "broadcast", "event": "down", "to": "all", "payload": { "who": "self", "by": { "var": "self.lastHitBy" } } },
85
+ { "do": "startTimer", "timer": "respawnDelay", "seconds": 2.5, "key": "self" }
86
+ ]
87
+ },
88
+ {
89
+ "when": { "on": "timerElapsed", "timer": "respawnDelay" },
90
+ "then": [
91
+ { "do": "respawn", "player": "self", "to": { "vec3": [0, 0, -2] } },
92
+ { "do": "set", "target": "self.health", "to": 100 }
93
+ ]
94
+ },
95
+ {
96
+ "when": { "on": "tick", "everyN": 20 },
97
+ "if": { "op": "==", "a": { "op": "aggregate", "scope": "entities:target", "agg": "count", "as": "t", "where": { "op": "==", "a": { "ref": "t", "var": "slot" }, "b": 1 } }, "b": 0 },
98
+ "then": [{ "do": "spawnEntity", "kind": "target", "at": { "vec3": [-6, 0, 10] }, "vars": { "slot": 1 } }]
99
+ },
100
+ {
101
+ "when": { "on": "tick", "everyN": 1 },
102
+ "then": [{ "do": "forEachEntity", "kind": "target", "as": "t", "where": { "op": "<=", "a": { "ref": "t", "var": "hp" }, "b": 0 }, "then": [{ "do": "destroyEntity", "entity": "t" }] }]
103
+ }
104
+ ]
105
+ }
106
+ ```
107
+
108
+ Duplicate the slot-1 spawn rule per target position (slot 2, 3, … each with its own `at`). The pattern
109
+ "count of my slot == 0 → spawn me" IS the respawn — destroyed targets come back on the next check. Replace
110
+ the `weaponItems` example id with a supported, existing package-backed weapon definition pin (`assetId@version`); a world using ONLY hand-registered profiles drops `weaponItems` and hardcodes `"by": -20`.
111
+
112
+ **Why the claims carry no damage:** a client-named number is a client-named damage multiplier. The gun's
113
+ damage lives in its PUBLISHED, immutable definition; the room resolves it itself (`weaponItems`) off the
114
+ attachment the shooter is actually holding. The distance gate (60 m) and the cooldown timer are the
115
+ anti-cheat floor — keep both.
116
+
117
+ ## 3. The client — `src/main.ts` (delta from `hangout`)
118
+
119
+ The full recipe with every option explained is `read_doc({ name: "shooter-worlds" })`; this is the minimal
120
+ assembly. Prereqs: `gun-control` pinned + installed (bundle at the WORLD ROOT — see the recipe's §1),
121
+ a gun GLB in `public/props/`, its shot sounds in `public/fx/sounds/`.
122
+
123
+ ```ts
124
+ import { Helix } from '{{SDK_DEP_SPEC}}';
125
+ import {
126
+ CharacterMultiplayer, GunAudio, GunFxSystem, GunHitSystem, MuzzleFlashPool, RapierBody, ShellEjector, TracerPool,
127
+ applyWeaponProfile, createSpreadCrosshair, loadInstalledAbilities, registerWeaponProfile, resolveWeaponProfile,
128
+ } from '@helix/humanoid-character';
129
+
130
+ const SPAWN = { x: 0, y: 0, z: -2 };
131
+ const GUN_URL = new URL('props/blaster.glb', location.href).href; // ABSOLUTE — the ref replicates verbatim
132
+
133
+ // One world-supplied gun: cadence/ammo/spread by category, sounds by relative path. Published definitions
134
+ // replace this block with installWeaponItem(...) — see shooter-worlds §8.
135
+ registerWeaponProfile({
136
+ weapon: 'blaster', category: 'Rifle',
137
+ fire: { rpm: 480, mode: 'auto' }, ammo: { clip: 30, reserve: 90 },
138
+ damage: { base: 11, falloffM: [], sweepRadiusM: 0.02, maxRangeM: 100, pellets: 1, tags: [] },
139
+ spread: resolveWeaponProfile('KAL')?.spread ?? /* copy a category spread block */ undefined as never,
140
+ fx: { shell: 'rifle', muzzlePointM: [0, 0.05, 0.4] },
141
+ sounds: { shots: ['blaster/shot_01.ogg', 'blaster/shot_02.ogg'] },
142
+ });
143
+
144
+ const body = await RapierBody.create({ position: SPAWN });
145
+ // …your floor/walls/static colliders here…
146
+
147
+ const soundsBaseUrl = new URL('fx/sounds/', location.href).href;
148
+ const modulesBaseUrl = new URL('helix_modules/', document.baseURI).href;
149
+
150
+ // ONE audio, flash pool and shell ejector for the WHOLE world. Every GunFxSystem (local + each replica)
151
+ // writes into these three; only the LOCAL system gets the real objects and advances them — replicas get
152
+ // non-advancing shims (their update is dt-driven, per-character ticking decays FX N× too fast, and a
153
+ // per-replica GunAudio would open an AudioContext each — browsers cap those around six).
154
+ const audio = new GunAudio({ listenerTarget: camera });
155
+ const flashPool = new MuzzleFlashPool({ scene });
156
+ const shells = new ShellEjector({ scene, baseUrl: new URL('fx/shells/', location.href).href });
157
+ const sharedAudio = { preload: (u) => audio.preload(u), play: (u, o) => audio.play(u, o), setMasterVolume: (v) => audio.setMasterVolume(v), update: () => {}, dispose: () => {} };
158
+ const sharedFlash = { flash: (a, o) => flashPool.flash(a, o), update: () => {}, dispose: () => {} };
159
+ const sharedShells = { preload: (c) => shells.preload(c), eject: (c, o, r, u) => shells.eject(c, o, r, u), update: () => {}, dispose: () => {} };
160
+ const replicaFx = new Map(); // id → { character, player, fx, url, prop } — the per-frame weapon poll reads it
161
+
162
+ const mp = await CharacterMultiplayer.create({
163
+ helix: Helix, renderer, scene, camera, body, assetBase: SYSTEM_ASSET_BASE, spawn: SPAWN,
164
+ character: {
165
+ character: { health: { enabled: true } }, // the room's health drives the chassis
166
+ bindings: { 'gun-control.equip': 'KeyH' }, // off Digit1 (equip-slot collision)
167
+ },
168
+ replica: {
169
+ decorate: (character, player, id) => { // replicas run the SAME bundle, remote-driven
170
+ character.setEnabled(false);
171
+ const fx = new GunFxSystem({ scene, camera, soundsBaseUrl, audio: sharedAudio, flashPool: sharedFlash, shells: sharedShells });
172
+ fx.attachTo(character);
173
+ const entry = { character, player, fx, url: null, prop: null };
174
+ replicaFx.set(id, entry);
175
+ void loadInstalledAbilities(modulesBaseUrl, character)
176
+ .then(() => character.services.config.set('gun-control.remoteDriven', true))
177
+ .finally(() => character.setEnabled(true));
178
+ // Identity-guarded: the facade can build a seat twice back-to-back — a losing build's dispose
179
+ // must not evict the winner's live entry.
180
+ return { dispose: () => { fx.dispose(); if (replicaFx.get(id) === entry) replicaFx.delete(id); } };
181
+ },
182
+ },
183
+ });
184
+
185
+ await loadInstalledAbilities(modulesBaseUrl, mp.local); // BEFORE the first mp.update()
186
+
187
+ // Bundle blackboard keys exist only while the bundle is installed — the has() guard keeps a per-frame
188
+ // read from ever throwing inside the render loop (an uncaught throw there freezes the world for good).
189
+ const bb = (key) => (mp.local.blackboard.has(key) ? mp.local.blackboard.get(key) : undefined);
190
+ // The weapon a player replicated onto the grip socket, or null (attachments is an Iterable, not an array).
191
+ const gripAsset = (player) => {
192
+ for (const a of player?.attachments ?? []) if (a.socket === 'hand_r.grip') return a.asset;
193
+ return null;
194
+ };
195
+
196
+ const gunFx = new GunFxSystem({ scene, camera, soundsBaseUrl, audio, flashPool, shells });
197
+ gunFx.attachTo(mp.local);
198
+ const tracers = new TracerPool({ scene });
199
+ const gunHit = new GunHitSystem({
200
+ camera, body,
201
+ walkSpeedMps: mp.local.services.config.get('locomotion.walkSpeed'),
202
+ maxSpeedMps: mp.local.services.config.get('locomotion.runSpeed'),
203
+ muzzle: (out) => gunFx.muzzleWorld(out),
204
+ players: function* () { for (const [id, r] of replicaFx) yield { id, position: r.character.model.position }; },
205
+ onPlayerHit: ({ id, point, claim }) => {
206
+ tracers.spawn(tracerFrom(point), point);
207
+ if (claim) { mp.room?.sendAction('claimHit', { target: id }); crosshair.hit('hit'); }
208
+ },
209
+ onWorldHit: ({ point }) => tracers.spawn(tracerFrom(point), point),
210
+ onMiss: ({ end }) => tracers.spawn(tracerFrom(end), end),
211
+ });
212
+ gunHit.attachTo(mp.local);
213
+ const crosshair = createSpreadCrosshair({
214
+ spreadDeg: () => gunHit.spreadDeg, fovYDeg: () => camera.fov,
215
+ visible: () => bb('gunDrawn') === true,
216
+ });
217
+
218
+ // Equip drawn: attach (replicates) → profile → FX/hit wiring → the draw tap (no `drawn` config exists).
219
+ const held = await mp.attach(GUN_URL, 'hand_r.grip', { preset: 'rifle' });
220
+ const profile = resolveWeaponProfile(GUN_URL);
221
+ if (profile) applyWeaponProfile(mp.local, profile);
222
+ gunFx.setWeapon(GUN_URL, held.object);
223
+ gunHit.setWeapon(GUN_URL);
224
+ mp.local.services.input.setVirtualButton('gun-control.equip', true);
225
+ mp.local.services.input.setVirtualButton('gun-control.equip', null);
226
+ mp.local.events.on('revived', () => { /* same two-line tap — death holsters */ });
227
+
228
+ // The server-confirmed kill feed — the authoritative marker (the predicted one can over-report).
229
+ mp.room?.onMessage('down', ({ who, by }) => { if (by === mp.room?.sessionId && who !== by) crosshair.hit('lethal'); });
230
+
231
+ renderer.setAnimationLoop(() => {
232
+ const dt = Math.min(clock.getDelta(), 0.1);
233
+ mp.update(dt); // shots latch during the character tick…
234
+ gunFx.update(dt); // …and resolve after it — keep this order
235
+ gunHit.update(dt);
236
+ tracers.update(dt);
237
+ crosshair.update(dt);
238
+ // REQUIRED — replica weapons resolve by poll, or other players' guns are silent, flashless and held
239
+ // two-handed. The wire url lands with the attachment set but the prop GLB loads async: wait for the
240
+ // PAIR, re-resolve on either edge, and tick each replica's FX system after its character updated.
241
+ for (const r of replicaFx.values()) {
242
+ const url = gripAsset(r.player); // helper below — attachments is an Iterable, not an array
243
+ const prop = r.character.services.sockets?.anchorOf('hand_r.grip')?.children[0] ?? null;
244
+ if (url !== r.url || prop !== r.prop) {
245
+ r.url = url; r.prop = prop;
246
+ const ready = url !== null && prop !== null;
247
+ const profile = url !== null ? resolveWeaponProfile(url) : null;
248
+ r.fx.setWeapon(ready ? url : null, ready ? prop : null); // muzzle point, shot sounds, shells
249
+ if (profile !== null) {
250
+ try { applyWeaponProfile(r.character, profile); } // stance family (one-handed!), placement
251
+ catch (err) { console.warn('replica profile failed:', err); } // untrusted item data — config.set throws
252
+ }
253
+ }
254
+ r.fx.update(dt);
255
+ }
256
+ renderer.render(scene, camera);
257
+ });
258
+ ```
259
+
260
+ **Footguns:** the frame order is a contract (shots resolve AFTER `mp.update`); the replica weapon poll
261
+ above is NOT optional — `GunFxSystem` without `setWeapon` is silently inert, and `shotSeq` still driving
262
+ the recoil animation hides the omission; one claim per trigger — the room strikes over-rate action
263
+ senders; read `walkSpeedMps`/`maxSpeedMps` from live locomotion config, never literals; the health HUD
264
+ reads `mp.room.me.num('health', 100)` fresh each frame; ragdoll needs nothing — death hands the body to
265
+ physics automatically (tune via `character.ragdoll`, recipe §6), and NEVER wrap it in an input-context
266
+ push (that releases pointer lock — the shooter guide §6/§7 has both rules in full); destructible
267
+ targets are `mp.entities()` + `mp.damageables({ action: 'claimTargetHit' })` — the recipe's §4 shows the
268
+ registration.
269
+
270
+ ## 4. Build, validate, publish
271
+
272
+ `npm install` → **`install_world_packages`** (MCP tool) → `npm run build` → `validate_world` on `dist/` →
273
+ **`helix verify-subpath dist`** (REQUIRED — the only local gate that serves the bundle from a nested path,
274
+ the way a published world is actually served) → `whoami` → `publish_world`. Verify with TWO clients —
275
+ every combat bug worth finding is invisible solo.