@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
@@ -12,6 +12,25 @@ lock side-on movement to one axis — see §8a).
12
12
  > For a bare scene with no character (a spinning object, a data viz, a menu), use the **scene**
13
13
  > recipe instead: `get_started({ kind: "scene" })`.
14
14
 
15
+ > ## SCAFFOLD FIRST — DO NOT HAND-WRITE THESE FILES
16
+ >
17
+ > **Call `scaffold_world` and run the `helix init` command it returns.** It writes all nine files —
18
+ > `package.json`, `vite.config.ts`, `tsconfig.json`, `index.html`, `.gitignore`, `public/helix.json`,
19
+ > `src/helix.runtime.ts`, `src/loading.ts`, and a **runnable `src/main.ts`** — already correct and already
20
+ > building. Existing files are skipped by default, so it is safe to run inside an existing project.
21
+ >
22
+ > **Everything below is EXPLANATION, not source to copy.** The snippets show what each generated file
23
+ > means and how to change it. An agent that types them out instead of scaffolding reproduces the exact
24
+ > failure this recipe exists to prevent: the last time one did, it got four API calls wrong against the
25
+ > installed `.d.ts` (`loadCharacterAssets` takes the asset base **positionally**; `RapierBody.create` is a
26
+ > static factory with a private constructor; `Character.create` takes `{model, camera, domElement, body,
27
+ > input}`; the world needs a floor collider) and shipped a world that built, validated, and rendered
28
+ > nothing. **The scaffold is the source of truth. Read the installed `.d.ts` before any call you are
29
+ > unsure of — never this prose.**
30
+ >
31
+ > Then: `npm install` → `install_world_packages` → `npm run build` → `validate_world` →
32
+ > **`helix verify-subpath dist`** → `publish_world` (§9).
33
+
15
34
  ## 0. Discover what's published FIRST — don't assume
16
35
 
17
36
  The catalog grows. Before you design anything, **fetch the latest** so you reuse what already exists
@@ -29,27 +48,37 @@ shooting → `gun-control`) before writing any custom behavior.
29
48
 
30
49
  ## 1. Project layout
31
50
 
51
+ `helix init` writes every file marked ✎ below. `helix install` fills in the rest.
52
+
32
53
  ```
33
54
  my-world/
34
- ├── package.json
35
- ├── vite.config.ts
36
- ├── tsconfig.json
37
- ├── index.html
55
+ ├── package.json ✎
56
+ ├── vite.config.ts ✎
57
+ ├── tsconfig.json ✎
58
+ ├── index.html ✎
59
+ ├── .gitignore ✎ node_modules/, dist/, public/helix_modules/
38
60
  ├── helix.lock.json ← written by `helix install`; commit it
39
61
  ├── public/
40
- │ ├── helix.json ← the v0.2 manifest (pins); lands at dist/ root on build
62
+ │ ├── helix.json ✎ the v0.2 manifest (pins); lands at dist/ root on build
41
63
  │ └── helix_modules/ ← written by `helix install` (code only); gitignored
42
64
  └── src/
43
- ├── main.ts
44
- ├── loading.ts ← the loading screen (count-based progress bar); restyle/extend freely (§8b)
45
- └── helix.runtime.ts ← written by `helix install`; committed (do not hand-edit)
65
+ ├── main.ts ✎ a RUNNABLE starter — edit it, don't replace it
66
+ ├── loading.ts ✎ the loading screen (count-based progress bar); restyle/extend freely (§8b)
67
+ └── helix.runtime.ts ✎ stub, then overwritten by `helix install`; committed (do not hand-edit)
46
68
  ```
47
69
 
48
70
  ## 2. package.json
49
71
 
50
72
  `three` is a **devDependency**: it is external at runtime (the platform hosts the single shared
51
73
  instance), but Vite needs it at build time to bundle the GLTFLoader/KTX2Loader addons + for types.
52
- `@dimforge/rapier3d-compat` (the physics engine) bundles into the world.
74
+ `@dimforge/rapier3d-compat` (the physics engine) works the same way — a devDependency for types and
75
+ `helix dev`, import-mapped to the platform-hosted instance at runtime. It never bundles.
76
+
77
+ **Keep the three devDependency on the platform's line — `0.185.1` today.** The core comes from the import
78
+ map, but the `three/examples/jsm/...` addons bundle out of THIS copy and `helix dev` runs it, so a stale
79
+ devDep ships mismatched halves and makes local dev disagree with the published build. The system pin is what
80
+ decides the hosted version (`humanoid-character` `^0.3` → three 0.185.1); new publishes targeting an older
81
+ three are refused — `read_doc({ name: "upgrades" })` has the migration for an existing world.
53
82
 
54
83
  ```json
55
84
  {
@@ -57,8 +86,8 @@ instance), but Vite needs it at build time to bundle the GLTFLoader/KTX2Loader a
57
86
  "private": true,
58
87
  "type": "module",
59
88
  "scripts": { "dev": "vite", "build": "vite build", "preview": "vite preview", "typecheck": "tsc --noEmit" },
60
- "dependencies": { "@dimforge/rapier3d-compat": "^0.14.0", "@hypersoniclabs/helix-sdk": "{{SDK_DEP_SPEC}}" },
61
- "devDependencies": { "@types/three": "^0.172.0", "three": "^0.172.0", "typescript": "~5.7.3", "vite": "^6.0.0" }
89
+ "dependencies": { "@hypersoniclabs/helix-sdk": "{{SDK_DEP_SPEC}}" },
90
+ "devDependencies": { "@dimforge/rapier3d-compat": "^0.14.0", "@types/three": "^0.185.1", "three": "^0.185.1", "typescript": "~5.7.3", "vite": "^6.0.0" }
62
91
  }
63
92
  ```
64
93
 
@@ -67,25 +96,48 @@ The system itself is NOT an npm dependency — `helix install` materializes it u
67
96
 
68
97
  ## 3. vite.config.ts
69
98
 
70
- Externalize **only the bare `three`** so the world, the embedded system, and every ability share the
71
- one hosted instance (two copies of three break `instanceof` and silently corrupt rendering).
99
+ **`base: './'` is REQUIRED and is the single most important line in this file.** A published world is
100
+ served from a nested path (`…/instant-worlds/<id>/<build>/`), never the origin root, so root-absolute
101
+ asset URLs 404 in production while working perfectly in local preview. A relative base is what makes one
102
+ build work in both places.
103
+
104
+ Externalize the **bare `three`** so the world, the embedded system, and every ability share the
105
+ one hosted instance (two copies of three break `instanceof` and silently corrupt rendering), and
106
+ `@dimforge/rapier3d-compat` so the ~2 MB physics engine loads once from the platform, not per world.
72
107
  `three/examples/jsm/...` is NOT externalized — Vite bundles the addons from your `node_modules` three.
73
108
 
109
+ The scaffold writes the two aliases a starter world uses. Add the others **only when you import them** —
110
+ the `engine-core` SUBPATH entries must always precede any root `@helix/engine-core` entry, because rollup
111
+ substitutes by `'/'`-boundary prefix in object order and a root alias listed first mangles the subpath
112
+ import into an ENOENT.
113
+
74
114
  ```ts
75
115
  import { defineConfig } from 'vite';
76
116
  import { fileURLToPath } from 'node:url';
77
117
 
78
118
  export default defineConfig({
79
- base: './',
119
+ base: './', // ← REQUIRED; `helix verify-subpath dist` proves it
80
120
  resolve: {
81
121
  alias: {
122
+ // Written by the scaffold:
123
+ '@helix/engine-core/inspect': fileURLToPath(new URL('./public/helix_modules/engine-core/inspect/index.js', import.meta.url)),
82
124
  '@helix/humanoid-character': fileURLToPath(new URL('./public/helix_modules/humanoid-character/index.js', import.meta.url)),
125
+ // Add when you use them (screenshots — read_doc "screenshots"; subpaths BEFORE the root entry):
126
+ // '@helix/engine-core/screenshot': fileURLToPath(new URL('./public/helix_modules/engine-core/screenshot/index.js', import.meta.url)),
127
+ // '@helix/engine-core/world-camera': fileURLToPath(new URL('./public/helix_modules/engine-core/world-camera/index.js', import.meta.url)),
128
+ // '@helix/engine-core': fileURLToPath(new URL('./public/helix_modules/engine-core/index.js', import.meta.url)),
83
129
  },
84
130
  },
85
- build: { target: 'es2022', rollupOptions: { external: ['three'] } },
131
+ build: {
132
+ target: 'es2022',
133
+ rollupOptions: { external: (id) => id === 'three' || id === '@dimforge/rapier3d-compat' || id.startsWith('@helix/') },
134
+ },
86
135
  });
87
136
  ```
88
137
 
138
+ Keep that `rollupOptions` line **byte-identical and on one line** — `helix install` matches it literally, and any
139
+ reformatting (an equivalent multi-line form included) makes it classify the world `legacy-bundled` and publish fail.
140
+
89
141
  ## 4. tsconfig.json
90
142
 
91
143
  Point the type path at the embedded system's declarations (installed alongside the code).
@@ -95,7 +147,12 @@ Point the type path at the embedded system's declarations (installed alongside t
95
147
  "compilerOptions": {
96
148
  "target": "ES2022", "module": "ESNext", "moduleResolution": "bundler",
97
149
  "lib": ["ES2022", "DOM"], "strict": true, "noEmit": true, "skipLibCheck": true,
98
- "paths": { "@helix/humanoid-character": ["./public/helix_modules/humanoid-character/index.d.ts"] }
150
+ "paths": {
151
+ "@helix/humanoid-character": ["./public/helix_modules/humanoid-character/index.d.ts"],
152
+ "@helix/engine-core": ["./public/helix_modules/engine-core/index.d.ts"],
153
+ "@helix/engine-core/world-camera": ["./public/helix_modules/engine-core/world-camera/index.d.ts"],
154
+ "@helix/engine-core/screenshot": ["./public/helix_modules/engine-core/screenshot/index.d.ts"]
155
+ }
99
156
  },
100
157
  "include": ["src"]
101
158
  }
@@ -104,9 +161,9 @@ Point the type path at the embedded system's declarations (installed alongside t
104
161
  ## 5. index.html
105
162
 
106
163
  Ship the `helix:three` import-map markers exactly as below — `helix install` rewrites the import map
