@hypersoniclabs/helix-mcp 0.2.5 → 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.
- package/README.md +81 -11
- package/dist/continuumCanary.d.ts +17 -0
- package/dist/continuumCanary.js +17 -0
- package/dist/continuumCanary.js.map +1 -0
- package/dist/server.d.ts +14 -1
- package/dist/server.js +4058 -162
- package/dist/server.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/dist/vehicleTools.d.ts +86 -0
- package/dist/vehicleTools.js +229 -0
- package/dist/vehicleTools.js.map +1 -0
- package/docs/avatar-face.md +115 -0
- package/docs/bridge.md +98 -0
- package/docs/bring-your-world.md +117 -0
- package/docs/catalog.md +69 -1
- package/docs/character-animation.md +442 -0
- package/docs/character-attachments.md +166 -0
- package/docs/character-world.md +785 -130
- package/docs/continuum.md +153 -0
- package/docs/items.md +73 -0
- package/docs/lighting-world.md +667 -0
- package/docs/locomotion-clip-spec.md +294 -0
- package/docs/manifest.md +31 -6
- package/docs/multiplayer-logic.md +460 -17
- package/docs/multiplayer-templates/chrono-orchard.md +36 -22
- package/docs/multiplayer-templates/collect-a-thon.md +28 -38
- package/docs/multiplayer-templates/collections.md +24 -24
- package/docs/multiplayer-templates/hangout.md +124 -111
- package/docs/multiplayer-templates/npc-wave.md +310 -0
- package/docs/multiplayer-templates/obby.md +13 -16
- package/docs/multiplayer-templates/persistent-progress.md +218 -0
- package/docs/multiplayer-templates/physics-bumper.md +27 -9
- package/docs/multiplayer-templates/physics-football.md +22 -8
- package/docs/multiplayer-templates/relic-bearers.md +12 -15
- package/docs/multiplayer-templates/server-motion.md +16 -19
- package/docs/multiplayer-templates/shooter-range.md +275 -0
- package/docs/multiplayer-templates/team-control.md +28 -15
- package/docs/multiplayer-templates/turn-arena.md +31 -22
- package/docs/multiplayer-templates/voice-radio.md +166 -0
- package/docs/multiplayer-templates/wave-survival.md +7 -8
- package/docs/multiplayer-templates/world-shop.md +240 -0
- package/docs/multiplayer-world.md +222 -129
- package/docs/npc-world.md +623 -0
- package/docs/publishing.md +108 -28
- package/docs/purchases.md +223 -0
- package/docs/scene-performance.md +64 -0
- package/docs/screenshots.md +140 -0
- package/docs/sdk.md +324 -5
- package/docs/shooter-worlds.md +537 -0
- package/docs/terrain.md +173 -0
- package/docs/upgrades.md +324 -0
- package/docs/vehicles.md +727 -0
- package/docs/world-inspect.md +156 -0
- package/docs/world-look.md +241 -0
- package/docs/world-recipe.md +65 -6
- package/package.json +15 -4
- package/skills/README.md +91 -0
- package/skills/helix-assets/SKILL.md +491 -0
- package/skills/helix-assets/references/asset-sources.md +143 -0
- package/skills/helix-assets/references/vault-api.md +105 -0
- package/skills/helix-avatar-qa/SKILL.md +85 -0
- package/skills/helix-avatars/SKILL.md +206 -0
- package/skills/helix-avatars/references/contract.md +166 -0
- package/skills/helix-avatars/references/dynamics.md +367 -0
- package/skills/helix-avatars/references/face.md +50 -0
- package/skills/helix-avatars/references/publish.md +76 -0
- package/skills/helix-avatars/references/qa.md +251 -0
- package/skills/helix-avatars/references/rigging.md +88 -0
- package/skills/helix-avatars/references/source-generated.md +190 -0
- package/skills/helix-avatars/references/source-model.md +90 -0
- package/skills/helix-avatars/references/source-rigid.md +90 -0
- package/skills/helix-avatars/references/source-vrm.md +61 -0
- package/skills/helix-gauntlet/SKILL.md +128 -0
- package/skills/helix-multiplayer/SKILL.md +150 -0
- package/skills/helix-multiplayer/references/dsl-capability-map.md +107 -0
- package/skills/helix-vehicles/SKILL.md +218 -0
- package/skills/helix-vehicles/references/addons.md +212 -0
- package/skills/helix-vehicles/references/appearance.md +339 -0
- package/skills/helix-vehicles/references/audio-import.md +138 -0
- package/skills/helix-vehicles/references/audio.md +580 -0
- package/skills/helix-vehicles/references/cabin.md +225 -0
- package/skills/helix-vehicles/references/host-manifest.md +174 -0
- package/skills/helix-vehicles/references/physics-from-specs.md +463 -0
- package/skills/helix-vehicles/references/publish.md +214 -0
- package/skills/helix-vehicles/references/qa.md +177 -0
- package/skills/helix-vehicles/references/reference-package.json +3481 -0
- package/skills/helix-vehicles/references/reference-package.md +69 -0
- package/skills/helix-vehicles/references/source-beamng.md +167 -0
- package/skills/helix-vehicles/references/source-concept.md +38 -0
- package/skills/helix-vehicles/references/source-model.md +100 -0
- package/skills/helix-vehicles/references/source-scratch.md +60 -0
- package/skills/helix-world-build/SKILL.md +376 -0
- package/skills/helix-world-build/references/config-gates.md +104 -0
- package/skills/helix-world-director/SKILL.md +210 -0
- package/skills/helix-world-qa/SKILL.md +371 -0
- package/skills/helix-world-qa/references/perf-budgets.md +240 -0
- package/skills/helix-world-qa/references/perf-handle.md +125 -0
- 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.
|
|
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) { //
|
|
133
|
-
|
|
134
|
-
|
|
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); `
|
|
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,
|
|
8
|
-
|
|
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.
|
|
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
|
|
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 (
|
|
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
|
|
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.
|
|
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
|
|
94
|
-
|
|
95
|
-
|
|
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 =
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
105
|
-
|
|
106
|
-
`
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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 `
|
|
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.
|