@helix3/helix-mcp 0.2.2-helix3.14

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.
@@ -0,0 +1,506 @@
1
+ # HELIX Instant — Character World Recipe (v0.2)
2
+
3
+ Follow this when the world has a **player character**, in **any camera framing**: first-person,
4
+ third-person, **3D top-down** (twin-stick / ARPG / MOBA), **2.5D / side-on** (platformer), an avatar
5
+ that walks / runs / jumps, or behaviors like flying, swimming, or shooting. You build on the published
6
+ **`humanoid-character` system** instead of hand-rolling a character — it gives you a physics-driven
7
+ chassis, locomotion + animation, first/third-person cameras, and an ability host. Top-down and 2.5D
8
+ are **not a different foundation** — they are camera + movement *config* on this same system
9
+ (`camera.initialPitch` / `allowRotate:false` for a fixed top-down angle, `locomotion.movementAxis` to
10
+ lock side-on movement to one axis — see §8a).
11
+
12
+ > For a bare scene with no character (a spinning object, a data viz, a menu), use the **scene**
13
+ > recipe instead: `get_started({ kind: "scene" })`.
14
+
15
+ ## 0. Discover what's published FIRST — don't assume
16
+
17
+ The catalog grows. Before you design anything, **fetch the latest** so you reuse what already exists
18
+ instead of rebuilding it:
19
+
20
+ - `list_systems` — runtime frameworks a world can build on (today: `humanoid-character`).
21
+ - `list_abilities` — installable behaviors that plug into a system (e.g. `fly`, `swim`, `gun-control`).
22
+ Filter by `system` to see only compatible ones: `list_abilities({ system: "humanoid-character" })`.
23
+ - `get_package_manifest({ slug })` — the contract for one package before you pin it. For an ability
24
+ this includes its **`configSchema`** (the tunable surface, e.g. `fly.maxSpeed`), activation, and
25
+ actions. The manifest *is* the documentation — never guess a package's options or version.
26
+
27
+ Map the user's request to abilities that already exist (a flying game → pin `fly`; underwater → `swim`;
28
+ shooting → `gun-control`) before writing any custom behavior.
29
+
30
+ ## 1. Project layout
31
+
32
+ ```
33
+ my-world/
34
+ ├── package.json
35
+ ├── vite.config.ts
36
+ ├── tsconfig.json
37
+ ├── index.html
38
+ ├── helix.lock.json ← written by `helix install`; commit it
39
+ ├── public/
40
+ │ ├── helix.json ← the v0.2 manifest (pins); lands at dist/ root on build
41
+ │ └── helix_modules/ ← written by `helix install` (code only); gitignored
42
+ └── 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)
46
+ ```
47
+
48
+ ## 2. package.json
49
+
50
+ `three` is a **devDependency**: it is external at runtime (the platform hosts the single shared
51
+ 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.
53
+
54
+ ```json
55
+ {
56
+ "name": "my-world",
57
+ "private": true,
58
+ "type": "module",
59
+ "scripts": { "dev": "vite", "build": "vite build", "preview": "vite preview", "typecheck": "tsc --noEmit" },
60
+ "dependencies": { "@dimforge/rapier3d-compat": "^0.14.0", "@hypersoniclabs/helix-sdk": "^0.1" },
61
+ "devDependencies": { "@types/three": "^0.172.0", "three": "^0.172.0", "typescript": "~5.7.3", "vite": "^6.0.0" }
62
+ }
63
+ ```
64
+
65
+ The system itself is NOT an npm dependency — `helix install` materializes it under
66
+ `public/helix_modules/` and the alias below resolves it.
67
+
68
+ ## 3. vite.config.ts
69
+
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).
72
+ `three/examples/jsm/...` is NOT externalized — Vite bundles the addons from your `node_modules` three.
73
+
74
+ ```ts
75
+ import { defineConfig } from 'vite';
76
+ import { fileURLToPath } from 'node:url';
77
+
78
+ export default defineConfig({
79
+ base: './',
80
+ resolve: {
81
+ alias: {
82
+ '@helix/humanoid-character': fileURLToPath(new URL('./public/helix_modules/humanoid-character/index.js', import.meta.url)),
83
+ },
84
+ },
85
+ build: { target: 'es2022', rollupOptions: { external: ['three'] } },
86
+ });
87
+ ```
88
+
89
+ ## 4. tsconfig.json
90
+
91
+ Point the type path at the embedded system's declarations (installed alongside the code).
92
+
93
+ ```json
94
+ {
95
+ "compilerOptions": {
96
+ "target": "ES2022", "module": "ESNext", "moduleResolution": "bundler",
97
+ "lib": ["ES2022", "DOM"], "strict": true, "noEmit": true, "skipLibCheck": true,
98
+ "paths": { "@helix/humanoid-character": ["./public/helix_modules/humanoid-character/index.d.ts"] }
99
+ },
100
+ "include": ["src"]
101
+ }
102
+ ```
103
+
104
+ ## 5. index.html
105
+
106
+ 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.
110
+
111
+ ```html
112
+ <!doctype html>
113
+ <html lang="en">
114
+ <head>
115
+ <meta charset="utf-8" />
116
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
117
+ <title>My World</title>
118
+ <!-- helix:three:start -->
119
+ <script type="importmap">
120
+ { "imports": { "three": "" } }
121
+ </script>
122
+ <!-- helix:three:end -->
123
+ <style>
124
+ html, body { margin: 0; height: 100%; overflow: hidden; background: #14161a; }
125
+ /* Loading overlay — painted on HTML parse (before any JS downloads → no flash). The bar starts as a
126
+ CSS marquee (no JS); src/loading.ts switches it to a measured fill, then fades out. Restyle freely. */
127
+ #loading { position: fixed; inset: 0; z-index: 100; display: flex; align-items: center; justify-content: center;
128
+ background: #14161a; color: #cdd6e0; font: 13px/1.5 ui-monospace, monospace; opacity: 1; transition: opacity 0.4s ease; }
129
+ #loading.done { opacity: 0; pointer-events: none; }
130
+ #loading .card { width: min(360px, 76vw); text-align: center; }
131
+ #loading .eyebrow { font-size: 11px; letter-spacing: 0.18em; text-transform: uppercase; color: #6cc7ff; opacity: 0.75; }
132
+ #loading .title { margin: 6px 0 18px; font-size: 16px; font-weight: 600; color: #e6edf5; }
133
+ #loading .bar { height: 6px; border-radius: 3px; background: #232830; overflow: hidden; }
134
+ #loading #bar { width: 30%; height: 100%; border-radius: 3px; background: #6cc7ff; animation: helix-marquee 1.1s ease-in-out infinite; }
135
+ #loading.determinate #bar { animation: none; transition: width 0.25s ease; }
136
+ #loading.working #bar { animation: helix-pulse 1s ease-in-out infinite; }
137
+ #loading.error #bar { animation: none; width: 100% !important; background: #e5675e; }
138
+ #loading-text { margin-top: 12px; color: #9aa7b4; }
139
+ @keyframes helix-marquee { 0% { transform: translateX(-110%); } 100% { transform: translateX(370%); } }
140
+ @keyframes helix-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
141
+ </style>
142
+ </head>
143
+ <body>
144
+ <div id="loading">
145
+ <div class="card">
146
+ <div class="eyebrow">HELIX Instant</div>
147
+ <div class="title">My World</div>
148
+ <div class="bar"><div id="bar"></div></div>
149
+ <div id="loading-text">Starting…</div>
150
+ </div>
151
+ </div>
152
+ <div id="hud"></div>
153
+ <script type="module" src="/src/main.ts"></script>
154
+ </body>
155
+ </html>
156
+ ```
157
+
158
+ ## 6. public/helix.json — pin what you discovered in step 0
159
+
160
+ `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`
164
+ resolves each range to an exact version and bakes it into `helix.lock.json`.
165
+
166
+ ```json
167
+ {
168
+ "helixVersion": "0.2",
169
+ "title": "My World",
170
+ "slug": "my-world",
171
+ "entry": "index.html",
172
+ "maxPlayers": 1,
173
+ "permissions": ["auth.profile"],
174
+ "supportsMobile": true,
175
+ "contentRating": "everyone",
176
+ "systems": { "humanoid-character": "^0.2" },
177
+ "abilities": { "fly": "^0.2", "swim": "^0.2" }
178
+ }
179
+ ```
180
+
181
+ ## 7. src/helix.runtime.ts — stub (helix install overwrites it)
182
+
183
+ ```ts
184
+ // GENERATED by `helix install` — placeholders until you run it; do not hand-edit.
185
+ export const THREE_VERSION = '';
186
+ export const THREE_MODULE_URL = '';
187
+ export const TRANSCODER_PATH = '/runtime/basis/';
188
+ export const SYSTEM_ASSET_BASE = '';
189
+ ```
190
+
191
+ ## 8. src/main.ts — the flow
192
+
193
+ `loadCharacterAssets` streams the body/face LOD + locomotion clips from the CDN (never bundled);
194
+ `Character.create` drives the chassis; `LocomotionAbility` is the built-in movement; everything you
195
+ pinned loads via `loadInstalledAbilities`. With the `avatar` option, a logged-in player's equipped
196
+ **universal avatar** becomes their body automatically — skeleton-gated and fault-tolerant (guests, no
197
+ 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`).
199
+
200
+ ```ts
201
+ import * as THREE from 'three';
202
+ import { Helix } from '@hypersoniclabs/helix-sdk';
203
+ import { Character, loadCharacterAssets, loadInstalledAbilities, LocomotionAbility, RapierBody } from '@helix/humanoid-character';
204
+ import { SYSTEM_ASSET_BASE, TRANSCODER_PATH } from './helix.runtime';
205
+ import { createLoadingScreen } from './loading'; // loading screen — hooks DefaultLoadingManager (§8b)
206
+
207
+ const scene = new THREE.Scene();
208
+ const camera = new THREE.PerspectiveCamera(60, innerWidth / innerHeight, 0.05, 200);
209
+ const renderer = new THREE.WebGLRenderer({ antialias: true });
210
+ renderer.setSize(innerWidth, innerHeight);
211
+ document.body.appendChild(renderer.domElement);
212
+ scene.add(new THREE.HemisphereLight(0xbfd4ff, 0x40382a, 0.9));
213
+
214
+ // Mount the loading screen before any loader runs — it hooks THREE.DefaultLoadingManager so every asset
215
+ // load below (character, abilities, any extra GLB you add) counts on the bar automatically. See §8b.
216
+ const loading = createLoadingScreen();
217
+
218
+ // 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
221
+
222
+ const { model, clips } = await loadCharacterAssets(SYSTEM_ASSET_BASE, {
223
+ renderer, transcoderPath: TRANSCODER_PATH,
224
+ avatar: equipped?.glbUrl ? { url: equipped.glbUrl, skeleton: equipped.skeleton } : undefined,
225
+ });
226
+ scene.add(model);
227
+
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…');
231
+
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
234
+
235
+ const character = await Character.create({
236
+ model, camera, domElement: renderer.domElement, body,
237
+ // FIRST-PERSON: set the initial camera mode. Omit for the third-person default.
238
+ config: { character: { camera: { mode: 'first-person' } } },
239
+ });
240
+ character.abilities.register(new LocomotionAbility(clips));
241
+ await loadInstalledAbilities('/helix_modules', character); // fly, swim, … from helix.json
242
+
243
+ // First-person mouse-look needs pointer lock — request it from a click (browser gesture rule).
244
+ renderer.domElement.addEventListener('click', () => character.services.input.requestLook());
245
+
246
+ const clock = new THREE.Clock();
247
+ let firstFrame = true;
248
+ renderer.setAnimationLoop(() => {
249
+ character.update(Math.min(clock.getDelta(), 0.1));
250
+ renderer.render(scene, camera);
251
+ if (firstFrame) { firstFrame = false; loading.dismiss(); } // world on screen → fade the overlay
252
+ });
253
+ ```
254
+
255
+ ## 8a. Configure the character — read the knobs from the manifest, NEVER guess
256
+
257
+ **This is the step agents get wrong.** Speed, camera, spawn, slope, jump-feel — almost everything a
258
+ game designer reaches for is already a **config value**, not a method on the TypeScript class. If you
259
+ inspect the `Character` / `LocomotionAbility` class API and conclude "there's no setting for X", you
260
+ looked in the wrong place: the tunable surface is the **published config**, and the authoritative list
261
+ is in the manifest.
262
+
263
+ > **Rule:** `get_package_manifest("humanoid-character")` → its **`config`** is the complete tunable
264
+ > surface (every key with default, range, and description: `character.spawn.*`, `character.camera.*`,
265
+ > `character.body.*`, `character.lod.*`, `locomotion.*`). Its **`capabilities`** lists input actions,
266
+ > blackboard keys, the runtime **`api`** entry points, and the **`events`**. Each ability adds its own
267
+ > `configSchema` (read its `get_package_manifest`). **Read it before you configure. Do not invent keys.**
268
+
269
+ There are exactly two ways to set any config value, and one runtime API surface:
270
+
271
+ **1. Initial — `Character.create({ config })`.** Nested under the namespace; merges over defaults.
272
+
273
+ ```ts
274
+ const character = await Character.create({
275
+ model, camera, domElement: renderer.domElement, body,
276
+ config: {
277
+ 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 },
280
+ },
281
+ locomotion: { runSpeed: 7, coyoteTimeMs: 120 },
282
+ // ability namespaces too, once pinned & installed: fly: { maxSpeed: 14 }
283
+ },
284
+ });
285
+ ```
286
+
287
+ **2. Runtime — `character.config.set('ns.key', value)`.** Read live every tick; the change applies
288
+ immediately. This is how you do timed buffs, difficulty changes, "look at the boss", etc.
289
+
290
+ ```ts
291
+ // Timed speed boost (a pickup): boost, then restore after 4 s — no engine change, pure config.
292
+ const base = 5.5; // or read it: character.config.get('locomotion.runSpeed')
293
+ character.config.set('locomotion.runSpeed', 11);
294
+ setTimeout(() => character.config.set('locomotion.runSpeed', base), 4000);
295
+
296
+ // Re-aim the camera (respawn / cutscene / face a target). yaw = boom azimuth, pitch = view (up +).
297
+ // initialYaw/initialPitch are construct-time SEEDS — to re-aim a LIVE camera, call these, not config.set.
298
+ character.camera?.setYaw(180);
299
+ character.camera?.setPitch(-10);
300
+
301
+ // Live difficulty / movement-lock changes (these keys re-apply on set):
302
+ character.config.set('character.camera.allowRotate', false);
303
+ character.config.set('character.camera.tp.distance', 15);
304
+ character.config.set('locomotion.movementAxis', 'x'); // restrict planar movement to one axis (2.5D)
305
+ ```
306
+
307
+ **Top-down / 2.5D recipe.** Set the fixed framing at create time (`initialPitch` is a seed); `allowRotate`,
308
+ `tp.distance` and `movementAxis` can still be flipped live afterward.
309
+
310
+ ```ts
311
+ // 3D top-down (twin-stick): camera looks straight down, no orbit, body faces the movement direction.
312
+ config: { character: { camera: { mode: 'third-person', allowRotate: false, initialPitch: -80, tp: { distance: 15 } } },
313
+ locomotion: { facingMode: 'movement' } }
314
+
315
+ // 2.5D side-on platformer: lock planar movement to the X axis (camera framing as you like).
316
+ config: { locomotion: { movementAxis: 'x' } }
317
+ ```
318
+
319
+ **Runtime APIs (from `capabilities.api`)** — for things config can't express:
320
+
321
+ ```ts
322
+ character.services.body.teleport({ x, y, z }); // move now (runtime equivalent of spawn)
323
+ character.services.body.applyImpulse({ x: 0, y: 8, z: 0 }); // jump pad / knockback / explosion
324
+ character.respawn(/* optional checkpoint */); // teleport + zero velocity + stand up + reface
325
+ character.setEnabled(false); // hard pause (halts gravity/abilities/anim) — cutscenes
326
+ character.camera?.addTrauma(0.6); // camera shake for a hit/landing (0..1)
327
+ ```
328
+
329
+ **Events (subscribe via `character.events.on(name, cb)`):** `landed` (`{ impactSpeed, fallDistance,
330
+ speed }` — `impactSpeed` is vertical, use it for fall damage), `fell`, `respawned`, `jumped`,
331
+ `stateEntered`. The full list + payloads are in `capabilities.events`.
332
+
333
+ ### Lock the controls to the genre — disable what doesn't fit (don't ship every control everywhere)
334
+
335
+ Every `allow*` gate **defaults to `true`** (all controls enabled). So you must **opt OUT** of the ones
336
+ that don't belong, or the player keeps a control the game shouldn't have. The classic bug: a
337
+ first-person game where pressing **T** still flips to third-person, because `allowModeToggle` was left
338
+ on. **Match the gates to the genre when you configure the character — don't leave it to chance.**
339
+
340
+ The gates (all `boolean`, default `true` — set `false` to remove the control; confirm names/defaults in
341
+ the manifest `config`):
342
+
343
+ | gate | removes |
344
+ |---|---|
345
+ | `character.camera.allowModeToggle` | first/third-person switch (the **T** action) |
346
+ | `character.camera.allowRotate` | orbit / mouse-look (fixed camera angle) |
347
+ | `character.camera.allowZoom` | scroll-wheel zoom |
348
+ | `character.camera.allowShoulderSwap` | over-shoulder side swap (the **V** action) |
349
+ | `locomotion.allowJump` / `allowCrouch` / `allowSprint` | the jump / crouch / sprint actions |
350
+
351
+ Genre presets (set these at create time, then add the rest of your tuning):
352
+
353
+ ```ts
354
+ // First-person ONLY — keep the player in FP; T must NOT switch to third-person.
355
+ config: { character: { camera: { mode: 'first-person', allowModeToggle: false } } }
356
+
357
+ // Third-person ONLY — lock to TP; no FP toggle, no shoulder-swap clutter.
358
+ config: { character: { camera: { mode: 'third-person', allowModeToggle: false, allowShoulderSwap: false } } }
359
+
360
+ // Top-down — fixed framing: no mode toggle, no orbit, no zoom (combine with the top-down recipe above).
361
+ config: { character: { camera: { mode: 'third-person', allowModeToggle: false, allowRotate: false, allowZoom: false } } }
362
+
363
+ // No-jump (walking sim / puzzle / point-to-move).
364
+ config: { locomotion: { allowJump: false } }
365
+ ```
366
+
367
+ You can also flip these live (e.g. disable controls during a cutscene): `character.config.set('character.camera.allowModeToggle', false)`.
368
+
369
+ ### First-person vs third-person (a slice of the camera config)
370
+
371
+ - Initial mode: `config.character.camera.mode` = `'first-person'` or `'third-person'` (default).
372
+ - Toggle at runtime: `character.services.camera?.setMode('first-person')` (or `.toggleMode()`).
373
+ - Tune via config like everything else: `character.camera.fp.fov`, `character.camera.tp.distance`,
374
+ `character.camera.lookSensitivity`, `character.camera.minPitchDeg` / `maxPitchDeg`, … — see the manifest.
375
+
376
+ ## 8b. Loading screen — `src/loading.ts` (count-based progress, extensible)
377
+
378
+ 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.
384
+
385
+ ```ts
386
+ import * as THREE from 'three';
387
+
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;
405
+ /** One-shot fade + remove. Call AFTER the first renderer.render(), never on manager.onLoad (it fires early). */
406
+ dismiss(): void;
407
+ /** Switch to the error state (red bar + message). */
408
+ fail(message: string): void;
409
+ }
410
+
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
+ }
459
+ ```
460
+
461
+ **Add your own assets to the bar.** An extra three load is counted automatically — just load it on the
462
+ default manager (`new GLTFLoader().loadAsync(...)`). For a NON-three asset (audio, a raw fetch), wrap it
463
+ in `loading.task()`:
464
+
465
+ ```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 });
470
+ audio.load();
471
+ ```
472
+
473
+ **Errors.** To surface a load failure on the overlay instead of a frozen bar, guard the awaited setup and
474
+ call `loading.fail(...)`:
475
+
476
+ ```ts
477
+ try {
478
+ // … loadCharacterAssets … Character.create … loadInstalledAbilities …
479
+ } catch (err) {
480
+ loading.fail(err instanceof Error ? err.message : String(err));
481
+ throw err;
482
+ }
483
+ ```
484
+
485
+ ## 9. Install, build, publish
486
+
487
+ ```bash
488
+ npm install
489
+ helix install # resolve the pins → download code, write installed.json + lock, wire the
490
+ # three import map + src/helix.runtime.ts (re-run after editing pins;
491
+ # `helix install --update` re-resolves ranges to newer versions)
492
+ npm run build # vite build (three external; system + addons + rapier bundle)
493
+ ```
494
+
495
+ Then `validate_world` on `dist/` (fix every problem — no `.glb`/`.ktx2` may ship; assets stream from
496
+ the CDN), `whoami` to confirm login, and `publish_world` on `dist/` for the play link.
497
+
498
+ ## The rules that matter
499
+
500
+ - **One three.** `three` is external everywhere (import map → hosted instance). Never bundle it.
501
+ - **Assets always stream from the CDN.** Animation clips + textures are never downloaded or shipped
502
+ with the world. The build contains no `.glb`/`.ktx2`. `loadCharacterAssets` fetches them at runtime.
503
+ - **Test with `build` + `preview`, not `dev`.** The dev server rewrites bare imports and hides whether
504
+ the import map is correct; preview serves the real built output the platform runs.
505
+ - **Pins are locked.** A rebuild uses the versions in `helix.lock.json`; `helix install --update`
506
+ advances them. Commit the lock so rebuilds are reproducible.
@@ -0,0 +1,51 @@
1
+ # helix.json — Manifest Reference (v0.1 / v0.2 / v0.3)
2
+
3
+ Declares a world's identity, entry point, and the platform capabilities it is allowed to use. Lives at the bundle root as helix.json.
4
+
5
+ `helixVersion` selects the schema: **"0.1"** (base), **"0.2"** (adds `systems`/`abilities` catalog pins), **"0.3"** (adds the declarative `multiplayer` block + more permissions). Pick the LOWEST version that has what you need — most single-player scene worlds are 0.1, character worlds are 0.2, multiplayer worlds are 0.3.
6
+
7
+ Required fields (all versions): `helixVersion`, `title`, `slug`, `entry`. Unknown fields are REJECTED.
8
+
9
+ ## Base fields (every version)
10
+
11
+ | Field | Type / allowed values | Default | Description |
12
+ | --- | --- | --- | --- |
13
+ | `helixVersion` | `"0.1"` / `"0.2"` / `"0.3"` | — (required) | Manifest schema version. Determines which fields and permissions are available. |
14
+ | `title` | `string` (1–80 chars) | — (required) | Display name shown on the world page and in listings. |
15
+ | `slug` | `string` matching `^[a-z0-9](?:[a-z0-9-]{1,48})[a-z0-9]$` | — (required) | URL identifier (3-50 chars: lowercase letters, digits, hyphens; no leading/trailing hyphen). Globally unique; the world's play URL is /g/{slug}. |
16
+ | `entry` | `string` matching `^(?!/)(?!.*\.\.)[A-Za-z0-9._/-]+\.html$` | — (required) | Bundle-relative path to the HTML entry point (e.g. index.html). Must not be absolute or contain '..'. |
17
+ | `maxPlayers` | `integer` (1–100; platform room cap 24, or 12 at `multiplayer.uploadHz: 20`) | `1` | Player slots per room. 1 = single-player. Any value > 1 REQUIRES the `multiplayer` permission (v0.3). |
18
+ | `permissions` | array (see per-version list below) | `[]` | Platform capabilities the world requests, OAuth-scope style. Worlds get nothing by default. |
19
+ | `supportsMobile` | `boolean` | `false` | Whether the world is playable on mobile browsers. Surfaces as a compatibility tag. |
20
+ | `requiresAuth` | `boolean` | `false` | When true, players must log in before playing — the shell's login overlay offers no guest option, and the world page shows an 'Account required' badge. Leave false (default) for instant guest play. NOTE: the `multiplayer` permission forces this to true (login is mandatory for multiplayer). |
21
+ | `contentRating` | `"unrated"` / `"everyone"` / `"teen"` / `"mature"` | `"unrated"` | Self-declared content rating. Curated review may override. 'unrated' worlds may be restricted from public listing. |
22
+
23
+ ## Permissions per version
24
+
25
+ - **v0.1 / v0.2:** `auth.profile` (read the player's id, username, display name).
26
+ - **v0.3:** `auth.profile`, `multiplayer` (join the world's shared room), `voice.room` / `voice.proximity` (reserved), `camera.capture` (save a photo taken with the in-engine world camera to the player's gallery).
27
+ - Cross-field rules the validator enforces: `maxPlayers > 1` ⇒ `multiplayer` permission; `voice.*` ⇒ `multiplayer` permission; the `multiplayer` permission ⇒ `requiresAuth: true`; a `multiplayer` config block (state/entities/rules) ⇒ the `multiplayer` permission.
28
+
29
+ ## v0.2+ — `systems` and `abilities` (catalog pins)
30
+
31
+ Maps of `slug -> semver range`, resolved against the platform catalog by `helix install` (which locks exact versions into `helix.lock.json` and copies code into `public/helix_modules/`):
32
+
33
+ ```json
34
+ "systems": { "humanoid-character": "^0.2" },
35
+ "abilities": { "fly": "^0.2", "swim": "^0.2" }
36
+ ```
37
+
38
+ Keep system and ability pins on the SAME minor — an ability bundle pins the system minor it was built for, so mixing e.g. system `^0.2` with an ability `^0.1` makes the ability refuse to load at runtime. Full walkthrough: the `character-world` recipe (`get_started { kind: "character" }`).
39
+
40
+ ## v0.3 — the `multiplayer` block
41
+
42
+ The declarative multiplayer config (synced state, server-side `when/if/then` rules, entities, zones, timers, phases, `uploadHz`). It is validated at publish and interpreted by the platform's generic room — no server code ships with the world. The grammar is large; author it from the `multiplayer-world` recipe (`get_started { kind: "multiplayer" }`) and the `multiplayer-logic` reference (`read_doc { name: "multiplayer-logic" }`), never from memory.
43
+
44
+ ## Bundle rules (enforced at validate AND publish)
45
+
46
+ - Max 200 files, 25 MB per file, 50 MB total, no empty files
47
+ - Paths are bundle-relative: no leading `/`, no `..`
48
+ - Allowed file extensions: html, js, mjs, css, json, map, wasm, png, jpg, jpeg, gif, webp, svg, ico, ktx2, glb, gltf, bin, mp3, ogg, wav, woff, woff2, ttf, txt, md
49
+ - `entry` must exist in the bundle and end in `.html`
50
+ - The manifest file itself must be at the bundle root as `helix.json`
51
+ - v0.2/v0.3: HELIX assets must stream from the CDN — a clip/texture under `helix_modules/` is rejected (only installed CODE lives there)