107
- to the hosted three URL. Leave the placeholder; do not fill it by hand. The `#loading` overlay is the
108
- loading screen: it paints on HTML parse (before any JS), and `src/loading.ts` drives its progress bar
109
- (§8b). Keep the markers and the overlay; restyle the overlay freely.
164
+ to the hosted three and Rapier URLs. Leave the placeholder; do not fill it by hand. The `#loading`
165
+ overlay is the loading screen: it paints on HTML parse (before any JS), and `src/loading.ts` drives its
166
+ progress bar (§8b). Keep the markers and the overlay; restyle the overlay freely.
110
167
 
111
168
  ```html
112
169
  <!doctype html>
@@ -138,6 +195,9 @@ loading screen: it paints on HTML parse (before any JS), and `src/loading.ts` dr
138
195
  #loading-text { margin-top: 12px; color: #9aa7b4; }
139
196
  @keyframes helix-marquee { 0% { transform: translateX(-110%); } 100% { transform: translateX(370%); } }
140
197
  @keyframes helix-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
198
+ /* HUD — the player shell overlays its chrome bar across the TOP-CENTER (~top 56px: Exit / Save / helixOS).
199
+ Keep your HUD OUT of that strip: anchored top-left here; use a corner or the bottom, never top-center. */
200
+ #hud { position: fixed; top: 12px; left: 12px; z-index: 10; max-width: min(340px, 40vw); font: 13px/1.5 system-ui, sans-serif; color: #e6edf5; }
141
201
  </style>
142
202
  </head>
143
203
  <body>
@@ -158,9 +218,9 @@ loading screen: it paints on HTML parse (before any JS), and `src/loading.ts` dr
158
218
  ## 6. public/helix.json — pin what you discovered in step 0
159
219
 
160
220
  `helixVersion: "0.2"` opts into systems/abilities. Pin the system + every ability you want as
161
- `slug -> semver range` (use `^0.2` unless `get_package_manifest` shows a newer line — and keep the system and
162
- ability pins on the SAME minor: an ability's bundle pins the system minor it was built for, so mixing e.g.
163
- system `^0.2` with an ability `^0.1` makes the ability refuse to load at runtime). `helix install`
221
+ `slug -> semver range` (the system is `^0.3` unless `get_package_manifest` shows a newer line — and pin each
222
+ ability at the version `get_package_manifest` reports for that system line: an ability's bundle declares the
223
+ system range it was built against and refuses to load at runtime outside it). `helix install`
164
224
  resolves each range to an exact version and bakes it into `helix.lock.json`.
165
225
 
166
226
  ```json
@@ -173,11 +233,16 @@ resolves each range to an exact version and bakes it into `helix.lock.json`.
173
233
  "permissions": ["auth.profile"],
174
234
  "supportsMobile": true,
175
235
  "contentRating": "everyone",
176
- "systems": { "humanoid-character": "^0.2" },
177
- "abilities": { "fly": "^0.2", "swim": "^0.2" }
236
+ "systems": { "humanoid-character": "^0.3", "engine-core": "^0.1.2" },
237
+ "abilities": { "fly": "^0.3", "swim": "^0.3" }
178
238
  }
179
239
  ```
180
240
 
241
+ **Keep the `engine-core` pin the scaffold wrote.** It is what makes `inspect_world`, `world_metrics`
242
+ and `capture_world_screenshot` work on this world. A world published without it cannot be measured or
243
+ screenshotted later without a pin edit + reinstall + rebuild + republish — `check_for_updates` reports
244
+ its absence as `missing-recommended` precisely because retrofitting it is the expensive path.
245
+
181
246
  ## 7. src/helix.runtime.ts — stub (helix install overwrites it)
182
247
 
183
248
  ```ts
@@ -188,57 +253,131 @@ export const TRANSCODER_PATH = '/runtime/basis/';
188
253
  export const SYSTEM_ASSET_BASE = '';
189
254
  ```
190
255
 
256
+ These are **placeholders**. `helix install` replaces all four with absolute platform-CDN URLs
257
+ (`https://…/runtime/three/<v>/three.module.js`, `https://…/runtime/basis/three-<v>/`, the system's asset
258
+ base). They are the one place root-absolute-looking values are fine before install, because none of them
259
+ survives it — never hand-edit this file, and never hardcode a `/runtime/...` path of your own.
260
+
191
261
  ## 8. src/main.ts — the flow
192
262
 
263
+ **`helix init` already wrote a working `src/main.ts`.** Read that file, then edit it. What follows is
264
+ the same flow annotated, so you understand what you are changing — it is not a file to type out.
265
+
193
266
  `loadCharacterAssets` streams the body/face LOD + locomotion clips from the CDN (never bundled);
194
267
  `Character.create` drives the chassis; `LocomotionAbility` is the built-in movement; everything you
195
268
  pinned loads via `loadInstalledAbilities`. With the `avatar` option, a logged-in player's equipped
196
269
  **universal avatar** becomes their body automatically — skeleton-gated and fault-tolerant (guests, no
197
270
  avatar, an unconverted rig, or any load failure all fall back to the default body; the world never
198
- breaks). Delete the `avatar:` line to opt the world out (see `character.universalAvatar.enabled`).
271
+ breaks). **To opt a single-player world out, delete the `avatar:` line** — that is the whole opt-out, because in the bare-`Character` recipe the world resolves the avatar itself. Do *not* reach for `character.universalAvatar.enabled: false` for this: that is the native feature set's TOP SWITCH and it also turns off emotes, the emote wheel, x-ray, pointing, sitting and the avatar camera (§8g).
272
+
273
+ **Changing the player's avatar: there is ONE function — `mp.changeAvatar(...)`** *(humanoid-character ≥ 0.3.90 / 0.2.69)*.
274
+ ```ts
275
+ const outcome = await mp.changeAvatar(inventoryItemId); // an avatar instance the player OWNS
276
+ await mp.changeAvatar(null); // take it off → the platform default body
277
+ // outcome: 'changed' | 'unchanged' | 'refused' | 'unreachable' | 'unsupported'
278
+ ```
279
+ It is the same call the quick-slot ring and a Home's equip station make. It puts the avatar placeholder up at
280
+ once, persists the change through the platform (which checks ownership and keeps the player to one body),
281
+ swaps the body when the new avatar loads, and hints the room so **every other player sees it** — and it
282
+ survives a reload. An equip station in your world is just a prompt whose action calls this. **Do not** build
283
+ your own: no `Helix.avatar.updateLoadout` to change the body, no loading a GLB onto the local character, no
284
+ hand-rolled placeholder or room message. There is deliberately no local-only variant — other players see only
285
+ what the platform says the player wears. Details: `helix-web-engine-new/docs/architecture/avatar-change.md`.
286
+
287
+ **Mid-room avatar swaps are transparent — do not write swap code.** When the player equips a different
288
+ avatar mid-session, the platform rebuilds the body behind a stable façade: `mp.local` keeps its identity
289
+ (it is a `LocalCharacter` seat, not the raw `Character`), attachments re-bind to the same handles, ability
290
+ installs replay, and runtime config — ammo included — carries over. Everything wired through
291
+ `mp.attach(...)` / `mp.local` (events, services, combat systems) keeps working untouched. Do NOT rebuild
292
+ on `Helix.avatar.onAvatarChanged` — it fires pre-swap and debounced, the wrong hook. The one legitimate
293
+ use of a swap hook is body-INTERNAL wiring: something you `model.add(...)`ed onto the old body, a held IK
294
+ reach handle, a listener registered on a per-body object. For those, subscribe ONCE to
295
+ `mp.onLocalSwap(cb)`: `{ phase: 'started', url }` fires while the old body is still live (checkpoint
296
+ window), `{ phase: 'ended', url, recovered, props }` after the rebuild — `props` lists each surviving
297
+ attachment as `{ handle, previousObject }` (same handle, re-bound; `previousObject` is your key for
298
+ re-pointing any world-side caches). It rides the seat, so one subscription hears every swap.
199
299
 
