@hypersoniclabs/helix-mcp 0.2.1

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,493 @@
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" },
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.1` unless `get_package_manifest` shows a newer line). `helix install`
162
+ resolves each range to an exact version and bakes it into `helix.lock.json`.
163
+
164
+ ```json
165
+ {
166
+ "helixVersion": "0.2",
167
+ "title": "My World",
168
+ "slug": "my-world",
169
+ "entry": "index.html",
170
+ "maxPlayers": 1,
171
+ "permissions": ["auth.profile"],
172
+ "supportsMobile": true,
173
+ "contentRating": "everyone",
174
+ "systems": { "humanoid-character": "^0.1" },
175
+ "abilities": { "fly": "^0.1", "swim": "^0.1" }
176
+ }
177
+ ```
178
+
179
+ ## 7. src/helix.runtime.ts — stub (helix install overwrites it)
180
+
181
+ ```ts
182
+ // GENERATED by `helix install` — placeholders until you run it; do not hand-edit.
183
+ export const THREE_VERSION = '';
184
+ export const THREE_MODULE_URL = '';
185
+ export const TRANSCODER_PATH = '/runtime/basis/';
186
+ export const SYSTEM_ASSET_BASE = '';
187
+ ```
188
+
189
+ ## 8. src/main.ts — the flow
190
+
191
+ `loadCharacterAssets` streams the body/face LOD + locomotion clips from the CDN (never bundled);
192
+ `Character.create` drives the chassis; `LocomotionAbility` is the built-in movement; everything you
193
+ pinned loads via `loadInstalledAbilities`.
194
+
195
+ ```ts
196
+ import * as THREE from 'three';
197
+ import { Character, loadCharacterAssets, loadInstalledAbilities, LocomotionAbility, RapierBody } from '@helix/humanoid-character';
198
+ import { SYSTEM_ASSET_BASE, TRANSCODER_PATH } from './helix.runtime';
199
+ import { createLoadingScreen } from './loading'; // loading screen — hooks DefaultLoadingManager (§8b)
200
+
201
+ const scene = new THREE.Scene();
202
+ const camera = new THREE.PerspectiveCamera(60, innerWidth / innerHeight, 0.05, 200);
203
+ const renderer = new THREE.WebGLRenderer({ antialias: true });
204
+ renderer.setSize(innerWidth, innerHeight);
205
+ document.body.appendChild(renderer.domElement);
206
+ scene.add(new THREE.HemisphereLight(0xbfd4ff, 0x40382a, 0.9));
207
+
208
+ // Mount the loading screen before any loader runs — it hooks THREE.DefaultLoadingManager so every asset
209
+ // load below (character, abilities, any extra GLB you add) counts on the bar automatically. See §8b.
210
+ const loading = createLoadingScreen();
211
+
212
+ const { model, clips } = await loadCharacterAssets(SYSTEM_ASSET_BASE, { renderer, transcoderPath: TRANSCODER_PATH });
213
+ scene.add(model);
214
+
215
+ // Assets are in (bar ~90%); the rest is un-metered (Rapier WASM compile, character setup) → show the
216
+ // indeterminate "Starting…" tail so the bar never looks frozen, then dismiss on the first frame.
217
+ loading.working('Starting…');
218
+
219
+ const body = await RapierBody.create({ position: { x: 0, y: 0.2, z: 0 } });
220
+ body.addStaticCuboid({ x: 24, y: 0.5, z: 24 }, { x: 0, y: -0.5, z: 0 }); // floor
221
+
222
+ const character = await Character.create({
223
+ model, camera, domElement: renderer.domElement, body,
224
+ // FIRST-PERSON: set the initial camera mode. Omit for the third-person default.
225
+ config: { character: { camera: { mode: 'first-person' } } },
226
+ });
227
+ character.abilities.register(new LocomotionAbility(clips));
228
+ await loadInstalledAbilities('/helix_modules', character); // fly, swim, … from helix.json
229
+
230
+ // First-person mouse-look needs pointer lock — request it from a click (browser gesture rule).
231
+ renderer.domElement.addEventListener('click', () => character.services.input.requestLook());
232
+
233
+ const clock = new THREE.Clock();
234
+ let firstFrame = true;
235
+ renderer.setAnimationLoop(() => {
236
+ character.update(Math.min(clock.getDelta(), 0.1));
237
+ renderer.render(scene, camera);
238
+ if (firstFrame) { firstFrame = false; loading.dismiss(); } // world on screen → fade the overlay
239
+ });
240
+ ```
241
+
242
+ ## 8a. Configure the character — read the knobs from the manifest, NEVER guess
243
+
244
+ **This is the step agents get wrong.** Speed, camera, spawn, slope, jump-feel — almost everything a
245
+ game designer reaches for is already a **config value**, not a method on the TypeScript class. If you
246
+ inspect the `Character` / `LocomotionAbility` class API and conclude "there's no setting for X", you
247
+ looked in the wrong place: the tunable surface is the **published config**, and the authoritative list
248
+ is in the manifest.
249
+
250
+ > **Rule:** `get_package_manifest("humanoid-character")` → its **`config`** is the complete tunable
251
+ > surface (every key with default, range, and description: `character.spawn.*`, `character.camera.*`,
252
+ > `character.body.*`, `character.lod.*`, `locomotion.*`). Its **`capabilities`** lists input actions,
253
+ > blackboard keys, the runtime **`api`** entry points, and the **`events`**. Each ability adds its own
254
+ > `configSchema` (read its `get_package_manifest`). **Read it before you configure. Do not invent keys.**
255
+
256
+ There are exactly two ways to set any config value, and one runtime API surface:
257
+
258
+ **1. Initial — `Character.create({ config })`.** Nested under the namespace; merges over defaults.
259
+
260
+ ```ts
261
+ const character = await Character.create({
262
+ model, camera, domElement: renderer.domElement, body,
263
+ config: {
264
+ character: {
265
+ spawn: { x: 4, z: -2, facingDeg: 90 }, // where the player starts + which way they look (0 = +Z)
266
+ camera: { mode: 'third-person', initialYaw: 90, lookSensitivity: 1.4, fov: 70 },
267
+ },
268
+ locomotion: { runSpeed: 7, coyoteTimeMs: 120 },
269
+ // ability namespaces too, once pinned & installed: fly: { maxSpeed: 14 }
270
+ },
271
+ });
272
+ ```
273
+
274
+ **2. Runtime — `character.config.set('ns.key', value)`.** Read live every tick; the change applies
275
+ immediately. This is how you do timed buffs, difficulty changes, "look at the boss", etc.
276
+
277
+ ```ts
278
+ // Timed speed boost (a pickup): boost, then restore after 4 s — no engine change, pure config.
279
+ const base = 5.5; // or read it: character.config.get('locomotion.runSpeed')
280
+ character.config.set('locomotion.runSpeed', 11);
281
+ setTimeout(() => character.config.set('locomotion.runSpeed', base), 4000);
282
+
283
+ // Re-aim the camera (respawn / cutscene / face a target). yaw = boom azimuth, pitch = view (up +).
284
+ // initialYaw/initialPitch are construct-time SEEDS — to re-aim a LIVE camera, call these, not config.set.
285
+ character.camera?.setYaw(180);
286
+ character.camera?.setPitch(-10);
287
+
288
+ // Live difficulty / movement-lock changes (these keys re-apply on set):
289
+ character.config.set('character.camera.allowRotate', false);
290
+ character.config.set('character.camera.tp.distance', 15);
291
+ character.config.set('locomotion.movementAxis', 'x'); // restrict planar movement to one axis (2.5D)
292
+ ```
293
+
294
+ **Top-down / 2.5D recipe.** Set the fixed framing at create time (`initialPitch` is a seed); `allowRotate`,
295
+ `tp.distance` and `movementAxis` can still be flipped live afterward.
296
+
297
+ ```ts
298
+ // 3D top-down (twin-stick): camera looks straight down, no orbit, body faces the movement direction.
299
+ config: { character: { camera: { mode: 'third-person', allowRotate: false, initialPitch: -80, tp: { distance: 15 } } },
300
+ locomotion: { facingMode: 'movement' } }
301
+
302
+ // 2.5D side-on platformer: lock planar movement to the X axis (camera framing as you like).
303
+ config: { locomotion: { movementAxis: 'x' } }
304
+ ```
305
+
306
+ **Runtime APIs (from `capabilities.api`)** — for things config can't express:
307
+
308
+ ```ts
309
+ character.services.body.teleport({ x, y, z }); // move now (runtime equivalent of spawn)
310
+ character.services.body.applyImpulse({ x: 0, y: 8, z: 0 }); // jump pad / knockback / explosion
311
+ character.respawn(/* optional checkpoint */); // teleport + zero velocity + stand up + reface
312
+ character.setEnabled(false); // hard pause (halts gravity/abilities/anim) — cutscenes
313
+ character.camera?.addTrauma(0.6); // camera shake for a hit/landing (0..1)
314
+ ```
315
+
316
+ **Events (subscribe via `character.events.on(name, cb)`):** `landed` (`{ impactSpeed, fallDistance,
317
+ speed }` — `impactSpeed` is vertical, use it for fall damage), `fell`, `respawned`, `jumped`,
318
+ `stateEntered`. The full list + payloads are in `capabilities.events`.
319
+
320
+ ### Lock the controls to the genre — disable what doesn't fit (don't ship every control everywhere)
321
+
322
+ Every `allow*` gate **defaults to `true`** (all controls enabled). So you must **opt OUT** of the ones
323
+ that don't belong, or the player keeps a control the game shouldn't have. The classic bug: a
324
+ first-person game where pressing **T** still flips to third-person, because `allowModeToggle` was left
325
+ on. **Match the gates to the genre when you configure the character — don't leave it to chance.**
326
+
327
+ The gates (all `boolean`, default `true` — set `false` to remove the control; confirm names/defaults in
328
+ the manifest `config`):
329
+
330
+ | gate | removes |
331
+ |---|---|
332
+ | `character.camera.allowModeToggle` | first/third-person switch (the **T** action) |
333
+ | `character.camera.allowRotate` | orbit / mouse-look (fixed camera angle) |
334
+ | `character.camera.allowZoom` | scroll-wheel zoom |
335
+ | `character.camera.allowShoulderSwap` | over-shoulder side swap (the **V** action) |
336
+ | `locomotion.allowJump` / `allowCrouch` / `allowSprint` | the jump / crouch / sprint actions |
337
+
338
+ Genre presets (set these at create time, then add the rest of your tuning):
339
+
340
+ ```ts
341
+ // First-person ONLY — keep the player in FP; T must NOT switch to third-person.
342
+ config: { character: { camera: { mode: 'first-person', allowModeToggle: false } } }
343
+
344
+ // Third-person ONLY — lock to TP; no FP toggle, no shoulder-swap clutter.
345
+ config: { character: { camera: { mode: 'third-person', allowModeToggle: false, allowShoulderSwap: false } } }
346
+
347
+ // Top-down — fixed framing: no mode toggle, no orbit, no zoom (combine with the top-down recipe above).
348
+ config: { character: { camera: { mode: 'third-person', allowModeToggle: false, allowRotate: false, allowZoom: false } } }
349
+
350
+ // No-jump (walking sim / puzzle / point-to-move).
351
+ config: { locomotion: { allowJump: false } }
352
+ ```
353
+
354
+ You can also flip these live (e.g. disable controls during a cutscene): `character.config.set('character.camera.allowModeToggle', false)`.
355
+
356
+ ### First-person vs third-person (a slice of the camera config)
357
+
358
+ - Initial mode: `config.character.camera.mode` = `'first-person'` or `'third-person'` (default).
359
+ - Toggle at runtime: `character.services.camera?.setMode('first-person')` (or `.toggleMode()`).
360
+ - Tune via config like everything else: `character.camera.fp.fov`, `character.camera.tp.distance`,
361
+ `character.camera.lookSensitivity`, `character.camera.minPitchDeg` / `maxPitchDeg`, … — see the manifest.
362
+
363
+ ## 8b. Loading screen — `src/loading.ts` (count-based progress, extensible)
364
+
365
+ The `#loading` overlay in `index.html` paints on HTML parse (before any JS downloads → no flash).
366
+ `src/loading.ts` then hooks **`THREE.DefaultLoadingManager`** — the SAME instance the engine's
367
+ GLTFLoader/KTX2Loader use (one shared three via the import map) — so the character, every ability, and
368
+ ANY extra three load you add count on the bar automatically, with no extra code. The bar climbs as
369
+ assets stream, shows an indeterminate **"Starting…"** tail for the un-metered window (Rapier WASM
370
+ compile, character setup), then fades on the first rendered frame. Ship this file verbatim; restyle freely.
371
+
372
+ ```ts
373
+ import * as THREE from 'three';
374
+
375
+ // Loading screen for this world. It hooks THREE.DefaultLoadingManager — the SAME instance the engine's
376
+ // GLTFLoader/KTX2Loader use (one shared three via the import map) — so the character, every ability, and
377
+ // ANY extra three load you add (a world GLB, a TextureLoader, …) count on the bar automatically, with no
378
+ // extra code. For a NON-three asset (audio, a raw fetch), wrap it in loading.task(). The overlay markup
379
+ // + CSS live in index.html so it paints before this module downloads (no flash). Restyle/extend freely.
380
+
381
+ const BAND_MAX = 0.9; // the measured climb fills [0, 0.9]; the last tenth is the post-load tail + first frame
382
+ const QUEUE_MIN = 5; // go indeterminate→measured only once a real wave is queued (total-loaded ≥ this), so the
383
+ // brief body+face 2/2 blip can't fill the bar then snap backward when the 38 clips enqueue
384
+
385
+ export interface LoadingScreen {
386
+ /** Fold a NON-three async asset into the bar (counts like one item). Call before the work; handle.done() when it resolves. */
387
+ task(): { done(): void };
388
+ /** Set the status line under the bar. */
389
+ label(text: string): void;
390
+ /** Enter the indeterminate "Starting…" tail — covers the un-metered window (Rapier WASM compile, character setup). */
391
+ working(text?: string): void;
392
+ /** One-shot fade + remove. Call AFTER the first renderer.render(), never on manager.onLoad (it fires early). */
393
+ dismiss(): void;
394
+ /** Switch to the error state (red bar + message). */
395
+ fail(message: string): void;
396
+ }
397
+
398
+ export function createLoadingScreen(): LoadingScreen {
399
+ const root = document.getElementById('loading');
400
+ const bar = document.getElementById('bar');
401
+ const textEl = document.getElementById('loading-text');
402
+ const noop = (): void => {};
403
+ if (!root || !bar || !textEl) return { task: () => ({ done: noop }), label: noop, working: noop, dismiss: noop, fail: noop };
404
+
405
+ const mgr = THREE.DefaultLoadingManager;
406
+ let determinate = false; // latched once a real queue appears; never reverts (no marquee↔fill flicker)
407
+ let tail = false; // working() latched — ignore further manager ticks; the pulse owns the bar
408
+ let shown = 0; // last rendered fraction — monotonic, never decreases
409
+ let extra = 0; // open task() handles, folded into the denominator
410
+
411
+ const render = (loaded: number, total: number): void => {
412
+ if (tail) return;
413
+ const t = total + extra;
414
+ if (!determinate && t - loaded >= QUEUE_MIN) { determinate = true; root.classList.add('determinate'); }
415
+ if (!determinate) return;
416
+ const frac = Math.min(loaded / Math.max(t, 1), 1) * BAND_MAX;
417
+ shown = Math.max(shown, frac);
418
+ bar.style.width = `${(shown * 100).toFixed(1)}%`;
419
+ };
420
+
421
+ mgr.onStart = (_url, loaded, total) => render(loaded, total);
422
+ mgr.onProgress = (url, loaded, total) => {
423
+ render(loaded, total);
424
+ if (tail) return;
425
+ if (/anims\/.*\.glb(\?|$)/.test(url)) textEl.textContent = `Loading animations… ${loaded}/${total}`;
426
+ else if (/\.ktx2(\?|$)/.test(url) || /basis_transcoder/.test(url)) textEl.textContent = 'Decoding textures…';
427
+ };
428
+ // No mgr.onLoad: it fires on the transient body/face 2/2 before the clips queue. Dismissal is the world's
429
+ // job — a one-shot after the first rendered frame (see main.ts).
430
+
431
+ return {
432
+ task() { extra += 1; let closed = false; return { done() { if (!closed) { closed = true; extra -= 1; } } }; },
433
+ label(text) { textEl.textContent = text; },
434
+ working(text = 'Starting…') { tail = true; root.classList.add('determinate', 'working'); textEl.textContent = text; },
435
+ dismiss() {
436
+ if (root.classList.contains('done')) return;
437
+ root.classList.remove('working');
438
+ root.classList.add('determinate');
439
+ bar.style.width = '100%';
440
+ root.classList.add('done');
441
+ setTimeout(() => root.remove(), 450);
442
+ },
443
+ fail(message) { root.classList.add('error'); textEl.textContent = `ERROR: ${message}`; },
444
+ };
445
+ }
446
+ ```
447
+
448
+ **Add your own assets to the bar.** An extra three load is counted automatically — just load it on the
449
+ default manager (`new GLTFLoader().loadAsync(...)`). For a NON-three asset (audio, a raw fetch), wrap it
450
+ in `loading.task()`:
451
+
452
+ ```ts
453
+ loading.label('Loading music…');
454
+ const music = loading.task(); // counts like one item; the bar waits for it
455
+ const audio = new Audio(new URL('theme.mp3', document.baseURI).href);
456
+ audio.addEventListener('canplaythrough', () => music.done(), { once: true });
457
+ audio.load();
458
+ ```
459
+
460
+ **Errors.** To surface a load failure on the overlay instead of a frozen bar, guard the awaited setup and
461
+ call `loading.fail(...)`:
462
+
463
+ ```ts
464
+ try {
465
+ // … loadCharacterAssets … Character.create … loadInstalledAbilities …
466
+ } catch (err) {
467
+ loading.fail(err instanceof Error ? err.message : String(err));
468
+ throw err;
469
+ }
470
+ ```
471
+
472
+ ## 9. Install, build, publish
473
+
474
+ ```bash
475
+ npm install
476
+ helix install # resolve the pins → download code, write installed.json + lock, wire the
477
+ # three import map + src/helix.runtime.ts (re-run after editing pins;
478
+ # `helix install --update` re-resolves ranges to newer versions)
479
+ npm run build # vite build (three external; system + addons + rapier bundle)
480
+ ```
481
+
482
+ Then `validate_world` on `dist/` (fix every problem — no `.glb`/`.ktx2` may ship; assets stream from
483
+ the CDN), `whoami` to confirm login, and `publish_world` on `dist/` for the play link.
484
+
485
+ ## The rules that matter
486
+
487
+ - **One three.** `three` is external everywhere (import map → hosted instance). Never bundle it.
488
+ - **Assets always stream from the CDN.** Animation clips + textures are never downloaded or shipped
489
+ with the world. The build contains no `.glb`/`.ktx2`. `loadCharacterAssets` fetches them at runtime.
490
+ - **Test with `build` + `preview`, not `dev`.** The dev server rewrites bare imports and hides whether
491
+ the import map is correct; preview serves the real built output the platform runs.
492
+ - **Pins are locked.** A rebuild uses the versions in `helix.lock.json`; `helix install --update`
493
+ advances them. Commit the lock so rebuilds are reproducible.
@@ -0,0 +1,25 @@
1
+ # helix.json — Manifest Reference (v0.1)
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
+ Required fields: `helixVersion`, `title`, `slug`, `entry`. Unknown fields are REJECTED.
6
+
7
+ | Field | Type / allowed values | Default | Description |
8
+ | --- | --- | --- | --- |
9
+ | `helixVersion` | const `"0.1"` | — (required) | Manifest schema version. Determines which fields and permissions are available. |
10
+ | `title` | `string` (1–80 chars) | — (required) | Display name shown on the world page and in listings. |
11
+ | `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}. |
12
+ | `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 '..'. |
13
+ | `maxPlayers` | `integer` (1–100) | `1` | Player slots per room. 1 = single-player. Only meaningful with the multiplayer permission (M2). |
14
+ | `permissions` | array of `"auth.profile"` | `[]` | Platform capabilities the world requests, OAuth-scope style. Worlds get nothing by default. v0.1 defines: auth.profile (read the player's id, username, display name). Later versions add multiplayer, voice.*, wallet.*, inventory.*. |
15
+ | `supportsMobile` | `boolean` | `false` | Whether the world is playable on mobile browsers. Surfaces as a compatibility tag. |
16
+ | `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. |
17
+ | `contentRating` | `"unrated"` / `"everyone"` / `"teen"` / `"mature"` | `"unrated"` | Self-declared content rating. Curated review may override. 'unrated' worlds may be restricted from public listing. |
18
+
19
+ ## Bundle rules (enforced at validate AND publish)
20
+
21
+ - Max 200 files, 25 MB per file, 50 MB total, no empty files
22
+ - Paths are bundle-relative: no leading `/`, no `..`
23
+ - 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
24
+ - `entry` must exist in the bundle and end in `.html`
25
+ - The manifest file itself must be at the bundle root as `helix.json`
@@ -0,0 +1,42 @@
1
+ # Publishing to HELIX Instant
2
+
3
+ ## The pipeline
4
+
5
+ `publish_world` runs the full creator pipeline against the platform:
6
+
7
+ 1. **Local validation** — the bundle directory is checked with the exact rules the server enforces (manifest schema, slug format, entry exists, file types, size budget). `validate_world` runs the same check standalone.
8
+ 2. **World resolution** — the manifest `slug` is matched against your existing worlds; a new world is created on first publish. Re-publishing a slug creates the **next build** (builds are immutable; the newest finalized build is what players get).
9
+ 3. **Upload** — each file is uploaded to CDN storage via presigned URLs (size and content type are cryptographically pinned; a mismatched upload is rejected by storage itself).
10
+ 4. **Finalize** — the platform verifies every declared file landed byte-exact, then atomically activates the build. A draft world auto-publishes on its first successful build.
11
+ 5. You get back the **play URL** — shareable, instant, no install.
12
+
13
+ ## Login (human prerequisite)
14
+
15
+ Publishing requires a creator account session. Agents cannot log in — the flow is interactive. If `whoami` says not logged in, ask the human to run:
16
+
17
+ ```bash
18
+ npm i -g @hypersoniclabs/helix-cli # NOT `npx helix` — that's an unrelated package
19
+ helix login
20
+ ```
21
+
22
+ Creator access is currently invite-only (curated launch); the account must have the creator flag.
23
+
24
+ ## Error semantics
25
+
26
+ - Validation errors list every problem at once — fix all of them, rebuild, re-validate.
27
+ - `409` on world creation: the slug is taken by another creator. Choose a different slug in `helix.json`.
28
+ - "missing upload" / "size mismatch" at finalize: the build directory changed between validate and publish — rebuild and re-publish.
29
+ - `401`/`403`: login expired or the account lacks the creator flag — back to the human.
30
+
31
+ ## After publishing
32
+
33
+ - World page: `https://helix-instant-website-production.up.railway.app/w/<slug>`
34
+ - Instant play: `https://helix-instant-website-production.up.railway.app/play/<slug>`
35
+ - Title, content rating, mobile support, and `requiresAuth` on the world page all come from the manifest — re-publish to update them.
36
+
37
+ ## Keeping the toolchain current
38
+
39
+ The `@helix` packages publish independently; call `check_for_updates` (or `helix doctor`) to see what's behind and get the exact update command.
40
+
41
+ - **CLI and MCP** — run them via `npx -y @hypersoniclabs/helix-cli@latest` / `npx -y @hypersoniclabs/helix-mcp@latest` and they're always current (the MCP config already does this). A global install goes stale; refresh it with `npm i -g @hypersoniclabs/helix-cli@latest`.
42
+ - **SDK** (`@hypersoniclabs/helix-sdk`, pinned in a world's `package.json`) — a `^0.x` range locks the minor, so a new minor isn't picked up by `npm update`. Bump the range and reinstall: `npm i @hypersoniclabs/helix-sdk@latest`.
package/docs/sdk.md ADDED
@@ -0,0 +1,105 @@
1
+ # @hypersoniclabs/helix-sdk
2
+
3
+ The HELIX Instant SDK. Worlds running on HELIX Instant use it for player identity today, and multiplayer / voice / wallet / inventory in upcoming versions. It is the **only** way a world talks to the platform — worlds never call platform APIs or internal services directly.
4
+
5
+ > This document is the SDK contract. It is written to be sufficient for an AI agent to integrate a world without reading the SDK source.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install @hypersoniclabs/helix-sdk
11
+ ```
12
+
13
+ ## Core concepts
14
+
15
+ 1. **Your world runs in a sandboxed iframe** inside the HELIX shell (the portal play page, or the `helix dev` shell during local development). The SDK talks to the shell via `postMessage`; the shell talks to the platform.
16
+ 2. **Identity is granted, not taken.** Your world receives a short-lived, world-scoped session that only unlocks the permissions declared in your `helix.json` manifest. You never see the player's platform credentials.
17
+ 3. **Login UI belongs to the shell.** Your world cannot render a login form — it *requests* login, and the shell overlays its own UI. This is deliberate (anti-phishing) and means you never handle passwords.
18
+ 4. **Standalone mode.** When the world is opened directly (e.g. `vite dev` without a shell), `init()` resolves with `embedded: false` and all identity APIs return `null`/`false`. Your world should still run — treat identity as an enhancement.
19
+
20
+ ## Quick start
21
+
22
+ ```ts
23
+ import { Helix } from '@hypersoniclabs/helix-sdk';
24
+
25
+ const { embedded, user, world } = await Helix.init(); // call once, before anything else
26
+
27
+ if (user) {
28
+ greet(user.displayName ?? user.username);
29
+ }
30
+
31
+ // React to login/logout at any time (e.g. update the HUD):
32
+ Helix.auth.onAuthChanged((user) => updateHud(user));
33
+
34
+ // Prompt login at a moment that makes sense in your world:
35
+ saveButton.onclick = async () => {
36
+ try {
37
+ const user = await Helix.auth.requestLogin(); // shell overlay; no page reload
38
+ await saveProgress(user.id);
39
+ } catch {
40
+ // player dismissed the login — keep playing, nothing is lost
41
+ }
42
+ };
43
+ ```
44
+
45
+ ## API
46
+
47
+ ### `Helix.init(): Promise<HelixInitResult>`
48
+
49
+ Performs the shell handshake. **Must be called once before any other API.** Safe to call again (returns the same state). Resolves within ~3s even with no shell present.
50
+
51
+ ```ts
52
+ type HelixInitResult = {
53
+ embedded: boolean; // false when running standalone (local dev)
54
+ world: { id: string; slug: string; title: string } | null;
55
+ user: HelixUser | null; // null when not logged in
56
+ };
57
+ ```
58
+
59
+ ### `Helix.auth.getUser(): Promise<HelixUser | null>`
60
+
61
+ The current player, or `null` when unauthenticated. Requires the `auth.profile` permission in your manifest.
62
+
63
+ ```ts
64
+ type HelixUser = {
65
+ id: string; // stable player id (UUID) — use this as your save key
66
+ username: string; // unique handle
67
+ displayName: string | null;
68
+ };
69
+ ```
70
+
71
+ ### `Helix.auth.isAuthenticated(): boolean`
72
+
73
+ Synchronous check.
74
+
75
+ ### `Helix.auth.requestLogin(): Promise<HelixUser>`
76
+
77
+ Asks the shell to show its login overlay. Resolves with the user on success. Rejects when: the player dismisses the overlay (`'dismissed'`), no shell is present (standalone), or the request times out. **The world keeps running throughout — there is no reload, and your state is preserved.** If already logged in, resolves immediately.
78
+
79
+ ### `Helix.auth.onAuthChanged(cb: (user: HelixUser | null) => void): () => void`
80
+
81
+ Subscribes to login/logout. Fires with the user on login and `null` on logout. Returns an unsubscribe function.
82
+
83
+ ### `Helix.getSessionToken(): string | null`
84
+
85
+ The raw world-scoped session token (JWT, `aud: helixb-world`). Most worlds never need this; later SDK modules use it internally.
86
+
87
+ ## Manifest requirements
88
+
89
+ Your bundle root must contain a `helix.json` manifest (see `@hypersoniclabs/helix-manifest`). To use the identity APIs, declare the permission:
90
+
91
+ ```json
92
+ {
93
+ "helixVersion": "0.1",
94
+ "title": "My World",
95
+ "slug": "my-world",
96
+ "entry": "index.html",
97
+ "permissions": ["auth.profile"]
98
+ }
99
+ ```
100
+
101
+ Calling an API whose permission is not declared returns an error — permissions are enforced server-side on the session token, not just in the SDK.
102
+
103
+ ## Coming in later versions
104
+
105
+ `Helix.multiplayer` (instances), `Helix.voice`, `Helix.wallet`, `Helix.inventory`, `Helix.cloudSave`, `Helix.leaderboards`. The shapes follow the same pattern: capability declared in the manifest → granted on the session → exposed as a namespace.