@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.
- 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
|
@@ -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".
|