200
300
  ```ts
201
301
  import * as THREE from 'three';
202
302
  import { Helix } from '@hypersoniclabs/helix-sdk';
203
- import { Character, loadCharacterAssets, loadInstalledAbilities, LocomotionAbility, RapierBody } from '@helix/humanoid-character';
303
+ import { Character, InputService, loadCharacterAssets, loadInstalledAbilities, LocomotionAbility, RapierBody } from '@helix/humanoid-character';
304
+ import { world } from '@helix/engine-core/inspect';
204
305
  import { SYSTEM_ASSET_BASE, TRANSCODER_PATH } from './helix.runtime';
205
306
  import { createLoadingScreen } from './loading'; // loading screen — hooks DefaultLoadingManager (§8b)
206
307
 
308
+ const SPAWN = { x: 0, y: 0.2, z: 0 };
309
+
207
310
  const scene = new THREE.Scene();
311
+ scene.background = new THREE.Color(0x14161a); // an unset background renders as a void
208
312
  const camera = new THREE.PerspectiveCamera(60, innerWidth / innerHeight, 0.05, 200);
209
313
  const renderer = new THREE.WebGLRenderer({ antialias: true });
210
314
  renderer.setSize(innerWidth, innerHeight);
315
+ renderer.outputColorSpace = THREE.SRGBColorSpace;
211
316
  document.body.appendChild(renderer.domElement);
317
+
318
+ // LIGHTING: the two hand-placed lights below are the CLASSIC starter — what the scaffold still writes,
319
+ // and what a world that keeps its own visual style (a deliberate opt-out) ships. The PLATFORM DEFAULT is
320
+ // the visual runtime: hand it the canvas, declare how the world is lit in public/helix.visuals.json, and
321
+ // delete both lights AND the renderer block above — createVisualRuntime owns the renderer, sky, sun,
322
+ // tone mapping and grade, and bounded static worlds then ship BAKED lighting at publish. The conversion
323
+ // is five mechanical steps: read_doc({ name: "lighting-world" }). If you keep the classic path:
324
+ // ambient fill ALONE leaves a dark character on a dark background — add a key light, or the world
325
+ // renders as a near-empty frame and `verify-subpath` fails it on blank-render (correctly).
326
+ // Either lane, making the world LOOK good — asset checks, materials, textures, composition —
327
+ // is read_doc({ name: "world-look" }).
212
328
  scene.add(new THREE.HemisphereLight(0xbfd4ff, 0x40382a, 0.9));
329
+ const sun = new THREE.DirectionalLight(0xffffff, 2.2);
330
+ sun.position.set(6, 12, 4);
331
+ scene.add(sun);
213
332
 
214
333
  // Mount the loading screen before any loader runs — it hooks THREE.DefaultLoadingManager so every asset
215
334
  // load below (character, abilities, any extra GLB you add) counts on the bar automatically. See §8b.
216
335
  const loading = createLoadingScreen();
217
336
 
218
337
  // Init FIRST — the body is bind-once, so the player's avatar must resolve before Character.create.
219
- await Helix.init();
220
- const equipped = await Helix.avatar.getEquipped(); // null for guests / no avatar / any failure
338
+ // Fail-soft: off-platform (a plain `npm run preview`) there is no session and the world must still run.
339
+ await Helix.init().catch(() => undefined);
340
+ const equipped = await Helix.avatar.getEquipped().catch(() => null); // null for guests / no avatar / any failure
221
341
 
342
+ // NOTE the POSITIONAL signature: the asset base is the FIRST argument, not a field on an options
343
+ // object. Passing an object here fails deep inside the loader with "t.replace is not a function".
222
344
  const { model, clips } = await loadCharacterAssets(SYSTEM_ASSET_BASE, {
223
345
  renderer, transcoderPath: TRANSCODER_PATH,
224
346
  avatar: equipped?.glbUrl ? { url: equipped.glbUrl, skeleton: equipped.skeleton } : undefined,
225
347
  });
226
348
  scene.add(model);
227
349
 
228
- // Assets are in (bar ~90%); the rest is un-metered (Rapier WASM compile, character setup) → show the
229
- // indeterminate "Starting…" tail so the bar never looks frozen, then dismiss on the first frame.
230
- loading.working('Starting…');
350
+ // RapierBody.create is a static async factory — the constructor is PRIVATE, `new RapierBody()` will not
351
+ // compile. The world needs a floor collider or the character falls forever through an empty scene.
352
+ const body = await RapierBody.create({ position: SPAWN });
353
+ body.addStaticCuboid({ x: 24, y: 0.5, z: 24 }, { x: 0, y: -0.5, z: 0 }); // floor COLLIDER, top at y=0
231
354
 
232
- const body = await RapierBody.create({ position: { x: 0, y: 0.2, z: 0 } });
233
- body.addStaticCuboid({ x: 24, y: 0.5, z: 24 }, { x: 0, y: -0.5, z: 0 }); // floor
355
+ // …and a floor you can SEE. The collider above is invisible: physics geometry and visual geometry are
356
+ // separate, and a world with only the collider renders as a character floating in a void.
357
+ const floor = new THREE.Mesh(
358
+ new THREE.BoxGeometry(48, 1, 48),
359
+ new THREE.MeshStandardMaterial({ color: 0x2a3138, roughness: 0.95 }),
360
+ );
361
+ floor.position.y = -0.5;
362
+ scene.add(floor);
363
+
364
+ // ONE world-owned InputService, injected into the character — register every game key on this router,
365
+ // never a raw addEventListener (§8c).
366
+ const input = new InputService();
234
367
 
235
368
  const character = await Character.create({
236
- model, camera, domElement: renderer.domElement, body,
369
+ model, camera, domElement: renderer.domElement, body, input,
237
370
  // FIRST-PERSON: set the initial camera mode. Omit for the third-person default.
238
- config: { character: { camera: { mode: 'first-person' } } },
371
+ config: { character: { spawn: SPAWN, camera: { mode: 'first-person' } } },
239
372
  });
240
373
  character.abilities.register(new LocomotionAbility(clips));
241
- await loadInstalledAbilities('/helix_modules', character); // fly, swim, … from helix.json
374
+
375
+ // Everything `helix install` resolved (fly, swim, … from helix.json). RESOLVE THE MODULES BASE AGAINST
376
+ // document.baseURI. A published world is served from a nested path (…/instant-worlds/<id>/<build>/), so a
377
+ // root-absolute '/helix_modules' 404s in production and THROWS — the world renders nothing but an error
378
+ // bar, while local preview from the origin root looks perfect. `helix verify-subpath dist` proves it.
379
+ const modulesBase = new URL('helix_modules/', document.baseURI).href;
380
+ await loadInstalledAbilities(modulesBase, character);
242
381
 
243
382
  // First-person mouse-look needs pointer lock — request it from a click (browser gesture rule).
244
383
  renderer.domElement.addEventListener('click', () => character.services.input.requestLook());
@@ -248,10 +387,22 @@ let firstFrame = true;
248
387
  renderer.setAnimationLoop(() => {
249
388
  character.update(Math.min(clock.getDelta(), 0.1));
250
389
  renderer.render(scene, camera);
251
- if (firstFrame) { firstFrame = false; loading.dismiss(); } // world on screen → fade the overlay
390
+ if (firstFrame) { firstFrame = false; loading.done(); } // world on screen → fade the overlay
252
391
  });
253
392
  ```
254
393
 
394
+ **One uncaught throw inside this callback stops rendering FOREVER** — three.js schedules the next frame
395
+ only after the callback returns, so a single bad per-frame read ships as "the world froze" (audio keeps
396
+ playing, the last frame stays on screen). Guard reads of state that may not exist yet
397
+ (`blackboard.has(key)` before `get`, optional chaining on services) — the loop is the one place a throw
398
+ has no second chance.
399
+
400
+ ```ts
401
+ // Measurement hook for inspect_world / world_metrics verification (§10). A no-op during normal play —
402
+ // it only activates under ?helixInspect=1. Call it at the END of boot, once the scene is built.
403
+ world.ready({ scene, body, spawn: SPAWN });
404
+ ```
405
+
255
406
  ## 8a. Configure the character — read the knobs from the manifest, NEVER guess
256
407
 
257
408
  **This is the step agents get wrong.** Speed, camera, spawn, slope, jump-feel — almost everything a
@@ -275,8 +426,10 @@ const character = await Character.create({
275
426
  model, camera, domElement: renderer.domElement, body,
276
427
  config: {
277
428
  character: {
278
- spawn: { x: 4, z: -2, facingDeg: 90 }, // where the player starts + which way they look (0 = +Z)
279
- camera: { mode: 'third-person', initialYaw: 90, lookSensitivity: 1.4, fov: 70 },
429
+ // 0° = +Z; 90° = +X. With initialYaw omitted/null, 0.2.21+ derives a stable
430
+ // behind-character camera (spawn facing - 180°), including after respawn.
431
+ spawn: { x: 4, z: -2, facingDeg: 90 },
432
+ camera: { mode: 'third-person', lookSensitivity: 1.4, fov: 70 },
280
433
  },
281
434
  locomotion: { runSpeed: 7, coyoteTimeMs: 120 },
282
435
  // ability namespaces too, once pinned & installed: fly: { maxSpeed: 14 }
@@ -284,6 +437,13 @@ const character = await Character.create({
284
437
  });
285
438
  ```
286
439
 
440
+ > **Spawn facing is durable in `humanoid-character` 0.2.21+.** `character.spawn.facingDeg` is body
441
+ > yaw (`0 = +Z`, `90 = +X`). Leave `character.camera.initialYaw` omitted or `null` and the engine
442
+ > derives the matching behind-character boom yaw as `facingDeg - 180`; locomotion no longer erases
443
+ > that authored facing, and `character.respawn()` restores both body and camera. Set a numeric
444
+ > `initialYaw` only when you intentionally want a camera seed independent of spawn facing. Camera
445
+ > yaw is boom azimuth: `0` places the camera on `+Z` looking toward `-Z`.
446
+
287
447
  **2. Runtime — `character.config.set('ns.key', value)`.** Read live every tick; the change applies
288
448
  immediately. This is how you do timed buffs, difficulty changes, "look at the boss", etc.
289
449
 
@@ -330,6 +490,27 @@ character.camera?.addTrauma(0.6); // camera shake for a hit
330
490
  speed }` — `impactSpeed` is vertical, use it for fall damage), `fell`, `respawned`, `jumped`,
331
491
  `stateEntered`. The full list + payloads are in `capabilities.events`.
332
492
 
493
+ ### Where the character is FACING — read the model, never the camera
494
+
495
+ The camera is NOT the character. In third person it orbits the character freely (and in top-down it is
496
+ fixed), so any "is the player facing X" check built from the camera's position or direction is wrong in
497
+ every framing except first-person. The character's authoritative forward is its model yaw (0 = +Z):
498
+
499
+ ```ts
500
+ const fwd = new THREE.Vector3(0, 0, 1).applyQuaternion(character.model.quaternion);
501
+ const to = obj.position.clone().sub(character.model.position);
502
+ to.y = 0; fwd.y = 0; // horizontal test — height must not affect a facing gate
503
+ const facingIt = to.lengthSq() > 1e-8 &&
504
+ to.normalize().dot(fwd.normalize()) >= Math.cos(THREE.MathUtils.degToRad(50)); // ±50° prompt window
505
+ ```
506
+
507
+ This is the same math the runtime uses internally. **For IK reaches, do not hand-roll it at all** —
508
+ `ik.reach(hand, target, { engage: {} })` already gates on the reaching arm's workspace arc (midline to
509
+ ~135° on that arm's side, ≥ 0.2.17) plus distance, and auto-releases when the player turns or walks
510
+ away; a one-off `press` gates itself the same way with `press: { when: {} }` (≥ 0.2.16 — a failed gate
511
+ fires nothing and the handle reports `fired: false`). See the character-animation doc's Runtime IK
512
+ section. Reserve the snippet above for your own prompts and UI ("press E to interact").
513
+
333
514
  ### Lock the controls to the genre — disable what doesn't fit (don't ship every control everywhere)
334
515
 
335
516
  Every `allow*` gate **defaults to `true`** (all controls enabled). So you must **opt OUT** of the ones
@@ -342,10 +523,10 @@ the manifest `config`):
342
523
 
343
524
  | gate | removes |
344
525
  |---|---|
345
- | `character.camera.allowModeToggle` | first/third-person switch (the **T** action) |
526
+ | `character.camera.allowModeToggle` | the first↔third-person legs of the camera cycle (the **T** action cycles FP → TP → shoulder-R → shoulder-L) |
346
527
  | `character.camera.allowRotate` | orbit / mouse-look (fixed camera angle) |
347
528
  | `character.camera.allowZoom` | scroll-wheel zoom |
348
- | `character.camera.allowShoulderSwap` | over-shoulder side swap (the **V** action) |
529
+ | `character.camera.allowShoulderSwap` | the over-shoulder legs of the camera cycle (**V** is the melee action, not the camera) |
349
530
  | `locomotion.allowJump` / `allowCrouch` / `allowSprint` | the jump / crouch / sprint actions |
350
531
 
351
532
  Genre presets (set these at create time, then add the rest of your tuning):
@@ -366,6 +547,8 @@ config: { locomotion: { allowJump: false } }
366
547
 
367
548
  You can also flip these live (e.g. disable controls during a cutscene): `character.config.set('character.camera.allowModeToggle', false)`.
368
549
 
550
+ The two camera legs are also **native features** with their own refusal ids — `character.native.disabled: ['camera-first-person']` makes the world third-person only and sets `mode` + `allowModeToggle` for you, so every surface that reads the camera policy agrees. Refusing BOTH throws at create. See §8g for the full native set and the rest of the veto.
551
+
369
552
  ### First-person vs third-person (a slice of the camera config)
370
553
 
371
554
  - Initial mode: `config.character.camera.mode` = `'first-person'` or `'third-person'` (default).
@@ -376,86 +559,25 @@ You can also flip these live (e.g. disable controls during a cutscene): `charact
376
559
  ## 8b. Loading screen — `src/loading.ts` (count-based progress, extensible)
377
560
 
378
561
  The `#loading` overlay in `index.html` paints on HTML parse (before any JS downloads → no flash).
379
- `src/loading.ts` then hooks **`THREE.DefaultLoadingManager`** — the SAME instance the engine's
380
- GLTFLoader/KTX2Loader use (one shared three via the import map) — so the character, every ability, and
381
- ANY extra three load you add count on the bar automatically, with no extra code. The bar climbs as
382
- assets stream, shows an indeterminate **"Starting…"** tail for the un-metered window (Rapier WASM
383
- compile, character setup), then fades on the first rendered frame. Ship this file verbatim; restyle freely.
562
+ `src/loading.ts` — **written by `helix init`; open that file, do not retype it** — hooks
563
+ **`THREE.DefaultLoadingManager`**, the SAME instance the engine's GLTFLoader/KTX2Loader use (one shared
564
+ three via the import map). So the character, every ability, and ANY extra three load you add count on the
565
+ bar automatically, with no extra code. Restyle it freely.
384
566
 
