@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.
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
@@ -0,0 +1,537 @@
1
+ # Shooter worlds — guns, damage, death & ragdoll (the combat recipe)
2
+
3
+ Read this when a world needs GUNS: aiming, firing, ammo, hit detection, damage, death and respawn — first
4
+ person or third person, single-player or multiplayer. The platform ships the whole gun stack: the
5
+ **`gun-control`** ability (stance, ADS, fire modes, ammo, reload, recoil), a **weapon-profile** data layer
6
+ (a gun is DATA, not code), world-side **FX/hit systems** (muzzle flash, tracers, shells, positional audio, a
7
+ spread-reflecting crosshair), a **health/death chassis** driven by server rules, and a physics **ragdoll**
8
+ that finishes every death. You write no netcode and no server code — gun state and damage ride the same
9
+ declarative room as everything else.
10
+
11
+ **Prerequisites:** installed `humanoid-character` `^0.3` and the `gun-control` ability `^0.3` (run
12
+ `check_for_updates` + `get_package_manifest("gun-control")` first — the configSchema is the authoritative
13
+ knob list). The room half needs a multiplayer world (`get_started({ kind: "multiplayer" })`); the worked
14
+ room config is the **`shooter-range`** template (`read_template({ name: "shooter-range" })`).
15
+
16
+ ## 1. Install gun-control — the one bundle-layout rule
17
+
18
+ Pin the ability in `helix.json` next to the system pin, then `install_world_packages`:
19
+
20
+ ```json
21
+ "systems": { "humanoid-character": "^0.3" },
22
+ "abilities": { "gun-control": "^0.3" }
23
+ ```
24
+
25
+ Take the gun-control version `get_package_manifest({ slug: "gun-control" })` reports for the system line you
26
+ pinned — an ability declares the system range it was built against and refuses to load at runtime outside it.
27
+
28
+ Load it in world code BEFORE the first `mp.update()` — the ability manager starts on the character's first
29
+ update and refuses bundles after that:
30
+
31
+ ```ts
32
+ import { loadInstalledAbilities } from '@helix/humanoid-character';
33
+ const modulesBaseUrl = new URL('helix_modules/', document.baseURI).href;
34
+ await loadInstalledAbilities(modulesBaseUrl, mp.local);
35
+ ```
36
+
37
+ > **The bundle must be served where vite TRANSFORMS it — the world root, not `public/`.** gun-control's
38
+ > entry imports bare `three`; static serving from `public/` cannot resolve that import and the ability
39
+ > silently never loads (holstered forever, no error). If your `helix_modules/` landed under `public/`, move
40
+ > it to the project root (keep `installed.json` beside it) so the dev server and build both transform the
41
+ > entry. Replicas load the SAME bundle (§7), so this rule decides whether other players see gun stances
42
+ > at all.
43
+
44
+ ## 2. The gun-control contract — config, blackboard, events, actions
45
+
46
+ **Config** (29 keys — read them from `get_package_manifest("gun-control")`, never guess): fire
47
+ (`fireCooldown`, `fireMode`, `magazineSize`, `reserveAmmo`, `reloadDuration`), ADS (`adsFovScale`,
48
+ `adsShoulder`, `aimYawDeadzoneDeg`, …), shooter movement rules (`firingSpeedTier`, `firingSpeedTailS`,
49
+ `adsSpeedTier`, `sprintToFireDelayS` — creator-tunable speed policy while fighting), baked camera recoil
50
+ (`recoilPitchDeg`, `recoilPatternSeed`, …), `spreadScale` (a world-global accuracy dial), `grip`
51
+ (`one-handed`/`two-handed` stance family), and `remoteDriven` (replica mode — §7; never set it on the local
52
+ player). Set initial values via `Character.create` config or live via `config.set('gun-control.key', v)`.
53
+
54
+ **Blackboard it publishes** (read with `character.blackboard.get`, guarded — the keys exist only once the
55
+ bundle loaded):
56
+
57
+ | Key | Type | Meaning |
58
+ |---|---|---|
59
+ | `gunDrawn` | boolean | gun up (drawn) vs holstered |
60
+ | `aiming` | boolean | ADS intent (secondary held) |
61
+ | `aimFraction` | number | the eased 0..1 ADS blend — gate UI on it, not on `aiming` |
62
+ | `firing` | boolean | ONE-TICK pulse per shot — subscribe to the `gunFired` event instead of polling this |
63
+ | `weaponEngaged` | boolean | actively fighting (ADS or recent fire) — drives stance/facing |
64
+ | `reloading` | boolean | the reload window |
65
+ | `oneHanded` | boolean | the active stance family |
66
+ | `ammoInClip` / `ammoReserve` | number | **−1 = not tracked** (ammo off or holstered); reserve −1 with ammo ON = infinite |
67
+ | `spreadScale` | number | the live accuracy dial (config × any runtime writes) |
68
+
69
+ **Events on `character.events`:** `gunFired` (per shot — payload-less BY DESIGN; resolve muzzle/aim AFTER
70
+ `mp.update()`, §4), `gunDryFired`, `gunReloadStarted`.
71
+
72
+ **Actions:** it binds the STANDARD ids `primary` (fire), `secondary` (ADS), `reload` — so pads and touch
73
+ work with zero effort — plus its own `gun-control.equip` draw/holster toggle, which **defaults to Digit1 and
74
+ collides with the equip tracker's `ability1`**. Any world using equip slots must remap it in the chassis
75
+ `bindings` config (the range uses `KeyH`).
76
+
77
+ **There is no `drawn` config.** Draw/holster is action-owned state. To force the shooter state from world
78
+ code (equip a gun already drawn, re-draw after respawn), tap the action through the input router — one edge,
79
+ no key involved:
80
+
81
+ ```ts
82
+ const drawSoon = () => {
83
+ if (bb('gunDrawn') === true) return;
84
+ mp.local.services.input.setVirtualButton('gun-control.equip', true);
85
+ mp.local.services.input.setVirtualButton('gun-control.equip', null);
86
+ };
87
+ ```
88
+
89
+ ## 3. A gun is DATA — weapon profiles
90
+
91
+ A weapon profile carries everything per-gun: cadence + fire mode, clip/reserve, the damage block, the spread
92
+ model, per-gun placement/sway patches, grip seating, FX points and sounds. The engine resolves profiles **by
93
+ asset URL basename** and applies them at equip:
94
+
95
+ ```ts
96
+ import { registerWeaponProfile, aliasWeaponProfile, resolveWeaponProfile, applyWeaponProfile, listWeaponProfiles } from '@helix/humanoid-character';
97
+
98
+ registerWeaponProfile(myProfile); // world-supplied gun (or override a stock one by name)
99
+ const p = resolveWeaponProfile(gunUrl); // by URL or bare name
100
+ if (p) applyWeaponProfile(mp.local, p); // writes gun-control config + the ADS/sway placement patches
101
+ listWeaponProfiles(); // the whole arsenal (a gun wall, a loadout menu)
102
+ ```
103
+
104
+ - **`aliasWeaponProfile(source, weapon)` is MANDATORY for opaque URLs.** Every resolve site keys off the
105
+ attachment URL's basename; a CDN URL whose basename is a hash resolves to NOTHING — the gun renders but
106
+ fires silently with default spread and no per-gun feel. Published gun definitions (§8) alias for you;
107
+ hand-registered CDN guns must call it before `attach()`.
108
+ - **`category` does most of the work.** Naming `Rifle` / `Pistol` / `SMG` / `Shotgun` / `Sniper` / `LMG`
109
+ inherits the calibrated spread model, reload foley, grip preset, one/two-handed default and shell class.
110
+ A conventionally-authored mesh + a category is a playable gun; per-gun `hold`/`ads`/`sway` blocks are
111
+ polish, not function. `Launcher` and `Grenade` are the explosive pair: they carry a `fire.kind`
112
+ (`projectile` / `thrown`) that decides which system flies the round, and their blast is world-authored (§9).
113
+ - **Re-apply on every swap.** `applyWeaponProfile` writes the FULL effective set (a gun silent on a field
114
+ gets the baseline, never the previous gun's value) and resets the placement caches so locators re-resolve
115
+ against the prop just attached. Call it after each `attach`, not once at boot.
116
+ - The profile's `damage` block is CLIENT-REFERENCE ONLY on the client (`pellets`, `maxRangeM`,
117
+ `sweepRadiusM` feed the hit ray). Actual damage is server-side — the room reads the PUBLISHED
118
+ definition's damage block through `weaponItems` + the `weaponDamage` op (see the `shooter-range`
119
+ template). A client can never name its own damage number.
120
+
121
+ **Sounds ride one of two lanes — and `@helix/humanoid-character` ships NO audio bytes, ever.** A profile
122
+ carries relative path *strings*; the lane decides where those bytes must actually exist:
123
+
124
+ | | Stock lane | Items lane |
125
+ |---|---|---|
126
+ | Source of truth | the stock profile tables — refs like `Pistol/Fierro/A_Fierro_Shot_001.wav` | the published definition, audio as vault refs |
127
+ | Where the bytes live | YOUR world's `public/fx/sounds/`, uploaded with the bundle | the Vault CDN; nothing bundled per world |
128
+ | Resolved by | `GunFxSystem`, against the `soundsBaseUrl` you pass it (§4) | `installWeaponItem`, which rewrites the refs to CDN URLs (§8) |
129
+ | Cost | the whole pack ships with the world, even for one gun in use | per definition pinned; audio deduped across guns |
130
+
131
+ > **NEVER hand-author a `WeaponProfile` object.** Use `resolveWeaponProfile('<Name>')` for a stock gun (merge
132
+ > your own damage/spread ON TOP of what it returns) or `installWeaponItem` for a published definition. Mixing
133
+ > lanes — a Vault prop mesh plus a typed-out profile — loses the audio SILENTLY: `GunFxSystem` plays exactly
134
+ > what the profile lists, an empty `sounds.shots` array plays nothing, and nothing warns. A LIVE world shipped
135
+ > precisely that (`sounds: { shots: [] }`) while every WAV that gun needed sat reachable in the same build.
136
+
137
+ ## 4. The world-side systems — FX, hit chain, crosshair
138
+
139
+ Seven composable systems ship with the engine. A minimal shooter wires four (`GunFxSystem`, `GunHitSystem`,
140
+ the crosshair, `TracerPool`); the rest are injectable shared pools for multi-character worlds:
141
+
142
+ ```ts
143
+ import { GunAudio, GunFxSystem, GunHitSystem, MuzzleFlashPool, ShellEjector, TracerPool, createSpreadCrosshair } from '@helix/humanoid-character';
144
+
145
+ const gunFx = new GunFxSystem({ scene, camera, soundsBaseUrl });
146
+ gunFx.attachTo(mp.local);
147
+ const gunHit = new GunHitSystem({
148
+ camera,
149
+ body, // your RapierBody — static geometry for the ray
150
+ // READ these from live config, never literals — hardcoding desyncs the moment locomotion is retuned:
151
+ walkSpeedMps: mp.local.services.config.get('locomotion.walkSpeed'),
152
+ maxSpeedMps: mp.local.services.config.get('locomotion.runSpeed'),
153
+ muzzle: (out) => gunFx.muzzleWorld(out),
154
+ players: function* () { /* yield { id, position, crouched, ragdolled, head } per replica — see below */ },
155
+ onPlayerHit: ({ id, point, claim, part }) => { if (claim) mp.room?.sendAction('claimHit', { target: id, bodyPart: part }); },
156
+ onWorldHit: ({ id, point, claim }) => { /* impact FX; claim → dmg?.hit(id, point) */ },
157
+ onMiss: ({ end }) => { /* tracer to end */ },
158
+ });
159
+ gunHit.attachTo(mp.local);
160
+ const tracers = new TracerPool({ scene });
161
+ const crosshair = createSpreadCrosshair({
162
+ spreadDeg: () => gunHit.spreadDeg,
163
+ fovYDeg: () => camera.fov,
164
+ visible: () => bb('gunDrawn') === true,
165
+ });
166
+ ```
167
+
168
+ **The candidate shape decides whether cover works.** Each `players` entry is
169
+ `{ id, position, crouched?, ragdolled?, head? }`. `position` is the FEET. `crouched` shrinks the hit
170
+ capsule (1.8 → 1.2 m by default) so a player ducked behind cover is actually safe; `ragdolled` lays it
171
+ down low — feed the WIRE flag (`mp.room.player(id)`'s `ragdolled`), not the replica's own rig state, since
172
+ the sender's corpse-follow is what put the replicated position at the pelvis. `head: () => Vector3 | null`
173
+ is a live head-socket read (`services.sockets.anchorOf('head').getWorldPosition(...)` — recompute through
174
+ ancestors, the character tick leaves `matrixWorld` stale) that arms the headshot sphere; `onPlayerHit`
175
+ then reports `part: 'head' | 'body'`. Omit the flags and every target tests as a standing capsule — the
176
+ "crouched behind the crate but still dying" bug. Send the part on as the `bodyPart` ENUM arg, never a
177
+ number: the ROOM owns every multiplier (§ damage ops below).
178
+
179
+ **The same generator is how an NPC shoots BACK** — an armed NPC runs its own `GunHitSystem` whose one
180
+ candidate is the player, traced from a proxy camera standing at the NPC's eye:
181
+ `read_doc({ name: "npc-world" })` §4a.
182
+
183
+ All of these systems ride `mp.local` and survive a mid-room avatar swap — never re-attach or rebuild them
184
+ on `Helix.avatar` events (see `character-world.md`, "Mid-room avatar swaps").
185
+
186
+ **The frame-order contract** — `gunFired` latches mid-ability-tick against LAST frame's matrices, so
187
+ everything gun resolves AFTER the character tick:
188
+
189
+ ```ts
190
+ mp.update(dt); // characters tick — shots latch
191
+ gunFx.update(dt); // latched shots resolve against THIS frame's muzzle
192
+ gunHit.update(dt); // the shot ray fires against current replica transforms
193
+ tracers.update(dt);
194
+ crosshair.update(dt); // the arms describe THIS frame's cone
195
+ ```
196
+
197
+ **On weapon change call `setWeapon` on BOTH** (`gunFx.setWeapon(url, propObject)`,
198
+ `gunHit.setWeapon(url)`) — same profile, different consumers (FX points/sounds vs spread/range).
199
+
200
+ **Shell casings come from the weapon itself.** Every official-arsenal weapon prop embeds its ejected casing
201
+ as a `Shell` node; `setWeapon` finds it, hides it on the held gun, and ejects copies of it — no
202
+ configuration, this works in any world. `shellsBaseUrl` (serving `SM_<class>shell.glb` per shell class) is
203
+ the FALLBACK lane for props without the node; with neither, shell ejection is silently absent while every
204
+ other effect still fires — so a gun with flash and sound but no brass means the prop predates embedded
205
+ shells and the world serves no fallback. A custom weapon opts in by authoring a mesh-less node named `Shell`
206
+ (exact name — not `ShellChamber`) at ZERO scale, with the casing mesh at real size in a child node, seated at
207
+ the eject port. Zero scale keeps the casing invisible on display/rack props (only the held gun runs the FX
208
+ resolve); the engine re-arms the ejected template to unit scale, and the profile's `fx.shellEjectM` still
209
+ overrides the node's position when authored.
210
+
211
+ **The one-claim rule.** A trigger pull may be several rays (a shotgun cartridge is 9 pellets — FX fire per
212
+ pellet) but flags **exactly one** `claim`. Send exactly one declared action per claim.
213
+ **The room strikes senders that exceed the declared action rate — claiming per pellet gets the player
214
+ kicked.** The predicted hitmarker (`crosshair.hit('hit')`) may over-report (the server can still refuse);
215
+ treat the server broadcast (`down` etc.) as the authoritative kill marker (`crosshair.hit('lethal')`).
216
+
217
+ **Destructible world objects:** register them with `const dmg = mp.damageables({ action: 'claimTargetHit' })`
218
+ — `dmg.register(id, { object3d, onHit, onDestroyed })`, and `dmg.hit(id, point)` sends the one claim.
219
+ Server-owned targets come through `mp.entities()` with their hp in entity vars; the `shooter-range` template
220
+ shows both plus the respawn rules.
221
+
222
+ ## 5. Health, death & respawn — the room owns the number
223
+
224
+ Enable the chassis mirror and declare the var; the facade wires them together on the local player AND every
225
+ replica:
226
+
227
+ ```ts
228
+ // world code (Character config):
229
+ character: { health: { enabled: true } }
230
+ ```
231
+
232
+ ```json
233
+ // multiplayer.json: the room OWNS health — rules mutate it, clients only read
234
+ "state": { "playerVars": { "health": { "type": "number", "default": 100 } } }
235
+ ```
236
+
237
+ The chassis then gives you: blackboard `health` / `dead` / `deathDir`, events `healthChanged{value,previous}`,
238
+ `died`, `revived`, `respawned`, and `character.setHealth(v)` for single-player worlds (in multiplayer never
239
+ write health client-side — send claims, let rules subtract). Death gates input, plays the directional stagger
240
+ and hands the body to the ragdoll (§6); the respawn rule's server move lands through the reconciler.
241
+ The full claim → damage → death → respawn rule anatomy lives in the `shooter-range` template.
242
+
243
+ ## 6. Ragdoll — death physics AND a world mechanic
244
+
245
+ Every character carries a client-side physics ragdoll (Rapier), **on by default in any world with health**:
246
+ at the death instant physics takes the body and the stagger cross-fades into the fall — no config needed.
247
+ Tune or disable via the chassis block:
248
+
249
+ ```ts
250
+ character: { ragdoll: { enabled: true, blendSeconds: 0.35, launchSpeed: 2.5, deathCamera: 'third-person' } }
251
+ ```
252
+
253
+ `deathCamera: 'third-person'` pulls a first-person victim out to watch their own body fall (`'none'` opts
254
+ out). The kill shove direction comes from the attacker automatically in multiplayer.
255
+
256
+ **Ragdoll is also a WORLD MECHANIC**, not just death:
257
+
258
+ ```ts
259
+ mp.local.ragdoll.enter({ launch: { x: 1, z: 0 }, speedMps: 4 }); // go limp (knockout, launch pad, comedy)
260
+ mp.local.ragdoll.exit(); // get back up (instant)
261
+ mp.local.ragdoll.active // is physics holding the body?
262
+ ```
263
+
264
+ `enter()` refuses while dead (death owns that body); `exit()` no-ops while dead (revive restores). While
265
+ limp, locomotion input is gated — the player cannot walk the capsule away. The state **replicates
266
+ automatically**: other players see the body go limp, late joiners see an already-limp player, and any world
267
+ can READ it — `room.state.players[id].ragdolled` — to build mechanics on top (a medic reviving downed
268
+ players, a "wake up" minigame).
269
+
270
+ **Never wrap a ragdoll/knockout in an input-context push** (`input.pushContext(...)`). Suspending the
271
+ `look` action deliberately releases pointer lock — that is the menu rule, and re-acquiring needs a real
272
+ user click, so the victim's cursor pops out and they must click the world to keep playing. Movement is
273
+ already gated while `ragdolled`; if the camera must freeze too, set
274
+ `config.set('character.camera.allowRotate', false)` for the duration and restore it on exit — that never
275
+ touches the lock (only toggle it while the lock is held: while false, click-to-relock refuses).
276
+
277
+ ## 7. Multiplayer — what replicates, and the replica recipe
278
+
279
+ Gun state rides the normal state upload — you send nothing by hand. Replicated per player and readable by
280
+ any world: `gunDrawn`, `aiming`, `reloading` (booleans), `shotSeq` (a monotonic shot counter), `ragdolled`.
281
+ **`shotSeq` semantics:** state is coalesced at 10/20 Hz, so react to the DELTA (fire `min(delta, cap)` FX),
282
+ never assume +1, and re-anchor when the delta is non-positive (a reconnect restarts it).
283
+
284
+ For other players to show gun STANCES (not just the prop), each replica runs the SAME gun-control bundle in
285
+ `remoteDriven` mode — the wire drives its stance instead of local input. The recipe, inside
286
+ `replica.decorate` — keep `player` in a per-replica record, the weapon poll below needs it:
287
+
288
+ ```ts
289
+ const replicaFx = new Map(); // id → { character, player, fx, url, prop }
290
+
291
+ decorate: (character, player, id) => {
292
+ character.setEnabled(false); // freeze until the bundle registers
293
+ const fx = new GunFxSystem({ scene, camera, soundsBaseUrl, audio: sharedAudio, flashPool: sharedFlash, shells: sharedShells });
294
+ fx.attachTo(character);
295
+ const entry = { character, player, fx, url: null, prop: null };
296
+ replicaFx.set(id, entry);
297
+ void loadInstalledAbilities(modulesBaseUrl, character)
298
+ .then(() => character.services.config.set('gun-control.remoteDriven', true))
299
+ .finally(() => character.setEnabled(true)); // stance converges a few frames late, by design
300
+ return { dispose: () => { fx.dispose(); if (replicaFx.get(id) === entry) replicaFx.delete(id); } };
301
+ }
302
+ ```
303
+
304
+ **The recipe above is HALF of replica guns — the weapon must also be SET, every frame.** A `GunFxSystem`
305
+ that never got `setWeapon` is silently inert (no muzzle flash, no shot sound, no shells — `shotSeq` still
306
+ drives the recoil animation, which makes the omission easy to miss), and a replica whose profile was never
307
+ applied keeps `gun-control.grip` at the `two-handed` schema default — a pistol held like a rifle. The wire
308
+ URL arrives with the attachment set but the prop GLB loads asynchronously, so poll the PAIR each frame
309
+ (after `mp.update`) and re-resolve on either edge:
310
+
311
+ ```ts
312
+ const gripAsset = (player) => { // player.attachments is an Iterable, not an array
313
+ for (const a of player?.attachments ?? []) if (a.socket === 'hand_r.grip') return a.asset;
314
+ return null;
315
+ };
316
+
317
+ for (const r of replicaFx.values()) {
318
+ const url = gripAsset(r.player);
319
+ const prop = r.character.services.sockets?.anchorOf('hand_r.grip')?.children[0] ?? null;
320
+ if (url !== r.url || prop !== r.prop) {
321
+ r.url = url; r.prop = prop;
322
+ const ready = url !== null && prop !== null;
323
+ const profile = url !== null ? resolveWeaponProfile(url) : null;
324
+ r.fx.setWeapon(ready ? url : null, ready ? prop : null); // muzzle point, shot sounds, shell class
325
+ if (profile !== null) {
326
+ try { applyWeaponProfile(r.character, profile); } // stance family (one-handed!), placement, sway
327
+ catch (err) { console.warn('replica profile failed:', err); } // untrusted item data — config.set throws
328
+ }
329
+ }
330
+ r.fx.update(dt);
331
+ }
332
+ ```
333
+
334
+ The shared pools (`GunAudio`, `MuzzleFlashPool`, `ShellEjector`) are constructed ONCE for the world and
335
+ injected into every `GunFxSystem` via the `audio`/`flashPool`/`shells` options — `GunAudio` lazily opens an
336
+ AudioContext (browsers cap those around six, so per-replica pools break at seven players), and only the
337
+ local system advances them (their update is dt-driven; ticking them per character decays FX N× too fast).
338
+ The held gun itself replicates through the attachments system (§ below).
339
+
340
+ ## 8. Where guns come from — three lanes
341
+
342
+ 1. **Published gun definitions (the platform lane).** A gun published as an `interactive_item` Vault asset
343
+ carries its mesh, sounds and full profile. **Prefer OFFICIAL definitions**:
344
+ `search_assets({ kinds: ["interactive_item"], official: true })` first — that tier is platform-vouched
345
+ (bench-calibrated profiles, convention-correct meshes and locators, mono sounds) — and widen to community
346
+ definitions only when the official set has no fit; a community gun deserves a placement pass on the range
347
+ before shipping (`item-def inspect` shows the `OFFICIAL` badge). Consume:
348
+
349
+ ```ts
350
+ import { installWeaponItem } from '@helix/humanoid-character';
351
+ const { propUrl, preset } = installWeaponItem(descriptor, assetUrls); // validates, registers, ALIASES the URL
352
+ await mp.attach(propUrl, 'hand_r.grip', { preset });
353
+ ```
354
+
355
+ `installWeaponItem` must run BEFORE `attach()` (grip seating resolves at attach time). It refuses the
356
+ config keys a definition may not set (`remoteDriven`, `spreadScale`) and degrades a definition with no
357
+ gun payload to a cosmetic held item. Existing published definitions remain consumable and inspectable;
358
+ the retired `item-def publish` route must not be used to upload bytes or mint a definition. New content
359
+ follows the sealed Continuum Package workflow in `read_doc({name: "continuum"})`; its typed Item projection
360
+ command is upcoming and is not yet available through this MCP. Updating an existing definition likewise
361
+ requires an approved Package/version workflow, not a direct Vault version upload.
362
+ `check_for_updates({ projectDir })` audits a world's `weaponItems` pins against the vault and names
363
+ any that have fallen behind (re-pin steps: `read_doc({ name: "upgrades" })`).
364
+ 2. **World-supplied profiles (the self-contained lane).** Bundle a GLB in `public/props/`, register a
365
+ profile whose `weapon` matches the basename, attach with an ABSOLUTE URL. No platform dependency.
366
+ 3. **The stock arsenal** exists for platform test worlds; treat it as reference data, not a dependency.
367
+
368
+ **Mesh convention** (all lanes): grip point at the ORIGIN, +y up, **+z out the barrel**, meters. Locators
369
+ (`Muzzle`, `IronSight`, `Left_hand`) are optional — every one has a numeric profile fallback
370
+ (`fx.muzzlePointM`, `ads.aimPointM`/`sightDistM`, `ads.foregripPointM`). Sounds should be MONO
371
+ (positional audio degrades on stereo).
372
+
373
+ ## 9. Explosive weapons — the item carries the WEAPON, the world carries the GAME
374
+
375
+ A rocket launcher and a grenade are ORDINARY gun definitions: same `interactive_item` descriptor and
376
+ `--category Launcher` or `--category Grenade`. New products follow the sealed Continuum Package workflow
377
+ described in §8; do not use the retired Item-first `item-def publish` route.
378
+ What a definition can carry stops at the weapon — the blast is GAME logic and no item carries game logic, so
379
+ budget for both halves. Reference implementation: the engine's **`multiplayer-shooter-range`** world
380
+ (`src/main.ts` + `public/multiplayer.json`); every snippet below is lifted from it.
381
+
382
+ | The ITEM carries (published, immutable) | The WORLD carries (yours to author) |
383
+ |---|---|
384
+ | the prop mesh + grip preset, ALIASED to the profile at install | ~6 DSL rules: the claim gate + AoE fanout, the throw spawn, the fuse, the refill |
385
+ | `fire.kind: "projectile"` + the `projectile` block (speed, gravity, `blastRadiusM`, range) | `entities.grenade` — the thrown body's physics + authority |
386
+ | `fire.kind: "thrown"` + the `thrown` block (`releaseS`, `speedMps`, `upBias`) | the `grenades` playerVar (the pouch the ROOM owns) |
387
+ | the `damage` block (`base`, `pellets`) — the number the room resolves | the explosion FX pool + its TEXTURES, and the knockback/trauma feel |
388
+ | `sounds.shots`, `sounds.dry`, **`sounds.explosion`** (the detonation takes) | the client wiring: `ProjectileSystem`, `ThrowableSystem`, the claim |
389
+
390
+ **The room half.** The launcher's blast is claimed by the shooter (one claim per detonation, §4); the
391
+ grenade's is the room's own, because nothing client-side simulates a thrown grenade to completion:
392
+
393
+ ```json
394
+ { "when": { "on": "action", "name": "claimExplosion" },
395
+ "if": { "op": "and", "of": [
396
+ { "op": "<", "a": { "op": "distance", "a": { "var": "self.position" }, "b": { "var": "action.args.at" } }, "b": 160 },
397
+ { "op": "==", "a": { "op": "timerRemaining", "timer": "explodeCooldown", "key": "self" }, "b": 0 } ] },
398
+ "then": [
399
+ { "do": "startTimer", "timer": "explodeCooldown", "seconds": 0.5, "key": "self" },
400
+ { "do": "broadcast", "event": "exploded", "to": "all", "payload": { "at": { "var": "action.args.at" }, "by": "self" } },
401
+ { "do": "forEachPlayer", "as": "p",
402
+ "where": { "op": "<", "a": { "op": "distance", "a": { "var": "action.args.at" }, "b": { "ref": "p", "var": "position" } }, "b": 6 },
403
+ "then": [
404
+ { "do": "setRef", "target": { "ref": "p", "var": "lastHitBy" }, "to": "self" },
405
+ { "do": "add", "target": { "ref": "p", "var": "health" },
406
+ "by": { "op": "*",
407
+ "a": { "op": "*", "a": -1, "b": { "op": "weaponDamage", "of": "self", "default": 100 } },
408
+ "b": { "op": "-", "a": 1, "b": { "op": "/",
409
+ "a": { "op": "distance", "a": { "var": "action.args.at" }, "b": { "ref": "p", "var": "position" } },
410
+ "b": 6 } } } } ] } ] }
411
+ ```
412
+
413
+ The distance gate is a SANITY gate (a claim from across the map is a lie), the cooldown is the rate fence, and
414
+ the `forEachPlayer` fanout is where the falloff shape lives. The other five rules, compactly:
415
+
416
+ - **`throwGrenade`** (action, args `origin` + `vel`) — gate on `self.grenades > 0`, a sanity distance from the
417
+ thrower to `origin`, and a throw cooldown; then `add self.grenades by -1` and
418
+ `{ "do": "spawnEntity", "kind": "grenade", "at": …, "vars": { "vel0": { "var": "action.args.vel" } } }`.
419
+ - **`entitySpawn` / kind `grenade`** → `startTimer` the fuse. The timer is ENTITY-keyed:
420
+ `"timers": { "grenadeFuse": { "keyed": "entity:grenade" } }`, `"key": "self"` — one live fuse per grenade,
421
+ not one per player.
422
+ - **`timerElapsed` / `grenadeFuse`** → `destroyEntity self`. Detonation IS destruction.
423
+ - **`entityDestroy` / kind `grenade`** → `broadcast grenadeExploded` with `at: { "var": "self.position" }` and
424
+ `by: { "op": "controllerOf", "entity": "self" }` (the thrower, for kill credit), then the same
425
+ `forEachPlayer` fanout keyed off `self.position`.
426
+ - **`refillGrenades`** (action, cooldown-gated) → `set self.grenades to 3`; the respawn rule sets it too, or
427
+ a player comes back empty.
428
+
429
+ The thrown body is an owner-authority physics entity — the thrower's client simulates it and uploads, everyone
430
+ else renders that stream:
431
+
432
+ ```json
433
+ "entities": { "grenade": {
434
+ "authority": "owner", "maxSpeed": 30, "transferPolicy": "fixed", "ownerLifecycle": "despawnWithOwner",
435
+ "vars": { "vel0": { "type": "vec3", "default": [0, 0, 0] } },
436
+ "physics": { "bodyType": "dynamic", "shape": { "type": "capsule", "halfHeight": 0.025, "radius": 0.035 },
437
+ "mass": 0.4, "restitution": 0.3, "friction": 0.5, "linearDamping": 0.2, "angularDamping": 2.5 } } },
438
+ "state": { "playerVars": { "grenades": { "type": "number", "default": 3 } } }
439
+ ```
440
+
441
+ **The client half.** `installWeaponItem` returns the definition's `kind`, and that is the routing switch — a
442
+ thrown definition is NEVER rack-equipped:
443
+
444
+ ```ts
445
+ const slot = installWeaponItem(descriptor, assetUrls); // { propUrl, profile, preset, kind, category, warnings }
446
+ if (slot.kind === 'thrown') throwables.setGrenade(slot.profile); // the pouch: never equipped, never applied as a gun
447
+ else arsenal.push(slot); // hitscan AND projectile guns rack normally
448
+ ```
449
+
450
+ The throw itself is gun-control's: action **`gun-control.throw`** (default `KeyG`) plays the overarm toss over
451
+ whatever gun is held, and config **`gun-control.throwEnabled`** is the world mirroring its own count in — while
452
+ false the press emits `throwDenied` instead of `throwStarted`, so an empty pouch answers with a dry click and no
453
+ swing. On `throwStarted` attach the wind-up prop (through `mp.attach`, so replicas see it); `ThrowableSystem`'s
454
+ `onThrow({ origin, velocity })` then sends the `throwGrenade` action — a server action, never a local spawn.
455
+ `ProjectileSystem` needs no such switch: it stays subscribed to the same shot events as `GunHitSystem` and
456
+ self-gates on `fire.kind: "projectile"`, so a racked launcher flies rounds, and its `onExplosion` sends the one
457
+ `claimExplosion`.
458
+
459
+ FX is one `createExplosionFx(scene, { textures })` pool for the world. Spawn it on the `grenadeExploded`
460
+ broadcast (nothing local drew that blast) but on the ROUND's own `onExplosion` for a rocket — the server's
461
+ `exploded` broadcast must draw nothing, or the shooter sees two fireballs. Blast audio prefers the weapon's own
462
+ `profile.sounds.explosion` and falls back to the world's set. Explosion **textures cannot ride the item** (the
463
+ publish walker resolves meshes, audio and clips only — no image reference site), so the world ships them; a
464
+ procedural fallback exists. Knockback and camera trauma ride the same broadcast handler: they are FEEL, and
465
+ retuning them can never desync health, because the room owns the damage.
466
+
467
+ **Damage ops: `weaponDamage` for HELD weapons, `itemDamage` for THROWN ones.** Both resolve a PUBLISHED
468
+ definition server-side — a client never names a damage number — and the world only picks which one:
469
+
470
+ - `{"op":"weaponDamage","of":<ref>,"target":<ref>?,"default":<n>?}` — the damage of the gun that player is
471
+ HOLDING right now. Correct for the launcher: at `claimExplosion` the RPG is still in the shooter's grip.
472
+ - `{"op":"itemDamage","item":"<vaultAssetId>@<version>","default":<n>?}` — the damage of the definition that
473
+ literal PIN names, regardless of what anyone holds. Returns `damage.base × (pellets ?? 1)`; any miss
474
+ (unresolved pin, no definition) yields `default ?? 0`. The pin **must also appear in
475
+ `multiplayer.weaponItems`** or the world fails publish validation. It has **no falloff leg by design**:
476
+ `weaponDamage`'s falloff is shooter→target, but a blast's distance term is blast-point→victim, which your
477
+ `forEachPlayer` rule already computes — the op contributes the NUMBER, the rule owns the SHAPE.
478
+
479
+ ```json
480
+ "weaponItems": ["1c9f6a2e-0b34-4e11-9c5d-8f4f2a6d7b01@3"],
481
+ "by": { "op": "*",
482
+ "a": { "op": "*", "a": -1, "b": { "op": "itemDamage", "item": "1c9f6a2e-0b34-4e11-9c5d-8f4f2a6d7b01@3", "default": 100 } },
483
+ "b": { "op": "-", "a": 1, "b": { "op": "/",
484
+ "a": { "op": "distance", "a": { "var": "self.position" }, "b": { "ref": "p", "var": "position" } },
485
+ "b": 4.5 } } }
486
+ ```
487
+
488
+ > **`weaponDamage` inside a grenade rule is a SILENT wrong number.** The fuse elapses seconds after the throw,
489
+ > when the hand is back on the rifle — so the op returns the RIFLE's damage, no error and no warning, and your
490
+ > grenade quietly does 11. A thrown weapon is `itemDamage`, keyed by pin.
491
+
492
+ **Headshots: the client names the PART, the definition names the MULTIPLIER.** Declare the enum on the claim —
493
+ `"claimHit": { "args": { "target": { "type": "ref", "of": "player" }, "bodyPart": { "type": "string", "enum": ["head", "body"] } } }`
494
+ — then pick the rule shape that matches how the world's guns arrive:
495
+
496
+ - **Definition-resolved guns** (players hold weapons that resolve through `weaponItems` pins — vault-equipped
497
+ arsenals): ONE `claimHit` rule, with the part fed straight into the op —
498
+ `{"op":"weaponDamage","of":"self","target":{"var":"action.args.target"},"part":{"var":"action.args.bodyPart"},"default":20}`.
499
+ A `part` resolving to `"head"` multiplies the resolved damage (after falloff) by the definition's
500
+ `damage.headMultiplier` — publish-capped 1..3, absent = ×1 (the official arsenal ships 2.0 on every hitscan
501
+ category and nothing on launcher/thrown). Any other part value — `"body"`, a typo, an unset var — is ×1.
502
+ - **World-attached guns** (props the world attaches itself, so nothing matches a `weaponItems` pin and
503
+ `weaponDamage` answers its `default`): the part has no definition to multiply through — branch at RULE level
504
+ instead: two `claimHit` rules split on `action.args.bodyPart`, the head rule applying a world literal
505
+ (`{"op":"*","a":-2,...}` vs `-1`). The `shooter-range` template is this shape.
506
+
507
+ `crouched` and `ragdolled` are read-only player BUILT-INS in the DSL now (and reserved names — a `playerVars`
508
+ entry named either fails publish). They are plausibility levers, e.g. a world where downed bodies are immune
509
+ gates its `claimHit` rule on the target's `ragdolled` being false.
510
+
511
+ **The hand-duplicated numbers.** Nothing reconciles these; a radius changed in one place gives you a blast that
512
+ LOOKS like it connected and does nothing (or damage with no fireball). Grep the literal, fix every site:
513
+
514
+ | Number | Every place it lives |
515
+ |---|---|
516
+ | blast radius | the definition's `projectile.blastRadiusM`, the rule's `where` distance AND its falloff divisor, the client feel literal |
517
+ | the thrown body (radius, half-height, max speed) | `entities.grenade.physics`, the client's own dynamic body, and the upload clamp |
518
+ | fuse seconds, cooldowns, pouch size | the rules, the respawn refill, and any HUD that counts them |
519
+
520
+ **And a world's multiplayer config exists TWICE.** `public/multiplayer.json` is what the ROOM fetches through
521
+ the signed `configUrl`; the inline `multiplayer` block in `helix.json` is what the PUBLISH validator reads.
522
+ Rules, entities, timers, playerVars, events and `weaponItems` must be identical in both — a pin added only to
523
+ `helix.json` publishes clean and resolves to nothing at runtime.
524
+
525
+ **The never-hand-author-a-profile rule (§3) binds hardest here.** Install the definition (§8) or resolve the
526
+ stock profile by name: a typed-out profile silently loses the sounds, and on an explosive it also drifts from
527
+ `sounds.explosion` and from the damage number the room resolves through the pin.
528
+
529
+ ## 10. Verify like a shooter
530
+
531
+ - Two clients, always: fire on A, confirm B sees the muzzle flash + tracer + stance; kill B, confirm the
532
+ ragdoll falls on A's screen and B respawns cleanly.
533
+ - `spreadDeg`/`heat` off `gunHit` + the HUD ammo counters are the numeric feel instruments — assert them in
534
+ smokes instead of eyeballing.
535
+ - The equip tracker + gun-control key collision (§2) is the #1 setup mistake; the silent-`helix_modules`
536
+ layout (§1) is #2; a missing `aliasWeaponProfile` on CDN guns (§3) is #3. All three fail SILENT — check
537
+ them first when "the gun does nothing".