@hypersoniclabs/helix-mcp 0.2.4 → 0.2.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/README.md +81 -11
  2. package/dist/continuumCanary.d.ts +17 -0
  3. package/dist/continuumCanary.js +17 -0
  4. package/dist/continuumCanary.js.map +1 -0
  5. package/dist/server.d.ts +14 -1
  6. package/dist/server.js +4058 -162
  7. package/dist/server.js.map +1 -1
  8. package/dist/tsconfig.build.tsbuildinfo +1 -1
  9. package/dist/vehicleTools.d.ts +86 -0
  10. package/dist/vehicleTools.js +229 -0
  11. package/dist/vehicleTools.js.map +1 -0
  12. package/docs/avatar-face.md +115 -0
  13. package/docs/bridge.md +98 -0
  14. package/docs/bring-your-world.md +117 -0
  15. package/docs/catalog.md +69 -1
  16. package/docs/character-animation.md +442 -0
  17. package/docs/character-attachments.md +166 -0
  18. package/docs/character-world.md +785 -130
  19. package/docs/continuum.md +153 -0
  20. package/docs/items.md +73 -0
  21. package/docs/lighting-world.md +667 -0
  22. package/docs/locomotion-clip-spec.md +294 -0
  23. package/docs/manifest.md +31 -6
  24. package/docs/multiplayer-logic.md +460 -17
  25. package/docs/multiplayer-templates/chrono-orchard.md +36 -22
  26. package/docs/multiplayer-templates/collect-a-thon.md +28 -38
  27. package/docs/multiplayer-templates/collections.md +24 -24
  28. package/docs/multiplayer-templates/hangout.md +124 -111
  29. package/docs/multiplayer-templates/npc-wave.md +310 -0
  30. package/docs/multiplayer-templates/obby.md +13 -16
  31. package/docs/multiplayer-templates/persistent-progress.md +218 -0
  32. package/docs/multiplayer-templates/physics-bumper.md +27 -9
  33. package/docs/multiplayer-templates/physics-football.md +22 -8
  34. package/docs/multiplayer-templates/relic-bearers.md +12 -15
  35. package/docs/multiplayer-templates/server-motion.md +16 -19
  36. package/docs/multiplayer-templates/shooter-range.md +275 -0
  37. package/docs/multiplayer-templates/team-control.md +28 -15
  38. package/docs/multiplayer-templates/turn-arena.md +31 -22
  39. package/docs/multiplayer-templates/voice-radio.md +166 -0
  40. package/docs/multiplayer-templates/wave-survival.md +7 -8
  41. package/docs/multiplayer-templates/world-shop.md +240 -0
  42. package/docs/multiplayer-world.md +222 -129
  43. package/docs/npc-world.md +623 -0
  44. package/docs/publishing.md +108 -28
  45. package/docs/purchases.md +223 -0
  46. package/docs/scene-performance.md +64 -0
  47. package/docs/screenshots.md +140 -0
  48. package/docs/sdk.md +324 -5
  49. package/docs/shooter-worlds.md +537 -0
  50. package/docs/terrain.md +173 -0
  51. package/docs/upgrades.md +324 -0
  52. package/docs/vehicles.md +727 -0
  53. package/docs/world-inspect.md +156 -0
  54. package/docs/world-look.md +241 -0
  55. package/docs/world-recipe.md +65 -6
  56. package/package.json +15 -4
  57. package/skills/README.md +91 -0
  58. package/skills/helix-assets/SKILL.md +491 -0
  59. package/skills/helix-assets/references/asset-sources.md +143 -0
  60. package/skills/helix-assets/references/vault-api.md +105 -0
  61. package/skills/helix-avatar-qa/SKILL.md +85 -0
  62. package/skills/helix-avatars/SKILL.md +206 -0
  63. package/skills/helix-avatars/references/contract.md +166 -0
  64. package/skills/helix-avatars/references/dynamics.md +367 -0
  65. package/skills/helix-avatars/references/face.md +50 -0
  66. package/skills/helix-avatars/references/publish.md +76 -0
  67. package/skills/helix-avatars/references/qa.md +251 -0
  68. package/skills/helix-avatars/references/rigging.md +88 -0
  69. package/skills/helix-avatars/references/source-generated.md +190 -0
  70. package/skills/helix-avatars/references/source-model.md +90 -0
  71. package/skills/helix-avatars/references/source-rigid.md +90 -0
  72. package/skills/helix-avatars/references/source-vrm.md +61 -0
  73. package/skills/helix-gauntlet/SKILL.md +128 -0
  74. package/skills/helix-multiplayer/SKILL.md +150 -0
  75. package/skills/helix-multiplayer/references/dsl-capability-map.md +107 -0
  76. package/skills/helix-vehicles/SKILL.md +218 -0
  77. package/skills/helix-vehicles/references/addons.md +212 -0
  78. package/skills/helix-vehicles/references/appearance.md +339 -0
  79. package/skills/helix-vehicles/references/audio-import.md +138 -0
  80. package/skills/helix-vehicles/references/audio.md +580 -0
  81. package/skills/helix-vehicles/references/cabin.md +225 -0
  82. package/skills/helix-vehicles/references/host-manifest.md +174 -0
  83. package/skills/helix-vehicles/references/physics-from-specs.md +463 -0
  84. package/skills/helix-vehicles/references/publish.md +214 -0
  85. package/skills/helix-vehicles/references/qa.md +177 -0
  86. package/skills/helix-vehicles/references/reference-package.json +3481 -0
  87. package/skills/helix-vehicles/references/reference-package.md +69 -0
  88. package/skills/helix-vehicles/references/source-beamng.md +167 -0
  89. package/skills/helix-vehicles/references/source-concept.md +38 -0
  90. package/skills/helix-vehicles/references/source-model.md +100 -0
  91. package/skills/helix-vehicles/references/source-scratch.md +60 -0
  92. package/skills/helix-world-build/SKILL.md +376 -0
  93. package/skills/helix-world-build/references/config-gates.md +104 -0
  94. package/skills/helix-world-director/SKILL.md +210 -0
  95. package/skills/helix-world-qa/SKILL.md +371 -0
  96. package/skills/helix-world-qa/references/perf-budgets.md +240 -0
  97. package/skills/helix-world-qa/references/perf-handle.md +125 -0
  98. package/skills/helix-world-qa/references/visual-scorecard.md +85 -0