385
- ```ts
386
- import * as THREE from 'three';
567
+ The scaffolded module exports exactly this surface — **match it; there is no `dismiss`, `working`,
568
+ `label` or `fail`**:
387
569
 
388
- // Loading screen for this world. It hooks THREE.DefaultLoadingManager — the SAME instance the engine's
389
- // GLTFLoader/KTX2Loader use (one shared three via the import map) — so the character, every ability, and
390
- // ANY extra three load you add (a world GLB, a TextureLoader, …) count on the bar automatically, with no
391
- // extra code. For a NON-three asset (audio, a raw fetch), wrap it in loading.task(). The overlay markup
392
- // + CSS live in index.html so it paints before this module downloads (no flash). Restyle/extend freely.
393
-
394
- const BAND_MAX = 0.9; // the measured climb fills [0, 0.9]; the last tenth is the post-load tail + first frame
395
- const QUEUE_MIN = 5; // go indeterminate→measured only once a real wave is queued (total-loaded ≥ this), so the
396
- // brief body+face 2/2 blip can't fill the bar then snap backward when the 38 clips enqueue
397
-
398
- export interface LoadingScreen {
399
- /** Fold a NON-three async asset into the bar (counts like one item). Call before the work; handle.done() when it resolves. */
400
- task(): { done(): void };
401
- /** Set the status line under the bar. */
402
- label(text: string): void;
403
- /** Enter the indeterminate "Starting…" tail — covers the un-metered window (Rapier WASM compile, character setup). */
404
- working(text?: string): void;
570
+ ```ts
571
+ export type LoadingScreen = {
572
+ /** Register an extra async unit of work so it counts on the bar. Returns its completion callback. */
573
+ task: (label?: string) => () => void;
405
574
  /** One-shot fade + remove. Call AFTER the first renderer.render(), never on manager.onLoad (it fires early). */
406
- dismiss(): void;
575
+ done: () => void;
407
576
  /** Switch to the error state (red bar + message). */
408
- fail(message: string): void;
409
- }
577
+ error: (message: string) => void;
578
+ };
410
579
 
411
- export function createLoadingScreen(): LoadingScreen {
412
- const root = document.getElementById('loading');
413
- const bar = document.getElementById('bar');
414
- const textEl = document.getElementById('loading-text');
415
- const noop = (): void => {};
416
- if (!root || !bar || !textEl) return { task: () => ({ done: noop }), label: noop, working: noop, dismiss: noop, fail: noop };
417
-
418
- const mgr = THREE.DefaultLoadingManager;
419
- let determinate = false; // latched once a real queue appears; never reverts (no marquee↔fill flicker)
420
- let tail = false; // working() latched — ignore further manager ticks; the pulse owns the bar
421
- let shown = 0; // last rendered fraction — monotonic, never decreases
422
- let extra = 0; // open task() handles, folded into the denominator
423
-
424
- const render = (loaded: number, total: number): void => {
425
- if (tail) return;
426
- const t = total + extra;
427
- if (!determinate && t - loaded >= QUEUE_MIN) { determinate = true; root.classList.add('determinate'); }
428
- if (!determinate) return;
429
- const frac = Math.min(loaded / Math.max(t, 1), 1) * BAND_MAX;
430
- shown = Math.max(shown, frac);
431
- bar.style.width = `${(shown * 100).toFixed(1)}%`;
432
- };
433
-
434
- mgr.onStart = (_url, loaded, total) => render(loaded, total);
435
- mgr.onProgress = (url, loaded, total) => {
436
- render(loaded, total);
437
- if (tail) return;
438
- if (/anims\/.*\.glb(\?|$)/.test(url)) textEl.textContent = `Loading animations… ${loaded}/${total}`;
439
- else if (/\.ktx2(\?|$)/.test(url) || /basis_transcoder/.test(url)) textEl.textContent = 'Decoding textures…';
440
- };
441
- // No mgr.onLoad: it fires on the transient body/face 2/2 before the clips queue. Dismissal is the world's
442
- // job — a one-shot after the first rendered frame (see main.ts).
443
-
444
- return {
445
- task() { extra += 1; let closed = false; return { done() { if (!closed) { closed = true; extra -= 1; } } }; },
446
- label(text) { textEl.textContent = text; },
447
- working(text = 'Starting…') { tail = true; root.classList.add('determinate', 'working'); textEl.textContent = text; },
448
- dismiss() {
449
- if (root.classList.contains('done')) return;
450
- root.classList.remove('working');
451
- root.classList.add('determinate');
452
- bar.style.width = '100%';
453
- root.classList.add('done');
454
- setTimeout(() => root.remove(), 450);
455
- },
456
- fail(message) { root.classList.add('error'); textEl.textContent = `ERROR: ${message}`; },
457
- };
458
- }
580
+ export function createLoadingScreen(): LoadingScreen;
459
581
  ```
460
582
 
461
583
  **Add your own assets to the bar.** An extra three load is counted automatically — just load it on the
@@ -463,25 +585,508 @@ default manager (`new GLTFLoader().loadAsync(...)`). For a NON-three asset (audi
463
585
  in `loading.task()`:
464
586
 
465
587
  ```ts
466
- loading.label('Loading music…');
467
- const music = loading.task(); // counts like one item; the bar waits for it
468
- const audio = new Audio(new URL('theme.mp3', document.baseURI).href);
469
- audio.addEventListener('canplaythrough', () => music.done(), { once: true });
588
+ const musicDone = loading.task('Loading music…'); // counts like one item; the bar waits for it
589
+ const audio = new Audio(new URL('theme.mp3', document.baseURI).href); // relative to baseURI — never '/theme.mp3'
590
+ audio.addEventListener('canplaythrough', () => musicDone(), { once: true });
470
591
  audio.load();
471
592
  ```
472
593
 
473
- **Errors.** To surface a load failure on the overlay instead of a frozen bar, guard the awaited setup and
474
- call `loading.fail(...)`:
594
+ **Errors.** The scaffold already wraps boot in a `main().catch(...)` that calls `loading.error(...)` and
595
+ prints the message into the HUD. Keep that guard — a throw with no guard is a frozen bar and a blank
596
+ screen, which is indistinguishable from a hang.
597
+
598
+ **Refinements worth adding if the bar feels rough** (the scaffold keeps the file small on purpose):
599
+ clamp the measured climb to ~90% so the un-metered tail (Rapier WASM compile, character setup) still has
600
+ somewhere to go; only switch marquee→measured once at least ~5 items are queued, so the brief body+face
601
+ `2/2` blip cannot fill the bar and then snap backwards when the clips enqueue; and make the rendered
602
+ fraction monotonic so it never decreases.
603
+
604
+
605
+ ## 8c. Input — the standard action set, the world-owned router, menu contexts
606
+
607
+ The character system ships ONE canonical input vocabulary — the **standard action set**. Movement,
608
+ look, jump, camera come pre-bound (keyboard/mouse + gamepad + touch-ready metadata); your world adds
609
+ its own actions on the **same router** instead of raw `addEventListener`.
610
+
611
+ > **Rule:** the authoritative action list is in `get_package_manifest("humanoid-character")` →
612
+ > **`capabilities.actions`** (per action: id, kind, context, default bindings, touch hint), the
613
+ > semantic gamepad map in **`capabilities.controller`** (`faceDown`/`triggerR`/`dpadUp`… →
614
+ > standard-mapping indices), and the platform-reserved keys in **`capabilities.reservedKeys`**.
615
+ > Read them before wiring input. Do not invent keys; never bind a reserved key.
616
+
617
+ **Two different "actions" — don't conflate them.** An **input action** (this section) is a named,
618
+ rebindable device binding on the input router (`jump`, `interact`). A multiplayer **declared action**
619
+ (`room.sendAction('plant')` — multiplayer-logic §14) is a client→server intent the room's rules
620
+ interpret. They compose — read the input action, send the declared action:
621
+
622
+ ```ts
623
+ if (input.wasPressed('interact')) mp.room?.sendAction('plant');
624
+ ```
625
+
626
+ ### The world-owned router *(humanoid-character ≥ 0.2.7)*
627
+
628
+ Create ONE `InputService` in the world, own the DOM attach, register the world-domain standard
629
+ actions you need, and inject it — world UI and character gameplay then share one router and one
630
+ context stack:
631
+
632
+ ```ts
633
+ import { Character, InputService, registerStandardActions } from '@helix/humanoid-character';
634
+
635
+ const input = new InputService();
636
+ input.attach(window as never); // keyboard + mouse buttons
637
+ input.attachPointer(renderer.domElement as never, undefined, { dragLook: true }); // locked look, drag/touch look, wheel
638
+ registerStandardActions(input, { only: ['pause', 'inventory', 'confirm', 'cancel', 'uiNavigate'] });
639
+
640
+ const character = await Character.create({ model, camera, domElement: renderer.domElement, input, body, config });
641
+ // Multiplayer: CharacterMultiplayer.create({ ..., input }) — the same option, forwarded to the inner character.
642
+ ```
643
+
644
+ Omit `input` and the character self-creates a private router — fine for a world with no menus and no
645
+ custom keys. Rebind ability-registered actions via config: `config: { bindings: { jump: { keys: ['KeyJ'] } } }`
646
+ (locomotion's move/sprint/walk/crouch/jump + any installed ability's actions; chassis-registered ones —
647
+ look, cameraMode, zoom, modifier, the equip cycle — take `input.rebind(id, …)` after create instead).
648
+
649
+ ### World input — bind the standard ids first; mint custom actions last
650
+
651
+ The standard set already ships world-ready ids — `interact` (E / `faceLeft` / touch button),
652
+ `primary`, `secondary` (aim), `ability1..9` + the `equipPrev`/`equipNext` pad cycle, `melee`,
653
+ `emote`, plus the menu set. **Prefer them: you never pick a key at all** — the id carries its
654
+ keyboard + gamepad + touch bindings, and player rebinding later comes for free:
655
+
656
+ ```ts
657
+ registerStandardActions(input, { only: ['interact', 'pause', 'confirm', 'cancel'] });
658
+
659
+ renderer.setAnimationLoop(() => {
660
+ const dt = Math.min(clock.getDelta(), 0.1);
661
+ character.update(dt); // pumps the router — query actions AFTER this
662
+ if (input.wasPressed('interact')) mp.room?.sendAction('plant'); // E / face-left / touch — one line
663
+ });
664
+ ```
665
+
666
+ Mint a **namespaced custom action** only for something genuinely outside the standard set. The router
667
+ throws on a duplicate action *id* but does NOT police two actions sharing a physical source in the
668
+ same context — collisions are yours to avoid, and the collision rule is about what is **REGISTERED in
669
+ this world**, not what appears in the registry:
670
+
671
+ - **Never a reserved key** (`capabilities.reservedKeys`: Escape, KeyN, backtick).
672
+ - **Never a source a REGISTERED same-context action uses.** Always live in a character world: the
673
+ locomotion/camera set — Space/WASD/Shift/KeyC/Alt/Ctrl/KeyT + faceDown/faceRight/stickL/dpadLeft,
674
+ both sticks, and dpadRight (the modifier). Plus whatever this world opts into (`interact`'s
675
+ KeyE/faceLeft, the menu set) and whatever its abilities bind (gun-control's mouse buttons +
676
+ triggers + KeyR/faceUp, the equip cycle's Digit1-9/brackets/bumpers when `equip.slots` is on).
677
+ - **Registry ids this world never registers are FREE REAL ESTATE — reuse their bindings.** A card
678
+ game has no gunplay: `reload`'s KeyR/faceUp, `primary`/`secondary`'s triggers, `melee`'s
679
+ KeyV/stickR, `emote`'s KeyG/dpadDown are all unclaimed there, and a plain button always beats a
680
+ chord or an exotic key. Genre decides: reuse when the owning action clearly can never appear in
681
+ this world; leave it alone when it plausibly might (a shooter keeps `reload`'s slots free even
682
+ before guns ship). Note the reuse in a comment at the registration site.
683
+ - **No equip slots ⇒ the bumpers and Digit1-9 are freed** — the bumpers are the natural pad home for
684
+ a world's own cycling/selection verbs (radio channels, tabs, targets), digits for direct select.
685
+ - **To reuse an ALWAYS-ON action's source, disable the owner first** via its genre gate
686
+ (`locomotion.allowCrouch: false` frees KeyC/faceRight for a world with no crouching — the same
687
+ match-controls-to-genre pass you already do). Never bind over a live action.
688
+
689
+ **Gamepad placement — in order of preference:**
690
+
691
+ ```ts
692
+ input.registerAction('world.mark', { kind: 'button', keys: ['KeyM'], pad: 'faceLeft' }, 'world'); // 1: genre-freed (no interact here)
693
+ input.registerAction('world.honk', { kind: 'button', keys: ['KeyH'], pad: 'dpadUp' }, 'world'); // 2: the always-free fallback
694
+ input.registerAction('world.taunt', { kind: 'button', keys: ['KeyG'], withModifier: { pad: 'faceUp' } }, 'world'); // 3: a modifier chord (KeyN is reserved — voice PTT)
695
+ input.registerAction('world.debugStats', { kind: 'toggle', keys: ['F9'] }, 'world'); // 4: keyboard-only (debug)
696
+ ```
697
+
698
+ 1. **A genre-freed plain button** (the rules above) — the DEFAULT choice: put the verb where hands
699
+ expect it (triggers for firing-shaped verbs, bumpers for cycling/selection, face buttons for
700
+ frequent verbs). **Paired/linked verbs share ONE symmetric group** — bumperL/bumperR,
701
+ dpadUp/dpadDown, faceLeft/faceRight, or triggerL/triggerR — never split a pair across groups
702
+ (red-team on the d-pad with blue-team on a face button is disorienting).
703
+ 2. **`pad: 'dpadUp'`** — always free in every world, but a FALLBACK, not a first pick: a lone odd
704
+ verb with no better home, or half of a dpadUp/dpadDown pair when `emote`'s slot is genre-freed.
705
+ 3. **A `withModifier` chord** — `dpadRight` is the platform's held `modifier`. While it is down,
706
+ `withModifier` bindings resolve and their sources go quiet for their plain owners, opening a
707
+ second layer. Resolution is additive per device — plain bindings stay live while the chord is
708
+ held (KeyH above still fires). **RESERVED chords — never claim:** `modifier`+right stick (the
709
+ platform camera zoom), `modifier`+bumperR (the standard `voicePTT` — push-to-talk on pad,
710
+ auto-registered when the world attaches voice), and `modifier`+bumperL (reserved for future
711
+ platform voice controls). Ergonomics: holding the modifier occupies the d-pad thumb, so chord
712
+ only occasional actions that tolerate a movement pause (a zoom does; a dodge would not).
713
+ 4. **Keyboard-only (omit `pad`)** — for testing/debug conveniences ONLY (stat overlays, free-cam,
714
+ cheats). A pad or touch player can never reach it, so anything gameplay-relevant belongs on a
715
+ plain button, a chord, or an indirect route (contextual `interact`, the equip cycle, a menu
716
+ entry) — the standard set itself works this way: `ability1..9` are keyboard digits, pad players
717
+ cycle with `equipPrev`/`equipNext`.
718
+
719
+ Don't stack chords or mint exotic keys while obvious freed buttons sit idle — reusing the standard
720
+ layout's real estate keeps pad controls where players' hands expect them.
721
+
722
+ **Installable abilities bind the standard ids too**: an `ability.json` action may declare a bare
723
+ standard character id — `{ "id": "primary" }`, no `kind`, no `defaultBinding` (the registry owns
724
+ both) — instead of minting a private `<ability>.<name>`. gun-control reads `primary`/`secondary`/
725
+ `reload` this way. Custom ability actions stay namespaced.
726
+
727
+ **Slot cycling comes built in** — enable the chassis tracker instead of writing one:
728
+ `config: { character: { equip: { slots: 4 } } }` registers `ability1..4` + `equipPrev`/`equipNext`,
729
+ tracks the active slot (wraparound + direct select), publishes blackboard `equipSlot`, and emits
730
+ `equipSlotChanged { slot, previous }` — your world does the actual equipping off that state.
731
+
732
+ A raw `addEventListener('keydown', …)` bypasses the context stack (your key still fires inside
733
+ menus), can collide with platform keys, gets no gamepad/touch support — and can never be rebound.
734
+ Rebinding is per-ACTION, not per-key: `bindings.<id>` config overrides the defaults of
735
+ ability-registered actions (see above), and a settings
736
+ menu rebinds live via `input.rebind(id, { keys: [...] })` — enumerate what's bound with
737
+ `input.actions()` (or `describeActions(input)` for registry labels/touch hints). Keyboard names are W3C
738
+ `KeyboardEvent.code` (`KeyE`, `Space`, `ShiftLeft` — physical/positional, layout-independent);
739
+ gamepad bindings use the semantic names from `capabilities.controller` (`pad: 'faceUp'`,
740
+ `stick: 'leftStick'`) — never raw indices. Kinds: `button` | `toggle` | `vec2` | `delta`; queries:
741
+ `isDown` / `wasPressed` / `wasReleased` / `isToggled` / `vec2` / `delta`.
742
+
743
+ ### Player-facing control text — `format()` tokens, never key names *(humanoid-character ≥ 0.2.7)*
744
+
745
+ Never write a physical control name ("WASD", "press E", "RT") into HUD/help text — it is wrong the
746
+ moment the player picks up a pad or rebinds. The router knows the truth; render through it and
747
+ re-render each frame — that IS the live device-swap mechanism (the text flips the frame the player
748
+ touches the other device):
749
+
750
+ ```ts
751
+ hudEl.textContent = input.format('Drive with {move} · {interact} to grab');
752
+ ```
753
+
754
+ Bare `{actionId}` tokens expand via `input.hint(id)` — the CURRENT resolved binding of that action
755
+ on the device whose activity was seen last (`input.activeDevice()`), so rebinds and config
756
+ overrides display correctly for free. Keyboard renders short key text ("E", "WASD", "Left click"),
757
+ gamepad renders Xbox-style names ("X", "RT", "Left stick"; chords as "D-pad Right + RB"). An action
758
+ unbound on the shown device renders its OTHER device's binding, and only falls back to its `label`
759
+ when no device has one. Pass a device to pin one (`input.hint('interact', 'gamepad')`); unknown ids
760
+ throw; a doubled opening brace escapes a literal one (token ids are word chars, dots, hyphens).
761
+ Give CUSTOM actions a `label` at registration so fallback text reads like a verb, not an id:
475
762
 
476
763
  ```ts