@@ -0,0 +1,623 @@
1
+ # HELIX Instant — NPC Characters (the recipe)
2
+
3
+ Follow this when the world needs someone in it who is **not a player**: a shopkeeper behind a counter, a quest
4
+ giver, a guard on a post, a zombie that hunts you around a wall, a companion that follows you. NPCs are a
5
+ **layer on the character system** — the same `humanoid-character` package the player is built on, with one seam
6
+ swapped.
7
+
8
+ > ## SCAFFOLD THE WORLD FIRST — THIS IS A LAYER, NOT A PROJECT
9
+ >
10
+ > NPCs go **into** a character world. Call `get_started({ kind: "character" })`, run the `helix init` command
11
+ > `scaffold_world` returns, and edit the `src/main.ts` it wrote. Do not hand-write project files from this doc:
12
+ > every snippet below is an addition to that generated boot flow, and every import comes from the **system
13
+ > alias** `@helix/humanoid-character` — never an engine path, never a relative import into a package.
14
+ >
15
+ > **Read the API before you call it.** `get_package_manifest("humanoid-character")` → `capabilities.api` carries
16
+ > the shipped signatures for `NpcScene`, `InteractionSpots`, `NavGrid` and `loadVaultCharacter`, and the
17
+ > installed `.d.ts` carries the full types. Never invent an NPC API — there is no `npc.attack()`, no
18
+ > `npc.patrol()`, no dialogue system. What exists is below.
19
+
20
+ ## 0. What an NPC is here *(humanoid-character ≥ 0.3.13)*
21
+
22
+ **An NPC is a `Character` whose driver is your behaviour instead of the keyboard.** It is the same chassis the
23
+ player runs on — the same config namespaces (`character.*`, `locomotion.*`), the same locomotion + animation
24
+ graph, the same abilities, the same gestures, the same nameplate — built headless (no camera, no DOM). The one
25
+ difference is the seam that feeds it intent: a player's `Character` reads real keys through
26
+ `LocalInputDriver`, and an NPC's reads an **`AIDriver`** that writes *virtual* input onto the NPC's own private
27
+ router, so every ability resolves that intent exactly as it resolves a player's and nothing downstream can tell
28
+ the difference. You never build that plumbing: **`NpcScene`** owns the model clone, the private router, the
29
+ body, the plate and the teardown, and you author two things — a `behaviour` callback and, when the player
30
+ should be able to talk to it, an interaction spot.
31
+
32
+ ## 1. The five-minute shop NPC *(humanoid-character ≥ 0.3.13)*
33
+
34
+ The smallest useful NPC: it stands at its counter, turns to watch whoever walks up, and says a line when you
35
+ press interact.
36
+
37
+ ```ts
38
+ import {
39
+ InteractionSpots, NpcScene, type AIBehaviour,
40
+ } from '@helix/humanoid-character';
41
+
42
+ // `assets` is what loadCharacterAssets returned (or `mp.assets` in a multiplayer world) — the shared base
43
+ // model + locomotion clips. NpcScene SkeletonUtils-clones that model per NPC, so one load feeds every NPC.
44
+ const npcs = new NpcScene({ scene, assets });
45
+
46
+ const COUNTER = { x: 7, y: 1, z: 4 };
47
+ const WATCH_RANGE_M = 6;
48
+
49
+ // The behaviour runs FIRST inside the NPC's own update, once a frame, and steers through the driver's
50
+ // public surface: seek / stop / face / jump / setCrouched, plus the `arrived` and `path` reads.
51
+ const shopBehaviour: AIBehaviour = (npc) => {
52
+ const self = npc.position; // the body's live feet position — copy it, never hold it
53
+ const near = Math.hypot(body.position.x - self.x, body.position.z - self.z) <= WATCH_RANGE_M;
54
+ npc.face(near ? body.position : COUNTER); // idle facing: a world point, or a yaw in DEGREES
55
+ };
56
+
57
+ const shop = await npcs.add({
58
+ id: 'shopkeeper',
59
+ at: { x: 7, y: 0, z: 5.2 },
60
+ facingDeg: 180,
61
+ body: 'stand', // the default — no physics world at all
62
+ behaviour: shopBehaviour,
63
+ name: 'Shopkeeper', // omit for no nameplate; nameColor restyles it
64
+ });
65
+
66
+ // Proximity interaction: walk into range, an overhead pill offers `{interact} Talk`, the press calls back.
67
+ const spots = new InteractionSpots({ body, input, scene });
68
+ spots.register({ // returns its unregister closure — keep it if the NPC can leave
69
+ id: 'shopkeeper-talk',
70
+ label: 'Talk', // the verb, sentence case
71
+ // A LIVE anchor, not a fixed point: the pill has to hang above wherever the NPC currently stands.
72
+ at: () => { const p = shop.driver.position; return { x: p.x, y: p.y + 2.1, z: p.z }; },
73
+ onInteract: () => showLine(nextShopLine()), // your own HUD + dialogue code, not an SDK API
74
+ });
75
+
76
+ renderer.setAnimationLoop(() => {
77
+ const dt = Math.min(clock.getDelta(), 0.1);
78
+ character.update(dt); // the PLAYER first — it moves the body every NPC behaviour reads, and its driver
79
+ // is what advances this frame's press edges on the shared router
80
+ npcs.update(dt); // then every NPC (each one's behaviour + driver run inside its own character.update)
81
+ spots.update(dt); // then the spots — the anchors they track just moved, and the interact edge is live
82
+ renderer.render(scene, camera);
83
+ });
84
+
85
+ // THE ONE LINE THAT MAKES NPCs MEASURABLE — the scaffold already calls world.ready at the end of boot; add
86
+ // `npcs` to it and `inspect_npc` can measure every NPC in this world (§5). `npcState` is your OWN behaviour
87
+ // machine, published per NPC and carried verbatim into every sample; optional, and everything else works without it.
88
+ world.ready({ scene, body, spawn, npcs, npcState: (id) => brains.get(id) ?? null });
89
+ ```
90
+
91
+ **The loop order is the recipe, not a preference.** `spots.update` reads `wasPressed('interact')` off the
92
+ player's router, and that router advances its edges inside `character.update(dt)` — run the spots first and the
93
+ press either misses by a frame or reads stale. NPCs go between the two so the pill tracks where the NPC is
94
+ *this* frame rather than trailing it.
95
+
96
+ **Pass `npcs` to `world.ready` the moment you build an `NpcScene`.** Without it the inspect helper installs no
97
+ NPC surface at all, and the only way to find out what a behaviour is doing is to watch it with your eyes — which
98
+ is exactly what §5 is about not doing. `npcState` is where your own state goes: whatever object you return for an
99
+ id (`{ phase: "chase", cooldown: 0.4 }`) rides along on every sample, so a state machine that never leaves its
100
+ wind-up shows up as a flat timeline instead of a mystery. Return `null` for an NPC you have nothing to say about.
101
+
102
+ **The two body tiers:**
103
+
104
+ | `body` | what it is | needs | walks? |
105
+ |---|---|---|---|
106
+ | `'stand'` *(default)* | `StandBody` — no physics world at all, so a standing NPC costs no simulation | nothing | **never.** `seek()` writes intent a stationary body ignores; `teleport()` is the only thing that moves it |
107
+ | `'physics'` | its own Rapier world, with the level geometry mirrored in from the scene's `staticsFrom` | `staticsFrom` on the `NpcScene` | yes — and it routes over a `nav` grid when it has one (§3) |
108
+
109
+ Everything else about the spot is a platform default: distance is **horizontal** (put the anchor at head
110
+ height — about 2.1 m above the feet — for where the pill *draws*; the radius ignores `y`, so height costs you no
111
+ reach), focus is taken at **2.2 m** and released only past **3.0 m** (per-spot hysteresis, so standing on the
112
+ boundary cannot flicker), nearest wins with where the player is *looking* settling a near-tie inside 0.5 m, and
113
+ the pill renders the **real keycap** for `interact` on the player's current device — `E`, an Xbox glyph, a pad
114
+ prompt — because it composes the label through the router's own `format()`. `register()` throws on a duplicate
115
+ id (a silent replace would re-point a verb the player is looking at) and returns its unregister closure.
116
+ `interact` is *ensured* on the router: if the world never registered it, `InteractionSpots` does, because an
117
+ offer nobody can take is worse than no offer.
118
+
119
+ > **If the world also authors sit/lie spots, yield to them.** Pass `suppressed: () => mp.sitPrompt !== null`
120
+ > (the multiplayer facade's live sit prompt) so an NPC pill and a chair pill never stack. While suppressed no
121
+ > prompt shows and presses are ignored.
122
+
123
+ ## 2. A zombie that hunts *(humanoid-character ≥ 0.3.13)*
124
+
125
+ A hunting NPC needs the `'physics'` tier, and that tier needs level geometry. Build the scene with
126
+ `staticsFrom` pointed at the **player's own body** — the walls the NPC steers around are then literally the
127
+ walls you walk on, mirrored into its private world:
128
+
129
+ ```ts
130
+ const npcs = new NpcScene({ scene, assets, staticsFrom: body, nav }); // `nav`: §3
131
+ ```
132
+
133
+ Skip `staticsFrom` and `add()` refuses before it builds anything:
134
+
135
+ ```
136
+ NpcScene.add('zombie'): body 'physics' needs level geometry — build the NpcScene with staticsFrom
137
+ ```
138
+
139
+ ```ts
140
+ const ATTACK_RANGE_M = 1.6;
141
+ const ATTACK_COOLDOWN_S = 1.2;
142
+ const RESEEK_M = 0.25;
143
+ /** The point the standing seek was last issued for — see rule 2. */
144
+ const sought = { x: Infinity, z: Infinity };
145
+ let cooldown = 0;
146
+
147
+ const zombieBehaviour: AIBehaviour = (npc, dt) => {
148
+ cooldown = Math.max(cooldown - dt, 0);
149
+ const self = npc.position;
150
+ const planar = Math.hypot(body.position.x - self.x, body.position.z - self.z);
151
+
152
+ // RULE 1 — read `arrived` BEFORE re-seeking.
153
+ if (npc.arrived && planar < ATTACK_RANGE_M && cooldown === 0) {
154
+ startAttack(); // your wind-up state machine — see the attack doctrine below
155
+ cooldown = ATTACK_COOLDOWN_S;
156
+ }
157
+
158
+ // RULE 2 — re-seek only once the target actually MOVED.
159
+ if (Math.hypot(body.position.x - sought.x, body.position.z - sought.z) > RESEEK_M) {
160
+ sought.x = body.position.x;
161
+ sought.z = body.position.z;
162
+ npc.seek(body.position, { arriveM: 1.2 }); // arriveM = the stop radius (default 0.6); sprint: true runs
163
+ }
164
+ };
165
+
166
+ const zombie = await npcs.add({
167
+ id: 'zombie', at: { x: -15, y: 0.2, z: -15 }, body: 'physics',
168
+ behaviour: zombieBehaviour, name: 'Zombie', nameColor: '#ff5c5c',
169
+ config: { locomotion: { runSpeed: 3.2 } }, // per-NPC config, merged OVER the spawn this scene derives from `at`
170
+ });
171
+ ```
172
+
173
+ > **Rule 1 — read `arrived` before you re-seek, in the same behaviour.** `seek()` clears the arrived flag, so a
174
+ > behaviour that seeks first and checks after reads `false` **every single frame** and the attack never fires
175
+ > once. The symptom is an NPC that chases perfectly and is completely harmless.
176
+
177
+ > **Rule 2 — re-seek only when the target actually moved.** `seek()` also resets the driver's stuck-progress
178
+ > window, so re-issuing it every frame silently disables the sideways commit that rounds a wall: the NPC grinds
179
+ > into the corner forever. (The A* plan itself is *not* the cost — a routed seek keeps its path unless the
180
+ > target drifted more than 1 m, exactly so a behaviour can chase a shuffling player without replanning per
181
+ > frame. The stuck detector is what you break.) A 0.25 m threshold is plenty.
182
+
183
+ **What the driver does for you, and what it does not.** Built in: whisker obstacle avoidance (three rays from
184
+ 0.9 m eye height, 2.5 m lookahead, ±35°, turning harder the closer the hit) and a **stuck commit** — when a
185
+ seek makes less than 0.35 m of progress in 1.5 s the NPC commits sideways for 1.2 s along the freer whisker,
186
+ which is what gets it around an L-shaped wall. Those numbers are the shipped defaults and `npcs.add()` does not
187
+ expose them; a world that must retune them constructs an `AIDriver` itself and hands it over with
188
+ `character.setDriver(...)`. Not built in: attacking, patrolling, fleeing, dialogue, aggro, factions. All of
189
+ that is your `behaviour` over the surface above.
190
+
191
+ > ## THE ATTACK DOCTRINE — the animation never causes damage
192
+ >
193
+ > A clip is a *picture* of an attack. The behaviour owns the attack: a small state machine of **wind-up →
194
+ > damage window → recovery**, each phase timed off `dt` against numbers you chose to match the clip, with the
195
+ > damage applied on exactly one tick inside the window. Drive the visual with
196
+ > `zombie.character.playAnimation('zombie-swipe')` (a world-authored pose-DSL gesture — construct the scene
197
+ > with `gestures` and read `read_doc({ name: "character-animation" })`), but never hang a hit off the
198
+ > animation's own timing: a clip that fades, blends or gets interrupted takes the hit with it, and two NPCs
199
+ > mid-swipe are two different frames of the same clip.
200
+ >
201
+ > **Solo damage is `character.setHealth(...)` on the PLAYER** — requires `character: { health: { enabled: true,
202
+ > maxHealth: 100 } }` (§8f of the character recipe), which brings `died`/`revived`/`healthChanged` and the
203
+ > death ragdoll with it. **Multiplayer damage is never client-side** — it is a server rule; see §6.
204
+
205
+ ## 3. Pathfinding — `NavGrid` *(humanoid-character ≥ 0.3.13)*
206
+
207
+ Without a grid a seek steers straight at its target and leans on the whiskers plus the stuck commit. With one,
208
+ the seek is **routed**: A* over a rasterized walkable grid, string-pulled to a short waypoint list, with the
209
+ whiskers still live over the route for whatever the grid cannot know about.
210
+
211
+ ```ts
212
+ // Rasterized off the SAME collider roster staticsFrom mirrors into each NPC's private world — one source of
213
+ // truth for what is walkable, so the planner can never promise a route the capsule cannot take.
214
+ const nav = NavGrid.fromColliders(body.describeColliders());
215
+ const npcs = new NpcScene({ scene, assets, staticsFrom: body, nav });
216
+ ```
217
+
218
+ The scene's `nav` is inherited by **`'physics'` NPCs only** (the only tier that walks). A per-NPC `nav` on
219
+ `add()` wins over it, *including* an explicit `nav: null`, which means "no routing even though the scene has a
220
+ grid".
221
+
222
+ **v1 is single-plane, and honest about it.** Defaults: `cellM` 0.5, `agentRadiusM` 0.4, `walkableY` 0,
223
+ `floorBandM` 0.5, `stepM` 0.35, `heightM` 1.8. Colliders whose top sits within `floorBandM` of `walkableY`
224
+ are **floor** (and floor classification wins, so a kerb inside the step band stays walkable instead of punching
225
+ a hole in the map); anything overlapping the body band blocks. Only **cuboids** rasterize, and only
226
+ axis-aligned or **yaw**-rotated ones — a tilted cuboid has no planar footprint. Everything else is skipped with
227
+ one warning naming what it dropped:
228
+
229
+ ```
230
+ NavGrid: skipped 2× trimesh, 1× ball — v1 rasterizes yaw-aligned cuboids only, so routes over that geometry degrade to direct steering
231
+ ```
232
+
233
+ **Degradation is silent by design.** A route the grid cannot make (endpoints in different components, a target
234
+ outside the floor bounds, geometry that never rasterized) leaves the plan null and the seek steers direct — no
235
+ throw, no log. So when an NPC beelines into a wall, **check the console for that warn first**; the world is
236
+ usually made of trimesh, or of a second storey the grid cannot represent.
237
+
238
+ > **THE INFLATION CONSEQUENCE — corridors need more than `2 × agentRadiusM + cellM`.** Every blocker is
239
+ > inflated by `agentRadiusM` plus half a cell's support (0.4 + 0.25 = **0.65 m** per side at defaults) before it
240
+ > is stamped, so no cell centre survives in a gap narrower than **1.3 m** — that doorway rasterizes shut, and
241
+ > the route through it degrades to direct steering. Author doorways and alleys at 1.5 m or wider, or lower
242
+ > `cellM`/`agentRadiusM` deliberately and accept the cost (a finer grid is quadratically more cells;
243
+ > `fromColliders` throws past a 1024 × 1024 budget). Size the geometry with `world_metrics` before you place
244
+ > it — the player's own capsule and corridor numbers are the floor for the NPC's.
245
+
246
+ Both endpoints of `findPath(from, to)` are **clamped onto the grid** first, so a target standing on a counter
247
+ resolves to the floor beside it and `null` genuinely means "these two ends are disconnected". `clampToWalkable(p)`
248
+ answers the same question for a spawn point before you place an NPC on it, and `npc.path` reads the live plan
249
+ (cell centres, goal included) — read it, never hold it.
250
+
251
+ ## 4. Vault characters as NPC bodies *(humanoid-character ≥ 0.3.13)*
252
+
253
+ An NPC does not have to wear the platform body. Any Vault **character** whose rig is a helix-humanoid can be
254
+ the model, and the platform's shared locomotion clips drive it — the flow is search → verify the rig → install
255
+ → load → pass as `model`.
256
+
257
+ 1. **Search with `match: 'narrow'`** — it makes the query a hard FILTER, so an empty result is the honest
258
+ answer to "does the Vault have a zombie?" instead of the nearest confidently-irrelevant rows:
259
+ `search_assets({ query: "zombie", kinds: ["character"], match: "narrow", engine: "web" })`.
260
+ 2. **Read the row before you install.** `skeleton` says which rig the character carries,
261
+ `skeletonVerification` says whether the publish gate actually MEASURED that, and
262
+ `character.boneCount` / `character.skinned` say whether there is a rig at all. The browser runtime plays
263
+ humanoid rigs — a verified humanoid skeleton is what makes a character web-playable, and this row is the
264
+ cheapest place to find that out.
265
+ 3. **Install it** — `install_asset({ directory, assetId })` downloads the exact immutable version, verifies
266
+ the checksum and writes provenance. The file lands at `public/assets/vault/<assetId>/<slug>.glb`.
267
+
268
+ ```ts
269
+ import { loadVaultCharacter } from '@helix/humanoid-character';
270
+
271
+ // Resolve against the document — a root-absolute '/assets/...' 404s in production (character recipe §9).
272
+ const url = new URL('assets/vault/<assetId>/<slug>.glb', document.baseURI).href;
273
+ const { model } = await loadVaultCharacter(url, { renderer, transcoderPath: TRANSCODER_PATH });
274
+ await npcs.add({ id: 'zombie-1', at: SPAWN, body: 'physics', behaviour: zombieBehaviour, model });
275
+ ```
276
+
277
+ **The skeleton gate is the whole point.** Platform clips bind by bone NAME, so a foreign rig assembles
278
+ perfectly and then stands there in its bind pose forever. Instead of that silent T-pose, `loadVaultCharacter`
279
+ **throws** and names the fix — recognize this message and route to `import_character` (the CLI's
280
+ `helix character import`), which conforms the mesh onto the canonical skeleton at its own proportions and
281
+ generates the LOD chain; then install the converted result:
282
+
283
+ ```
284
+ loadVaultCharacter: <url> is not a helix-humanoid rig — missing canonical bones: pelvis, hand_l. Convert it first (helix character import), then install it into the world.
285
+ ```
286
+
287
+ The bones it checks are the core of `helix-humanoid@1`: `root`, `pelvis`, `spine_01`, `head`, `hand_l`,
288
+ `hand_r`, `foot_l`, `foot_r` — few enough that a legitimately decimated LOD export still passes.
289
+
290
+ A multi-scene GLB assembles as an LOD chain (scene *i* = level *i*, one skeleton, one mixer). A single-scene one
291
+ loads as-is with `lod: null` and one warning — worth fixing for a crowd, harmless for one shopkeeper:
292
+
293
+ ```
294
+ loadVaultCharacter: <url> is a single-scene GLB — no LOD chain, so its full-detail mesh renders at every distance. Re-export with one scene per LOD level.
295
+ ```
296
+
297
+ Two more real details: **one model per NPC** — `NpcScene` clones the shared `assets.model` for every NPC that
298
+ passes no `model`, but a model you *pass* is used as-is, so give each NPC its own load (handing the same object
299
+ to two `add()` calls puts one body in two places). And when the page already built a character loader, pass
300
+ `assetIO` so the whole page shares **one** KTX2 transcoder:
301
+
302
+ ```ts
303
+ const io = createCharacterAssetIO({ renderer, transcoderPath: TRANSCODER_PATH });
304
+ const assets = await loadCharacterAssets(SYSTEM_ASSET_BASE, { io });
305
+ const guard = await loadVaultCharacter(url, { assetIO: io });
306
+ ```
307
+
308
+ ## 4a. Armed NPCs — a guard that shoots back *(humanoid-character ≥ 0.3.13 · `gun-control` ≥ 0.3 · asset pack `humanoid-character-assets` ≥ 0.1.6)*
309
+
310
+ An NPC can carry a **real** gun: the same `gun-control` ability, the same weapon profile, the same FX and hit
311
+ systems the player's gun runs on — the gun contract itself is `read_doc({ name: "shooter-worlds" })`, and this
312
+ section is only what changes when the shooter is an NPC. The engine poses the body, plays the effects and
313
+ resolves the ray. **When it pulls the trigger and what a hit costs are yours**, in the same behaviour loop §2
314
+ put the zombie's attack in.
315
+
316
+ **Arming happens inside `decorate`, in REPLICA mode, with the `net:*` keys registered up front:**
317
+
318
+ ```ts
319
+ import {
320
+ AttachmentService, GunFxSystem, GunHitSystem, applyWeaponProfile, createCharacterAssetIO, hitBearingDeg,
321
+ loadInstalledAbilities, presetForCategory, resolveWeaponProfile, type NpcHandle,
322
+ } from '@helix/humanoid-character';
323
+
324
+ const GUN_URL = new URL('props/weapons/Pistol/Gaston.glb', document.baseURI).href; // never root-absolute
325
+ const OWNER = 'guard-brain'; // blackboard owner tag for the keys this world synthesizes
326
+ const profile = resolveWeaponProfile(GUN_URL); // null = nothing resolved for that BASENAME (shooter-worlds §3)
327
+ let fx: GunFxSystem; // built here, advanced in the frame loop below
328
+
329
+ const arm = async (npc: NpcHandle): Promise<void> => {
330
+ await loadInstalledAbilities(modulesBaseUrl, npc.character); // the ONLY window — see the three rules below
331
+ npc.character.services.config.set('gun-control.remoteDriven', true);
332
+ const bb = npc.character.blackboard;
333
+ for (const key of ['net:gunDrawn', 'net:aiming', 'net:reloading']) bb.register(key, 'boolean', OWNER);
334
+ bb.register('net:shotSeq', 'number', OWNER);
335
+ bb.set('net:shotSeq', 0); // THE TRAP: the first observation only ANCHORS — start it here, count at fire time
336
+ const attachments = new AttachmentService(npc.character.services.sockets!, {
337
+ io: createCharacterAssetIO({ renderer }), // the system's own IO — a bare GLTFLoader drops KTX2/meshopt props
338
+ gestures: npc.character.gestures,
339
+ });
340
+ const held = await attachments.attach(GUN_URL, 'hand_r.grip', { preset: presetForCategory(profile!.category) });
341
+ applyWeaponProfile(npc.character, profile!); // cadence, clip, spread, grip family, placement — never hand-authored
342
+ fx = new GunFxSystem({ scene, camera, soundsBaseUrl, audio, flashPool, shells }); // the world's SHARED pools
343
+ fx.attachTo(npc.character);
344
+ fx.setWeapon(GUN_URL, held.object); // BOTH halves — an FX system without setWeapon is silently inert
345
+ bb.set('net:gunDrawn', true); // on duty: the gun stays out, so engaging only changes the aim
346
+ };
347
+
348
+ const guard = await npcs.add({
349
+ id: 'guard', at: POST_A, body: 'physics', name: 'Guard',
350
+ // Catching here keeps an unarmable guard patrolling; let it throw and add() REJECTS with no NPC at all.
351
+ decorate: (npc) => arm(npc).catch((error: unknown) => console.warn('guard could not be armed:', error)),
352
+ });
353
+ ```
354
+
355
+ - **`remoteDriven`, never local mode.** The local lane's ADS reaches for the camera rig and a headless NPC has
356
+ none, so it throws on the first aim. The replica lane drives the identical stance/recoil/reload one-shots off
357
+ four blackboard keys instead — which is exactly the seam a behaviour can write.
358
+ - **In `decorate`, nothing later.** `decorate` is awaited before the NPC joins the tick, and the first tick
359
+ starts its `AbilityManager`; a started manager refuses every install, so "right after `add()`" is already too
360
+ late.
361
+ - **Register the `net:*` keys yourself.** On a player replica a `NetworkDriver` registers and writes them; an
362
+ NPC has no network lane, so your world **is** the driver. **`net:shotSeq` swallows its first observation on
363
+ purpose** (a late joiner must not replay a stranger's magazine) — register it at 0 in `decorate` and
364
+ increment it at fire time, or the first shot is anchored away instead of fired.
365
+
366
+ **One set of FX pools per WORLD, never per NPC.** `GunAudio`, `MuzzleFlashPool` and `ShellEjector` are built
367
+ once and injected into every `GunFxSystem` (the player's and each armed NPC's); their update is dt-driven, so a
368
+ second system that also advances them decays every flash and shell twice as fast, and `GunAudio` opens an
369
+ AudioContext browsers cap around six. Give the extra systems no-op `update`/`dispose` wrappers around the
370
+ shared three — the same pattern the shooter templates use for replicas.
371
+
372
+ **The behaviour loop owns the trigger.** `aimAt(point)` is the only driver surface a gun needs: a **sticky** aim
373
+ triad the NPC holds while it keeps walking wherever `seek` sent it, so a guard patrols and tracks you at once
374
+ (precedence `aimAt` > `face` > move heading; `null` clears it). Everything else is a counter in your tick:
375
+
376
+ ```ts
377
+ // The gun's own numbers, read as DEFAULTS — a deliberate guard fires slower than its rpm, and may say so.
378
+ const FIRE_COOLDOWN_S = Math.max(0.02, 60 / profile!.fire.rpm);
379
+ const MAGAZINE = profile!.ammo.clip;
380
+ const RELOAD_S = guard.character.services.config.get<number>('gun-control.reloadDuration');
381
+ let clip = MAGAZINE, fireT = 0, reloadT = 0, shots = 0;
382
+
383
+ const eye = new THREE.Vector3(), chest = new THREE.Vector3(), dir = new THREE.Vector3(); // scratch, reused
384
+
385
+ /** Clear line from the eye to the point it wants to shoot — the SAME statics the shot ray resolves against. */
386
+ const losClear = (from: THREE.Vector3, to: THREE.Vector3): boolean => {
387
+ const d = from.distanceTo(to);
388
+ return d < 1e-3 || body.castRay(from, dir.copy(to).sub(from).divideScalar(d), d) === null;
389
+ };
390
+
391
+ /** Runs AFTER npcs.update(dt): the aim, the proxy camera and the ray all read THIS frame's transforms. */
392
+ const guardTick = (dt: number): void => {
393
+ const bb = guard.character.blackboard;
394
+ const self = guard.driver.position;
395
+ eye.set(self.x, self.y + 1.65, self.z); // the NPC's eye
396
+ chest.set(body.position.x, body.position.y + 1.4, body.position.z); // the point on you it aims at
397
+ const engaged = eye.distanceTo(chest) <= ENGAGE_RANGE_M && losClear(eye, chest); // your range, your LoS policy
398
+ if (engaged) { guard.driver.stop(); guard.driver.aimAt(chest); } // re-issued, so it tracks you
399
+ else { guard.driver.aimAt(null); if (guard.driver.arrived) guard.driver.seek(nextPost(), { arriveM: 0.8 }); }
400
+ bb.set('net:aiming', engaged);
401
+
402
+ reloadT = Math.max(0, reloadT - dt);
403
+ if (reloadT === 0 && bb.get<boolean>('net:reloading')) { bb.set('net:reloading', false); clip = MAGAZINE; }
404
+ if (!engaged || reloadT > 0) return;
405
+ fireT = Math.max(0, fireT - dt);
406
+ if (fireT > 0) return;
407
+ if (clip === 0) { bb.set('net:reloading', true); reloadT = RELOAD_S; return; } // the reload one-shot plays itself
408
+ clip -= 1;
409
+ fireT = FIRE_COOLDOWN_S;
410
+ bb.set('net:shotSeq', (shots += 1)); // MONOTONIC: the ability reacts to the DELTA and re-anchors on anything else
411
+ };
412
+ ```
413
+
414
+ **Who controls how this guard fights? You do — all of it.** The engine owns exactly three things: the **pose**
415
+ (stance, aim, recoil, the reload one-shot), the **FX** (flash, sound, shells, tracer) and the **hit math**
416
+ (spread cone, capsule + head narrow phase, the wall ray). Every knob a player would call behaviour is a number
417
+ in your loop:
418
+
419
+ | Knob | Where it lives |
420
+ |---|---|
421
+ | **fire cadence, bursts, hesitation** | when your tick increments `net:shotSeq`. `profile.fire.rpm` is a default to read, not a rule |
422
+ | **damage per hit** | whatever `onPlayerHit` subtracts below. The profile's number ×2 on a head is a CONVENTION, not policy |
423
+ | **magazine & reload timing** | your counter and your timer, gated by `net:reloading`; `ammo.clip` / `gun-control.reloadDuration` are defaults |
424
+ | **engage range** | your distance test — there is no aggro system |
425
+ | **line of sight** | your `castRay` policy, including "no LoS check at all" |
426
+ | **which gun** | any prop URL; the profile resolves by BASENAME (`aliasWeaponProfile` first for a hashed CDN URL) |
427
+ | **accuracy** | `config.set('gun-control.spreadScale', …)` on that NPC — the authored dial into its cone |
428
+
429
+ **Damage back at the player — one `GunHitSystem` per armed NPC.** It traces from a *camera*, and a headless NPC
430
+ has none, so give it a proxy that stands at the eye and looks down the aim:
431
+
432
+ ```ts
433
+ const DAMAGE = profile!.damage.base; // a default to READ — what a hit costs is the world's call
434
+ const HEAD_MULTIPLIER = profile!.damage.headMultiplier; // ×2 on a head is a convention, not platform policy
435
+ const hp = (): number => character.blackboard.get<number>('health');
436
+ const headAt = new THREE.Vector3(); // scratch for the head read — consumed before the next
437
+ // Never added to the scene — nothing renders through it; it exists to carry an origin and a direction.
438
+ const guardEye = new THREE.PerspectiveCamera();
439
+
440
+ const gunHit = new GunHitSystem({
441
+ camera: guardEye,
442
+ // The PLAYER's RapierBody: the only one carrying the level, so walls block the shot — and it excludes its own
443
+ // capsule, so the player is hit as a CANDIDATE, never as a wall. A 'stand' NPC's body has no statics at all.
444
+ body,
445
+ walkSpeedMps: guard.character.services.config.get<number>('locomotion.walkSpeed'), // live config, never literals
446
+ maxSpeedMps: guard.character.services.config.get<number>('locomotion.runSpeed'),
447
+ muzzle: (out) => fx.muzzleWorld(out), // arms the near-hit muzzle veto (shooter-worlds §4)
448
+ players: function* () { // the SHOOTER is the NPC, so the player is a legal candidate
449
+ if (character.blackboard.get<boolean>('dead')) return;
450
+ yield {
451
+ id: 'player',
452
+ position: character.model.position, // FEET
453
+ crouched: body.isCrouched,
454
+ ragdolled: character.ragdoll?.active ?? false,
455
+ // getWorldPosition through the ancestors — the character tick leaves matrixWorld stale (§4b aims off this).
456
+ head: () => character.services.sockets?.anchorOf('head')?.getWorldPosition(headAt) ?? null,
457
+ };
458
+ },
459
+ onPlayerHit: ({ claim, part, point }) => {
460
+ if (!claim) return; // exactly ONE claim per trigger pull
461
+ character.hitReaction.setHint(hitBearingDeg(body.facingYaw, character.model.position, point)); // §4b
462
+ character.setHealth(hp() - DAMAGE * (part === 'head' ? HEAD_MULTIPLIER : 1)); // YOUR number, not the engine's
463
+ },
464
+ });
465
+ gunHit.attachTo(guard.character); // latches `gunFired`, which the replica lane emits per shotSeq delta
466
+ gunHit.setWeapon(GUN_URL);
467
+ ```
468
+
469
+ **The frame order is a contract, exactly as it is for a player's gun** — everything gun resolves after the
470
+ character ticks that moved the bodies:
471
+
472
+ ```ts
473
+ character.update(dt); // the player
474
+ npcs.update(dt); // every NPC (behaviours run inside)
475
+ spots.update(dt);
476
+ guardTick(dt); // the attack machine, on transforms both ticks just settled
477
+ guardEye.position.copy(eye); guardEye.lookAt(chest); // the proxy rides the aim, so the traced shot IS the aim
478
+ gunHit.update(dt); // the ray fires against THIS frame's player transform
479
+ fx.update(dt); // latched shots resolve at THIS frame's muzzle
480
+ ```
481
+
482
+ > **`character.health.enabled` is OFF by default — on the player and on every NPC.** Without
483
+ > `character: { health: { enabled: true, maxHealth: 100 } }` on whoever is being shot, `setHealth` publishes
484
+ > nothing, no `healthChanged` fires and no flinch plays: the guard aims, the muzzle flashes, the brass ejects,
485
+ > and the hit lands on nobody. Turn it on for anything that can be hurt — a shootable NPC needs it too.
486
+
487
+ **Publish the attack machine's phase through `npcState`** (§1) — `{ phase: engaged ? 'engaging' : 'patrol',
488
+ clip, reloading }` — and every `inspect_npc` sample carries it (§5). That timeline is how you see a guard that
489
+ engages and never fires (a swallowed `shotSeq`) or one that never leaves reload, neither of which a screenshot
490
+ can show.
491
+
492
+ ## 4b. Hit reactions — the flinch layer *(humanoid-character ≥ 0.3.13 · asset pack `humanoid-character-assets` ≥ 0.1.6 — the ten hit takes)*
493
+
494
+ **Every character already carries it and it is on by default** — the player, a replica, an NPC. Any health
495
+ DECREASE the body survives plays a directional one-shot **additively** over whatever it is already doing
496
+ (walking, aiming, mid-emote), so nothing about the gait or the grip is replaced. There is nothing to register
497
+ and nothing to wire: `setHealth` is the trigger.
498
+
499
+ - **Aim it with a bearing.** `character.hitReaction.setHint(bearingDeg)` **before** the `setHealth` that fires
500
+ it (that flinch consumes the hint): 0 = the hit came from ahead, +90 = the victim's right, ±180 = behind.
501
+ Compute it from the hit point with the exported `hitBearingDeg(facingYaw, victimPosition, from)` — the impact
502
+ point stands in for the shooter, because it is always on the side the ray came in from. Without a hint the
503
+ flinch falls through to the front family. **In multiplayer you author no bearing at all**: the platform
504
+ derives it from the attacker's seat when your damage rule writes the `lastHitBy` playerVar — declare that var
505
+ and set it in the rule, as the shooter templates already do.
506
+ - **Scripted flinches** ignore health entirely: `character.hitReaction.play({ direction: 'back', intensity:
507
+ 'heavy' })` (or `{ bearingDeg }`) for a shove, a melee that costs no hp, a cutscene beat.
508
+ - **Tune with `character.hitReactions.*`** — `enabled`, `weight` (0-1, how much reaches the body), `blendInS` /
509
+ `blendOutS`, `cooldownS` (the pellet-storm guard: several damage events in one frame must not re-trigger at
510
+ frame 0 forever) and the `lightFraction` / `mediumFraction` damage thresholds the auto-trigger buckets on.
511
+ Read them from `get_package_manifest("humanoid-character")`, never guess.
512
+ - **The coverage is honest: HEAVY is authored on the front only.** Heavy from a side or behind degrades to that
513
+ direction's medium rather than turning into a front flinch — the direction reads louder than the force. The
514
+ killing blow never flinches (the death stagger and the ragdoll own that body) and neither does a limp one.
515
+
516
+ > **The FIRST health change per character is deliberately swallowed.** It is a multiplayer seeding guard — a
517
+ > late joiner arriving at 40 hp must not flinch on frame one — and it applies to a solo world too, so the
518
+ > session's first hit on a given body does not react. A world that cares burns the seed at spawn with a
519
+ > scratch: `setHealth(maxHealth - 1)` (swallowed) then `setHealth(maxHealth)` (an increase never flinches), and
520
+ > every real hit after it reacts.
521
+
522
+ ## 5. Budgets & verification *(humanoid-character ≥ 0.3.13)*
523
+
524
+ **The budget is 16 live NPCs (`maxNpcs`), and going past it throws:**
525
+
526
+ ```
527
+ NpcScene.add('guard-9'): NPC budget full (maxNpcs = 16) — remove one or raise maxNpcs
528
+ ```
529
+
530
+ That is a refusal, not a silent degrade, because an NPC that never appears is a world-logic bug and you want to
531
+ find it at `add()` rather than in a screenshot. The number is real cost, not ceremony: **each NPC is a full
532
+ skinned character** — its own model clone, anim graph, mixer, router and driver, plus a private Rapier world for
533
+ every `'physics'` one. Raising `maxNpcs` is a decision about your frame budget; a crowd of standing NPCs is far
534
+ cheaper than a pack of hunting ones. Every refusal (duplicate id, full budget, physics without geometry) is
535
+ decided **before the first await**, so a rejected `add()` has built and added nothing:
536
+
537
+ ```
538
+ NpcScene.add: an NPC with id 'zombie' already exists
539
+ ```
540
+
541
+ Housekeeping is symmetric: `npcs.get(id)` / `has` / `list()` / `ids()` / `count` read the pool, `remove(id)`
542
+ tears one down completely (false if the id is unknown), `dispose()` frees all of them — model, body, private
543
+ router, plate — and `unregister()` from `spots.register(...)` retires the pill that followed one.
544
+
545
+ **Verifying NPCs** *(humanoid-character ≥ 0.3.13; `inspect_npc` and `capture_npc` additionally need the world
546
+ **rebuilt** since §1's `world.ready({ …, npcs, npcState })` line — a bundle built before it exposes no NPC
547
+ surface, and both tools fail naming the exact line and whether a reinstall is needed first)*:
548
+
549
+ **Measure first, look second — the order is a rule, not a preference.** Every way an NPC goes wrong here is
550
+ *silent*, and none of them is visible in a still frame: the chaser that never attacks (rule 1), the one grinding
551
+ into a corner (rule 2), an NPC on `body: 'stand'` that physically cannot walk, a NavGrid that degraded to direct
552
+ steering without a word (§3). A screenshot shows a character standing in a room in all four cases.
553
+
554
+ 1. **`inspect_npc({ directory: "<world>/dist", seconds: 10, target: [x, y, z] })`** — boots the built world
555
+ headlessly, watches it run, and reports per NPC: distance actually **walked** vs distance actually **gained**
556
+ (the progress ratio — 1.00 is a straight line), start/closest/final distance to `target` and how much ground
557
+ it **closed**, **stuck windows** on the driver's own rule (under 0.35 m in 1.5 s — the same stall its sideways
558
+ commit fires on), the **`arrived` edges** with timestamps, how much of the run held a planned nav path at all,
559
+ and the **locomotion + `npcState` timelines**. Pass `npc: "zombie"` to scope it to one.
560
+ 2. **Read the three that decide whether the behaviour works.** *Did it close on the target?* — a chase that moves
561
+ and never closes is rule 1. *Any stuck windows?* — rule 2, or a doorway the grid rasterized shut. *Was it ever
562
+ on a path?* — `nav path: 0%` while it moves means the grid produced no route and nobody said so.
563
+ 3. **`capture_npc({ directory: "<world>/dist", npc: "zombie" })`** — only once the numbers pass. A captioned
564
+ filmstrip of the running world: each frame carries the sample taken at that same instant, so "T-posing at
565
+ 4 s" is answerable. It answers *presence, body, pose, surroundings* and nothing about distance — and it has
566
+ no camera control, so the frames are the world's own view, not a shot framed on the NPC.
567
+ 4. **`world_metrics({ projectDir })`** — **before** you place the geometry an NPC has to walk through: the step
568
+ height, slope and corridor clearances it reports for the player are the same envelope the NPC's capsule has,
569
+ and they are what §3's corridor rule is measured against.
570
+
571
+ A failed capture or inspection is still diagnostic: page errors come back in the timeout message, which is where
572
+ a `NpcScene.add` throw will be.
573
+
574
+ ## 6. Multiplayer — where NpcScene stops *(humanoid-character ≥ 0.3.13)*
575
+
576
+ **`NpcScene` NPCs are client-local.** Nothing about them crosses the wire: each client that loads the world
577
+ builds its own copy and runs its own behaviour. For a **stand** NPC that is exactly right — it never moves, so
578
+ every client agrees about it for free, and a shopkeeper with a Talk spot needs no server involvement at all.
579
+ For anything that *moves*, it is exactly wrong: four clients each run their own zombie chasing their own
580
+ player, and no two players see the same fight. The multiplayer answer is to move the brain to the room and keep
581
+ the humanoid as pure **rendering** — the enemy becomes a declared entity, and
582
+ `humanoidEntities({ scene, assets: mp.assets })` is the `mp.entities` build fn that renders each one as a full
583
+ headless character, deriving its locomotion animation from the replicated motion. Damage follows the same
584
+ split: the entity carries a declared **zone** and a `zoneEnter` rule subtracts health server-side — the client
585
+ never writes health, and the attack animation still causes nothing.
586
+
587
+ ```
588
+ read_template({ name: "npc-wave" })
589
+ ```
590
+
591
+ ## The rules that matter (NPCs)
592
+
593
+ - **Hand the pool to `world.ready`, then MEASURE before you look.** `world.ready({ scene, body, spawn, npcs,
594
+ npcState })` is what makes `inspect_npc` possible at all; every NPC failure mode on this system is silent, so
595
+ numbers first and `capture_npc` last (§5).
596
+ - **An NPC is a Character whose driver is your behaviour.** Same chassis, same config, same abilities — do not
597
+ hand-roll a "monster" out of meshes and lerps, and do not invent an NPC API. Read `capabilities.api` from
598
+ `get_package_manifest("humanoid-character")`.
599
+ - **Read `arrived` before you re-seek, and re-seek only when the target moved.** The first mistake makes a
600
+ harmless chaser; the second makes one that grinds into every wall. Both are silent.
601
+ - **The animation never causes damage.** The behaviour owns wind-up → damage window → recovery; solo damage is
602
+ `setHealth`, multiplayer damage is a server rule.
603
+ - **An armed NPC is `gun-control` in REPLICA mode, armed inside `decorate`.** `remoteDriven` (local mode's ADS
604
+ wants a camera an NPC has not got) plus the `net:*` keys you register yourself — `net:shotSeq` at 0, because
605
+ its first observation only anchors. Cadence, damage, magazine, range, LoS and accuracy are all your loop's;
606
+ the engine owns the pose, the FX and the hit math (§4a).
607
+ - **Flinches are automatic on any survived health drop, on every character.** `setHint(bearingDeg)` before
608
+ `setHealth` aims one; the first health change per body is swallowed by design, and `character.health.enabled`
609
+ is off until you turn it on (§4b).
610
+ - **`body: 'stand'` never walks** (a seek on it is inert; `teleport` is the only move) and **`body: 'physics'`
611
+ needs `staticsFrom`** — the scene refuses without it.
612
+ - **One source of truth for the level.** Build the nav grid from the same `describeColliders()` roster
613
+ `staticsFrom` mirrors, or the planner will route through a wall the capsule cannot pass.
614
+ - **NavGrid v1 is single-plane, yaw-aligned cuboids only, and degrades to direct steering in silence.** Check
615
+ the skipped-geometry warn before theorising, and keep corridors wider than `2 × agentRadiusM + cellM` (1.3 m
616
+ at defaults).
617
+ - **Verify a Vault rig before you install it** (`skeleton` / `skeletonVerification` on the search row) and
618
+ recognize the convert-first throw: `import_character` first, install second.
619
+ - **16 NPCs is the default budget and add() throws past it.** Each one is a full skinned character; a raised
620
+ cap is a frame-budget decision.
621
+ - **Loop order: player → NPCs → spots.** The press edge and the pill anchors both depend on it.
622
+ - **NpcScene NPCs never replicate.** Anything that moves and must be shared belongs to the room —
623
+ `read_template({ name: "npc-wave" })`.