477
- try {
478
- // … loadCharacterAssets … Character.create … loadInstalledAbilities …
479
- } catch (err) {
480
- loading.fail(err instanceof Error ? err.message : String(err));
481
- throw err;
764
+ // faceRight reuses crouch's slot — this world sets locomotion.allowCrouch:false (gate-disable first!)
765
+ input.registerAction('world.discard', { kind: 'button', keys: ['KeyH'], pad: 'faceRight', label: 'Discard' }, 'world');
766
+ ```
767
+
768
+ Worlds without their own router reach the character's: `mp.local.services.input.format(...)`.
769
+ README/prose may name default keys, stated as defaults ("E by default").
770
+
771
+ ### Menus that actually pause the game — the context stack
772
+
773
+ Every action carries a `context` (default `'gameplay'`); the router resolves only the top of its
774
+ context stack. Push `'menu'` and every gameplay action reads inert (movement decelerates to a stop,
775
+ look and zoom freeze) while `confirm`/`cancel`/`uiNavigate` go live — the character never learns a
776
+ menu is open, its actions simply stop resolving:
777
+
778
+ ```ts
779
+ if (input.wasPressed('pause') || input.wasPressed('inventory')) { input.pushContext('menu'); showMenu(); }
780
+ if (input.wasPressed('confirm') || input.wasPressed('cancel')) { input.popContext('menu'); hideMenu(); }
781
+ ```
782
+
783
+ No edge leaks across the transition (the press that opened the menu never re-fires as `confirm`) and
784
+ held keys resume cleanly on pop.
785
+
786
+ **Context pushes free the mouse — that is their job.** A push that suspends `look` releases pointer lock
787
+ the way Esc would (a menu needs the cursor), and the engine can only re-acquire on a real user click. So
788
+ reach for `pushContext` ONLY for UI that wants the cursor. For gameplay states that suppress input while
789
+ the cursor stays captured — knockout, stun, ragdoll, a cutscene — do NOT push a context: locomotion is
790
+ already gated while `dead`/`ragdolled`/emote-locked, and a camera freeze is
791
+ `config.set('character.camera.allowRotate', false)` (restore on exit), which never touches the lock.
792
+
793
+ ### Reserved keys — never bind these
794
+
795
+ From `capabilities.reservedKeys`: **Escape** (the SDK's phone/tablet overlay; pointer-lock exit also
796
+ rides it), **KeyN** (the voice push-to-talk default — repoint voice via the SDK's `pttKey` option
797
+ rather than binding over it), **backtick** (the debug-overlay convention). **KeyB is no longer
798
+ reserved** — it is now the `point` standard action's binding, so it is *bound*, not free. The standard
799
+ set already avoids the reserved keys — `pause` is **P**, `cancel` is **Backspace**. Pad players get
800
+ PTT without KeyN: the `voicePTT` standard action (hold modifier + right bumper, `global` context so it
801
+ works under menus) is auto-registered by `mp.attachVoice` and forwarded to the SDK.
802
+
803
+ ### Driving actions without a keyboard (AI / NPCs / tests)
804
+
805
+ `input.setVirtualButton('jump', true)` / `setVirtualVec2('move', { x, y })` /
806
+ `setVirtualDelta('zoom', d)` write the same actions a player does — abilities and game logic cannot
807
+ tell the difference. Pass `null` to clear.
808
+
809
+ ### Default mobile controls
810
+
811
+ A current `helix init` character world already mounts `createMobileControls(input, { surface })`
812
+ and calls `mobileControls.update()` before the character tick. The generated, framework-neutral HUD
813
+ uses the same registered action metadata and virtual-input channel described above: left stick,
814
+ open-space drag look, true two-pointer pinch zoom/camera-mode transition, jump, and contextual
815
+ `button`/`hold`/`tap` actions. It activates only for touch capability, respects safe areas plus the
816
+ host-provided top offset, follows the input context stack, and clears captures/virtual state on
817
+ cancel, blur, teardown, or remount.
818
+
819
+ Customize it through constrained data, not copied DOM:
820
+
821
+ ```ts
822
+ input.registerAction('world.scan', {
823
+ kind: 'button', context: 'gameplay', label: 'Scan', touch: 'hold', keys: ['KeyF']
824
+ }, 'world');
825
+
826
+ const mobileControls = createMobileControls(input, {
827
+ surface: renderer.domElement,
828
+ actions: [
829
+ { id: 'world.scan', label: 'Scan', order: 1 },
830
+ { id: 'cameraMode', placement: 'utility' },
831
+ { id: 'crouch', visible: false },
832
+ ],
833
+ theme: { accent: '#7dd3fc' },
834
+ });
835
+ ```
836
+
837
+ Labels are inserted as plain text; layout and theme are safe tokens. Core move/look/zoom remain on
838
+ unless intentionally disabled with `core`. To replace the default entirely, provide an equivalent
839
+ custom InputService controller and declare `provider: "custom"` in `public/helix.controls.json` while
840
+ preserving the required move/look/zoom/jump capabilities. In both cases, `supportsMobile: true`
841
+ requires touch-only browser proof at portrait and landscape phone sizes.
842
+
843
+ ## 8d. Custom gestures — see the character-animation doc
844
+
845
+ Worlds can author their own character animations as JSON keyframe poses (no GLB export, no platform
846
+ publish) — a wave, a salute, a world-specific ritual — and they replicate in multiplayer by default.
847
+
848
+ Most worlds do not need this: locomotion, jumps and the standard emote set already ship with the
849
+ humanoid-character system.
850
+
851
+ **Scope first:** the pose DSL is for SHORT, SIMPLE, upper-body-led motion — player actions (wave, point,
852
+ sip), held stances (hurt, carrying), one-shot accents (nod, flinch), and additive overlays that ride
853
+ locomotion (a limp, a hunch). It is NOT for dances, fight choreography, acrobatics, or anything where the
854
+ feet must step, plant or leave the ground: a gesture is bone rotations only, with no root motion, no foot
855
+ planting and no weight shift, because the locomotion graph owns the lower body. Those need a
856
+ motion-captured clip.
857
+
858
+ If you DO need a custom gesture, read the dedicated doc before writing any
859
+ keys — joint-space posing has non-obvious failure modes (rotations chain, so the axis meanings only hold
860
+ near rest) and the doc carries the axis table, the composition rules and the measurement tooling:
861
+
862
+ ```
863
+ read_doc("character-animation")
864
+ ```
865
+
866
+ ## 8e. Sitting & lying spots — furniture players can actually use *(humanoid-character ≥ 0.2.36)*
867
+
868
+ Any world with chairs, benches, couches or beds should register **sit/lie spots** — a social hub without
869
+ them is furniture nobody can use. One call per seat; the prompt ("E — Sit"), the interact press, the seated
870
+ pose, replication to every client and remote occupancy (the prompt walks past a taken chair) are ALL
871
+ platform defaults — the world authors nothing else:
872
+
873
+ ```ts
874
+ const spots = mp.local.sitting.spots; // (multiplayer facade; solo: character.sitting.spots)
875
+ spots.register({ id: 'couch-left', seat: { x: -0.4, y: 0.45, z: 2.35 }, facingYawDeg: 180, pose: 'sit' });
876
+ spots.register({ id: 'bed', seat: { x: -3.3, y: 0.35, z: 1.4 }, facingYawDeg: -90, pose: 'lie', label: 'Lie down' });
877
+ // returns an unregister closure; optional: label, exitPoint, clearanceM
878
+ ```
879
+
880
+ **The coordinate contract — get these right and it just works:**
881
+ - `seat` sits **ON the surface** the player sits/lies on: the cushion top, the mattress top — NOT the floor,
882
+ NOT a point in the air. The engine lifts the hips the anatomical clearance itself (sit ~0.07 m,
883
+ lie ~0.10 m; `clearanceM` overrides for odd furniture). Place it where a butt (sit) or hips (lie) go —
884
+ centred on the seating surface, clear of armrests and backrests.
885
+ - `facingYawDeg` = which way the OCCUPANT faces (0 = +Z, positive toward +X). For a chair: outward, away
886
+ from the backrest. **For a bed: the body lies down BACKWARD, so the head lands OPPOSITE the facing —
887
+ face AWAY from the pillow end.**
888
+ - **Any seat height works.** Typical surfaces: chair/couch 0.42–0.5 m, bar stool 0.6–0.8 m (the feet
889
+ simply dangle), bed 0.3–0.5 m. Sub-chair heights (floor cushions ~0.1 m) will plant feet oddly — the
890
+ shipped sit pose is chair-height; prefer real furniture heights.
891
+ - Two seats on one couch: author one spot per cushion (~0.6–0.8 m apart); occupancy offers the free one.
892
+ - **`seat` goes near the furniture's FRONT face, never at a deep cushion's center.** The seated pose puts
893
+ the pelvis AT the seat point and drops the knees/shins **~0.25 m in front of it** — any furniture
894
+ occupying that space clips through the legs. Rule: place the seat point **0.15–0.2 m behind the front
895
+ face** of the cushion, measured along the facing direction. A chair (~0.5 m deep) can use its center; a
896
+ couch or bench cushion 0.7 m+ deep CANNOT — bring the point forward (e.g. an 0.8 m-deep cushion with its
897
+ front face at z=2.0, occupant facing −Z: seat z ≈ 2.2, not the 2.4 center). Depth behind the seat point
898
+ is free — the body leans back over it.
899
+ - `exitPoint` is optional — standing up defaults to just in front of the seat at the height the player
900
+ walked in at.
901
+
902
+ The seated POSE is the player's own carried sit/lie emote, falling back to the DEFAULT BUNDLED WITH THE
903
+ CHARACTER SYSTEM — every player can take a chair or a bed, guests and empty emote sets included; nothing
904
+ needs equipping and no platform config is involved. (The exception: a world that turns EMOTES off turns the
905
+ whole posture lane off with them — the facade's `emotes: false`, `character.emote.enabled: false`, or
906
+ `character.native.disabled: ['emotes']`. Refusing `'sitting'` outright removes the spots, the prompt and the
907
+ seat claim on `interact` (§8g). Do not disable either in a world that authors sitting spots.) Do NOT put furniture colliders where the
908
+ seated body goes — the posture pins the body; a mattress collider only fights it. Tunables:
909
+ `character.sitting.promptRangeM` (1 — HORIZONTAL body→seat-point distance; the seat point is the seat
910
+ CENTER ~0.25 m inside the furniture, so this offers about a stride from the furniture's edge. Height is a
911
+ separate ±1.5 m band, so a mezzanine chair never offers through a floor and a tall stool costs no reach)
912
+ and `.promptSwitchMarginM` (0.35).
913
+ **A world with its OWN interaction UI style should own the sit prompt too**: pass `sitPrompt: false` to the
914
+ facade (kills the built-in pill; the mechanic still takes the seat on `interact`) and render from
915
+ `mp.sitPrompt` / `mp.onSitPrompt((p) => ...)` — `{ kind: 'enter' | 'exit', spotId, label, distance }` or
916
+ null, fired on change, never per frame. That is how sitting joins a world's existing prompt system instead
917
+ of stacking a second pill beside it. Otherwise the prompt pill restyles or
918
+ switches off via the facade's `sitPrompt` option. Paired (two-person) emotes need NO world authoring —
919
+ they are player-carried items; the platform renders the offer and join prompts.
920
+
921
+ ## 8f. Size geometry from `world_metrics` BEFORE you place it
922
+
923
+ **Call `world_metrics({ projectDir })` the moment you start laying out a world — the first step, ledge,
924
+ gap, ramp, doorway or ceiling — not after publishing.** It reads the traversal envelope out of the
925
+ world's OWN installed character system: player capsule (height / radius / crouch), **step height** (what
926
+ auto-steps versus what is a wall), max walkable slope, movement speeds, and the derived jump envelope
927
+ (air time, max flat-ground gap at run and at sprint) plus corridor and ceiling clearances.
928
+
929
+ This is the cheapest bug in the catalogue to avoid and one of the most expensive to find: a world shipped
930
+ with a **0.42 m step against a 0.35 m limit** looked completely fine in every screenshot, passed
931
+ validation, and published — the objective was simply unreachable, and nothing flagged it because a
932
+ screenshot cannot show that a player physically cannot get somewhere. Numbers can.
933
+
934
+ The values are the system DEFAULTS the world starts from; if you override them in `config.locomotion` /
935
+ `config.character.body`, your own values win — read the manifest (§8a) and size against those instead.
936
+
937
+ Then, once geometry is placed, verify it with `inspect_world` (§10) — `world_metrics` tells you what will
938
+ fit, `inspect_world` tells you what you actually built.
939
+
940
+ ## 8f. Health, death & ragdoll — the survival chassis *(humanoid-character ≥ 0.2.25)*
941
+
942
+ Any world where the character can DIE (combat, hazards, survival) turns on the health mirror:
943
+
944
+ ```ts
945
+ character: { health: { enabled: true, maxHealth: 100 } }
946
+ ```
947
+
948
+ That registers blackboard `health` / `deathDir` (plus `dead`, which always exists), the events
949
+ `healthChanged{value,previous}` / `died` / `revived` / `respawned`, and `character.setHealth(v)`. **Two
950
+ authority modes:** single-player worlds drive `setHealth` themselves (a hazard zone, a fall); multiplayer
951
+ worlds NEVER write health client-side — declare `playerVars.health` and let server rules mutate it (the
952
+ facade mirrors the room's number onto the chassis, local and replicas alike — see the `shooter-range`
953
+ template). On death the chassis gates input, plays the directional stagger (`deathDir`), and hands the body
954
+ to the **ragdoll**:
955
+
956
+ ```ts
957
+ character: { ragdoll: { enabled: true, blendSeconds: 0.35, launchSpeed: 2.5, deathCamera: 'third-person' } }
958
+ ```
959
+
960
+ Physics takes the body at the death instant (the stagger cross-fades over `blendSeconds`; `0` = hard
961
+ switch); `deathCamera` pulls a first-person victim out to third person to watch the fall (`'none'` opts
962
+ out). All defaults are tuned — most worlds change nothing.
963
+
964
+ **Ragdoll is also a world MECHANIC**, independent of death: `character.ragdoll.enter({ launch, speedMps })`
965
+ drops the body limp (knockouts, launch pads, physics comedy), `exit()` stands it back up, `.active` reads
966
+ the state, and locomotion input is gated while limp. It replicates automatically — remotes see the body go
967
+ limp and late joiners converge — and any world can read another player's state via
968
+ `room.state.players[id].ragdolled`. `enter()` refuses while dead (death owns that body).
969
+
970
+ For guns and the full combat loop on top of this, read `read_doc({ name: "shooter-worlds" })`.
971
+
972
+ ## 8g. Native avatar features — what every world gets, and the one way to refuse it *(humanoid-character ≥ 0.2.54)*
973
+
974
+ **These are ON in every character world, with no world code, no option and no republish.** They are
975
+ constructed by `Character` itself, so they do not depend on which constructor you called: a world built
976
+ on the bare `Character` + `LocomotionAbility` gets exactly what a `CharacterMultiplayer.create` world
977
+ gets.
978
+
979
+ | id | what it is | its affordance |
980
+ |---|---|---|
981
+ | `emotes` | the player's carried emotes: tap-to-replay, the wheel's slots, and the sit/lie posture lane | `emote` — **KeyG** / dpad-down, touch **Emote** |
982
+ | `emote-wheel` | the 8-petal radial overlay (hold `emote`, steer, release), including the **Customize** door that opens the platform's emote-slots editor | the overlay only (emotes and tap-replay survive refusing it) |
983
+ | `xray` | hold-to-scan whatever is under the reticle — player, placed item or vehicle — and its detail card | `xray` — **KeyZ** (pad: modifier + left bumper), touch hold |
984
+ | `pointing` | hold to point the right index finger along the aim | `point` — **KeyB**, touch hold |
985
+ | `sitting` | sit/lie at authored spots (§8e), the proximity prompt, and the seat claim on `interact` | the *claim* on `interact` — the key itself stays, because worlds share that id |
986
+ | `avatar-camera` | the Helix OS phone presentation and the embodied avatar camera composed on it | `shutter` — **KeyF**, touch button |
987
+ | `camera-first-person` | the first-person camera leg | the FP leg of the `cameraMode` cycle (**KeyT**) |
988
+ | `camera-third-person` | the third-person camera leg | the TP leg of the `cameraMode` cycle |
989
+ | `universal-avatar` | rendering each player's equipped universal avatar as their body | — (the **top switch**, see below) |
990
+
991
+ The player's carried emotes arrive on their own: the runtime reads the loadout off the shell's platform
992
+ channel, so a world does **not** have to wire `Helix.emotes` for the wheel to work. When the wheel is
993
+ empty it says *why* — signed out, nothing equipped, outside the HELIX app, or the platform could not be
994
+ reached — rather than showing eight blank petals. The **Customize** door on the wheel appears only once
995
+ the shell has proved it can service the request (so: inside the HELIX app, signed in, on a shell that
996
+ mounts the editor) — it is genuinely absent otherwise rather than painted hopefully, which is the same
997
+ rule as the rest of this set: no affordance without its feature.
998
+
999
+ ### The creator veto — `character.native.disabled`
1000
+
1001
+ A world that does not want one of these **names it in a refusal list**, in the config you hand
1002
+ `Character.create` / `CharacterMultiplayer.create`:
1003
+
1004
+ ```ts
1005
+ config: {
1006
+ character: {
1007
+ native: { disabled: ['xray', 'pointing'] }, // everything not listed stays ON
1008
+ },
482
1009
  }
483
1010
  ```
484
1011
 
1012
+ This is **runtime config, not manifest** — it does not go in `public/helix.json`.
1013
+
1014
+ Six things to understand before you use it:
1015
+
1016
+ 1. **It is a blocklist, and never an allow-list.** The platform runtime resolves fresh at launch, so a
1017
+ world published today runs a *newer* character system tomorrow (see §9 and the delivery contract). A
1018
+ list of things you *want* would freeze every future feature out of every already-published world. A
1019
+ list of **refusals** cannot: a feature nobody has heard of yet is in nobody's list, so it arrives ON.
1020
+ That is deliberate. Refuse the features you do not want by name; do **not** exact-pin the system to
1021
+ stop receiving them — engine systems are ranges, and publish refuses an exact pin.
1022
+ 2. **`universal-avatar` is the top switch, and it is all-or-nothing.** Refusing it also refuses `emotes`,
1023
+ `emote-wheel`, `xray`, `pointing`, `sitting` and `avatar-camera`, and every key, touch button and
1024
+ prompt that advertised them. Those features are authored against the platform humanoid — its skeleton,
1025
+ its sockets, its posture clips — so they are not portable onto a world's own character rig. **The two
1026
+ camera legs survive it**: a world still needs a camera whatever body it draws. One asymmetry to know:
1027
+ on `CharacterMultiplayer` this id also stops the facade resolving equipped avatars at all, while in the
1028
+ bare-`Character` recipe your own code is what passes `avatar:` to `loadCharacterAssets` (§8) — so there,
1029
+ drop that line too, or you will keep drawing the equipped body while having refused everything that
1030
+ hangs off it.
1031
+ 3. **A refusal takes the affordance with it.** No key binding, no rebind row, no controller button, no
1032
+ touch pad, no prompt. A world that refuses `xray` must not still show a scan key that does nothing —
1033
+ the runtime enforces that for you, and the refusal stands even against a later
1034
+ `registerStandardActions`.
1035
+ 4. **Refusing both camera legs throws at create.** `['camera-first-person', 'camera-third-person']` is
1036
+ not a world. Refuse one to make the world single-mode — that also sets `character.camera.mode` and
1037
+ clears `allowModeToggle`, so the camera cycle, the zoom-through-mode switch and the death camera all
1038
+ agree — or neither to keep the cycle.
1039
+ 5. **An id this runtime does not recognise is kept and warned about once, never fatal.** It is either
1040
+ your typo (the warning names the legal ids) or a feature from a runtime newer than the one that
1041
+ happens to be resolved. A `disabled` that is not a list at all is ignored with its own warning, and
1042
+ nothing is refused — check the console if a refusal did not take.
1043
+ 6. **The older per-feature switches still work**, and route into this same resolver, so you never have to
1044
+ migrate and never get a precedence puzzle — a feature is off if *either* surface says so:
1045
+ `character.emote.enabled: false` (= `emotes`), `character.emote.wheel: false` (= `emote-wheel`),
1046
+ `character.xray.enabled: false` (= `xray`), `character.universalAvatar.enabled: false`
1047
+ (= `universal-avatar`, **and therefore its whole dependent set** — see 2). On the multiplayer facade,
1048
+ `CharacterMultiplayer.create`'s `emotes: false`, `emoteWheel: false` and `universalAvatars: false`
1049
+ options compile into exactly those config keys.
1050
+
1051
+ Not part of the veto: `localControl: false` on `CharacterMultiplayer.create`. That is not a feature
1052
+ switch — it declares a passive spectator body the world drives its own camera and input for, and such a
1053
+ body is not a native host at all (neither are replicas), so it mounts no native overlays by construction.
1054
+
1055
+ **Version — read this before you rely on any of it.** All of §8g needs `@helix/humanoid-character`
1056
+ **0.2.54** or newer. A runtime older than that ignores `character.native.disabled` silently and keeps
1057
+ whatever it already did: your refusals do not take, and the features listed above are not all there to
1058
+ refuse. So do not reason from this section about what a given world actually has — **confirm against the
1059
+ resolved system's own config schema** (`get_package_manifest` / `list_systems`): if `character.native`
1060
+ appears in its `config`, that runtime provides this surface. That schema is also the authoritative id
1061
+ list; the table above is prose about it.
1062
+
1063
+ ## 8h. NPCs — see the NPC recipe *(humanoid-character ≥ 0.3.13)*
1064
+
1065
+ A world can put **characters that are not players** in it — a shopkeeper behind a counter, a quest giver, a
1066
+ guard on a post, a zombie that hunts you — on the same chassis this recipe just configured. **An NPC is a
1067
+ `Character` whose driver is your behaviour instead of the keyboard**: same config namespaces, same locomotion
1068
+ and animation graph, same abilities, same gestures, built headless (no camera, no DOM) with an `AIDriver`
1069
+ writing virtual intent onto its own private router, so every ability resolves that intent exactly as it
1070
+ resolves yours. `NpcScene` owns the model clone, the router, the body, the nameplate and the teardown; you
1071
+ author a `behaviour` callback, and `InteractionSpots` gives any of them a "walk up, press interact" prompt.
1072
+
1073
+ **Scope first:** the platform ships the *chassis and the steering* — a seek with whisker obstacle avoidance and
1074
+ a stuck commit, optional A* routing over a `NavGrid` rasterized off your own level colliders, two body tiers
1075
+ (`stand` runs no physics at all, `physics` gets its own Rapier world mirrored from the player's), a 16-NPC
1076
+ budget, and Vault characters as bodies. It does **not** ship attacking, patrolling, aggro, factions or
1077
+ dialogue: those are game logic you write in the behaviour. And it does not ship replication — `NpcScene` NPCs
1078
+ are **client-local**, which is right for a standing NPC and wrong for a moving one (in a multiplayer world a
1079
+ moving NPC belongs to the room, rendered by `humanoidEntities`).
1080
+
1081
+ If the world needs an NPC, read the dedicated recipe before writing any of it — the failure modes are ordering
1082
+ rules with silent symptoms (read `arrived` before re-seeking or the attack never fires; re-seek only when the
1083
+ target moved or the stuck detector is disabled), and the doc carries the worked shop NPC, the hunting one, the
1084
+ nav-grid limits with real numbers, the Vault-character flow and the multiplayer handoff:
1085
+
1086
+ ```
1087
+ read_doc("npc-world")
1088
+ ```
1089
+
485
1090
  ## 9. Install, build, publish
486
1091
 
487
1092
  ```bash
@@ -490,22 +1095,72 @@ npm install
490
1095
 
491
1096
  Then resolve the pins: call the **`install_world_packages`** tool with this project directory (or run
492
1097
  `npx -y {{CLI_PKG}}@latest install` in it) — it downloads the pinned code, writes installed.json + the
493
- lock, and wires the three import map + `src/helix.runtime.ts`. Re-run it after editing pins; pass
1098
+ lock, and wires the runtime import map + `src/helix.runtime.ts`. Re-run it after editing pins; pass
494
1099
  `update: true` (or `--update`) to re-resolve ranges to newer versions. Then:
495
1100
 
496
1101
  ```bash
497
- npm run build # vite build (three external; system + addons + rapier bundle)
1102
+ npm run build # creator code/addons bundle; three, rapier and @helix systems stay external
498
1103
  ```
499
1104
 
500
1105
  Then `validate_world` on `dist/` (fix every problem — no `.glb`/`.ktx2` may ship; assets stream from
501
- the CDN), `whoami` to confirm login, and `publish_world` on `dist/` for the play link.
1106
+ the CDN), then the sub-path gate:
1107
+
1108
+ ```bash
1109
+ npx -y {{CLI_PKG}}@latest verify-subpath dist
1110
+ ```
1111
+
1112
+ **Do not skip this, and do not publish without it.** `npm run preview`, `validate_world`,
1113
+ `inspect_world` and `capture_world_screenshot` all serve `dist/` from an origin **root**. A published
1114
+ world is only ever served from a **nested path**. So every one of those gates passes a bundle whose
1115
+ root-absolute URL 404s and throws in production — the world renders a red error bar and nothing else.
1116
+ `verify-subpath` serves the same bytes from a nested path and fails on a throw, a 404, or a blank
1117
+ render. It is the only local check that sees the difference.
1118
+
1119
+ Finally `whoami` to confirm login, and `publish_world` on `dist/` for the play link.
1120
+
1121
+ ## 10. Verify placement by measurement — see the world-inspect doc
1122
+
1123
+ Do not judge object placement from screenshots — distances and depth read off an image are unreliable.
1124
+ `inspect_world` on `dist/` returns the scene as data (positions, extents, colliders) and runs placement
1125
+ checks: floating/sunk/intersecting geometry, collider-vs-visual mismatches, invisible walls.
1126
+ `focusNear: [x, y, z]` checks just the object you placed at that coordinate, and `world_metrics` gives the
1127
+ capsule/step/slope/jump numbers for sizing gaps, steps and doorways BEFORE you place geometry. Findings are
1128
+ measurements to check against your intent, never orders to change the world. Full workflow:
1129
+
1130
+ ```
1131
+ read_doc("world-inspect")
1132
+ ```
502
1133
 
503
1134
  ## The rules that matter
504
1135
 
1136
+ - **NEVER write a root-absolute URL for anything inside your own bundle.** A published world is served
1137
+ from `…/instant-worlds/<id>/<build>/`, so `'/helix_modules'`, `'/models/x.glb'`, `fetch('/data.json')`
1138
+ and friends resolve against the SITE root, 404, and throw. Always resolve against the document:
1139
+ `new URL('helix_modules/', document.baseURI).href`. Keep `base: './'` in the vite config, and prove it
1140
+ with `helix verify-subpath dist` — local preview serves from the root and cannot show you this bug.
1141
+ (Absolute `https://…` CDN URLs and the platform values `helix install` writes into
1142
+ `src/helix.runtime.ts` are a different thing and are correct.)
505
1143
  - **One three.** `three` is external everywhere (import map → hosted instance). Never bundle it.
1144
+ - **One rapier.** Same for `@dimforge/rapier3d-compat`: external in every build, import map → the hosted
1145
+ instance, never bundled. The npm devDependency exists only for types + `helix dev`.
1146
+ - **Platform systems stay external.** Keep the generated `id.startsWith('@helix/')` external rule.
1147
+ Compatible `^`/`~` system ranges receive safe native character, emote, camera and pointing updates on
1148
+ a fresh launch without republishing — **including native features that did not exist when you published**
1149
+ (§8g). Engine systems are ranges: validation and publish **refuse an exact version** (it freezes the World on
1150
+ one build). To get an engine fix, promote it rather than pinning to it. Validation and publish also refuse a
1151
+ custom config that silently bundles those systems.
506
1152
  - **Assets always stream from the CDN.** Animation clips + textures are never downloaded or shipped
507
1153
  with the world. The build contains no `.glb`/`.ktx2`. `loadCharacterAssets` fetches them at runtime.
508
1154
  - **Test with `build` + `preview`, not `dev`.** The dev server rewrites bare imports and hides whether
509
1155
  the import map is correct; preview serves the real built output the platform runs.
510
1156
  - **Pins are locked.** A rebuild uses the versions in `helix.lock.json`; `helix install --update`
511
1157
  advances them. Commit the lock so rebuilds are reproducible.
1158
+ - **Keep the top-center clear for platform chrome.** The player shell overlays a bar across the TOP-CENTER of
1159
+ every world (~top 56px: Exit / Save Progress / helixOS / fullscreen — user-hideable, shown by default). Anchor
1160
+ your HUD / on-screen UI to a CORNER (top-left/right) or the BOTTOM; a full-width or centered element goes ≥64px
1161
+ down (the templates' banners sit ~20% down). Never place UI at top-center — it renders behind the chrome.
1162
+ - **World keys are router actions, not `addEventListener`.** Register them on the shared `InputService`
1163
+ (§8c), read them with `wasPressed`/`isDown` after `character.update(dt)`, use `pushContext('menu')` for
1164
+ pause/UI, and never bind the reserved keys (Escape, KeyN, backtick — `capabilities.reservedKeys`).
1165
+ - **Control names come from the router.** Player-facing text uses `input.format('… {move} …')` tokens
1166
+ re-rendered per frame — never hardcoded key names (§8c); custom actions carry a `label`.