@hypersoniclabs/helix-mcp 0.2.4 → 0.2.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +81 -11
- package/dist/continuumCanary.d.ts +17 -0
- package/dist/continuumCanary.js +17 -0
- package/dist/continuumCanary.js.map +1 -0
- package/dist/server.d.ts +14 -1
- package/dist/server.js +4058 -162
- package/dist/server.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/dist/vehicleTools.d.ts +86 -0
- package/dist/vehicleTools.js +229 -0
- package/dist/vehicleTools.js.map +1 -0
- package/docs/avatar-face.md +115 -0
- package/docs/bridge.md +98 -0
- package/docs/bring-your-world.md +117 -0
- package/docs/catalog.md +69 -1
- package/docs/character-animation.md +442 -0
- package/docs/character-attachments.md +166 -0
- package/docs/character-world.md +785 -130
- package/docs/continuum.md +153 -0
- package/docs/items.md +73 -0
- package/docs/lighting-world.md +667 -0
- package/docs/locomotion-clip-spec.md +294 -0
- package/docs/manifest.md +31 -6
- package/docs/multiplayer-logic.md +460 -17
- package/docs/multiplayer-templates/chrono-orchard.md +36 -22
- package/docs/multiplayer-templates/collect-a-thon.md +28 -38
- package/docs/multiplayer-templates/collections.md +24 -24
- package/docs/multiplayer-templates/hangout.md +124 -111
- package/docs/multiplayer-templates/npc-wave.md +310 -0
- package/docs/multiplayer-templates/obby.md +13 -16
- package/docs/multiplayer-templates/persistent-progress.md +218 -0
- package/docs/multiplayer-templates/physics-bumper.md +27 -9
- package/docs/multiplayer-templates/physics-football.md +22 -8
- package/docs/multiplayer-templates/relic-bearers.md +12 -15
- package/docs/multiplayer-templates/server-motion.md +16 -19
- package/docs/multiplayer-templates/shooter-range.md +275 -0
- package/docs/multiplayer-templates/team-control.md +28 -15
- package/docs/multiplayer-templates/turn-arena.md +31 -22
- package/docs/multiplayer-templates/voice-radio.md +166 -0
- package/docs/multiplayer-templates/wave-survival.md +7 -8
- package/docs/multiplayer-templates/world-shop.md +240 -0
- package/docs/multiplayer-world.md +222 -129
- package/docs/npc-world.md +623 -0
- package/docs/publishing.md +108 -28
- package/docs/purchases.md +223 -0
- package/docs/scene-performance.md +64 -0
- package/docs/screenshots.md +140 -0
- package/docs/sdk.md +324 -5
- package/docs/shooter-worlds.md +537 -0
- package/docs/terrain.md +173 -0
- package/docs/upgrades.md +324 -0
- package/docs/vehicles.md +727 -0
- package/docs/world-inspect.md +156 -0
- package/docs/world-look.md +241 -0
- package/docs/world-recipe.md +65 -6
- package/package.json +15 -4
- package/skills/README.md +91 -0
- package/skills/helix-assets/SKILL.md +491 -0
- package/skills/helix-assets/references/asset-sources.md +143 -0
- package/skills/helix-assets/references/vault-api.md +105 -0
- package/skills/helix-avatar-qa/SKILL.md +85 -0
- package/skills/helix-avatars/SKILL.md +206 -0
- package/skills/helix-avatars/references/contract.md +166 -0
- package/skills/helix-avatars/references/dynamics.md +367 -0
- package/skills/helix-avatars/references/face.md +50 -0
- package/skills/helix-avatars/references/publish.md +76 -0
- package/skills/helix-avatars/references/qa.md +251 -0
- package/skills/helix-avatars/references/rigging.md +88 -0
- package/skills/helix-avatars/references/source-generated.md +190 -0
- package/skills/helix-avatars/references/source-model.md +90 -0
- package/skills/helix-avatars/references/source-rigid.md +90 -0
- package/skills/helix-avatars/references/source-vrm.md +61 -0
- package/skills/helix-gauntlet/SKILL.md +128 -0
- package/skills/helix-multiplayer/SKILL.md +150 -0
- package/skills/helix-multiplayer/references/dsl-capability-map.md +107 -0
- package/skills/helix-vehicles/SKILL.md +218 -0
- package/skills/helix-vehicles/references/addons.md +212 -0
- package/skills/helix-vehicles/references/appearance.md +339 -0
- package/skills/helix-vehicles/references/audio-import.md +138 -0
- package/skills/helix-vehicles/references/audio.md +580 -0
- package/skills/helix-vehicles/references/cabin.md +225 -0
- package/skills/helix-vehicles/references/host-manifest.md +174 -0
- package/skills/helix-vehicles/references/physics-from-specs.md +463 -0
- package/skills/helix-vehicles/references/publish.md +214 -0
- package/skills/helix-vehicles/references/qa.md +177 -0
- package/skills/helix-vehicles/references/reference-package.json +3481 -0
- package/skills/helix-vehicles/references/reference-package.md +69 -0
- package/skills/helix-vehicles/references/source-beamng.md +167 -0
- package/skills/helix-vehicles/references/source-concept.md +38 -0
- package/skills/helix-vehicles/references/source-model.md +100 -0
- package/skills/helix-vehicles/references/source-scratch.md +60 -0
- package/skills/helix-world-build/SKILL.md +376 -0
- package/skills/helix-world-build/references/config-gates.md +104 -0
- package/skills/helix-world-director/SKILL.md +210 -0
- package/skills/helix-world-qa/SKILL.md +371 -0
- package/skills/helix-world-qa/references/perf-budgets.md +240 -0
- package/skills/helix-world-qa/references/perf-handle.md +125 -0
- package/skills/helix-world-qa/references/visual-scorecard.md +85 -0
package/docs/sdk.md
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# @hypersoniclabs/helix-sdk
|
|
2
2
|
|
|
3
|
-
The HELIX Instant SDK.
|
|
3
|
+
The HELIX Instant SDK. It is the **only** way a world talks to the platform — worlds never call platform APIs or internal services directly.
|
|
4
|
+
|
|
5
|
+
Shipped namespaces: `Helix.auth` · `Helix.avatar` · `Helix.emotes` · `Helix.multiplayer` (see the multiplayer hub via `get_started({ kind: "multiplayer" })`) · `Helix.voice` · `Helix.wallet` · `Helix.marketplace` · `Helix.purchases` (what a sale granted — see `read_doc({ name: "purchases" })`) · `Helix.inventory` · `Helix.dataStore` · `Helix.leaderboard` · `Helix.achievements` · `Helix.camera` · `Helix.presence` · `Helix.notify` · `Helix.prompts` · `Helix.device` · `Helix.analytics`.
|
|
4
6
|
|
|
5
7
|
> 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
8
|
|
|
@@ -12,10 +14,10 @@ npm install {{SDK_INSTALL_SPEC}}
|
|
|
12
14
|
|
|
13
15
|
## Core concepts
|
|
14
16
|
|
|
15
|
-
1. **Your world runs in a sandboxed iframe** inside
|
|
17
|
+
1. **Your world runs in a sandboxed iframe** inside a HELIX shell — the portal play page in production, or the **simulated** shell `helix dev` serves on your own machine (see *Local development* at the end of this doc). The SDK talks to the shell via `postMessage`; the shell talks to the platform.
|
|
16
18
|
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
19
|
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 (
|
|
20
|
+
4. **Standalone (preview) mode.** When the world is opened directly (`vite dev`, `vite preview` — no shell), `init()` resolves with `embedded: false`, identity APIs return `null`/`false`, and the rest of the platform surface degrades to neutral: saves no-op, purchases refuse, entitlements read empty. The SDK says so out loud, once per method — ``[Helix] dataStore.set() did nothing — not embedded in a HELIX shell. Run `helix dev` to exercise this locally.`` Your world should still run (treat identity as an enhancement), but a clean preview run is **never** evidence that a save, a sale or a board works — run `helix dev` for that.
|
|
19
21
|
|
|
20
22
|
## Quick start
|
|
21
23
|
|
|
@@ -96,6 +98,72 @@ character recipe §8 and the `hangout` template §3a. **Gate on `skeleton === 'h
|
|
|
96
98
|
character assets load — the body is bind-once. In multiplayer, remote players' avatars arrive on room state
|
|
97
99
|
(`player.avatarUrl`, `''` = none) — you never look up another user's avatar yourself.
|
|
98
100
|
|
|
101
|
+
**Changing the avatar is not an SDK call.** Use `mp.changeAvatar(inventoryItemId | null)` on the character
|
|
102
|
+
system's `CharacterMultiplayer` — the one avatar change (placeholder → persist → swap → every player sees it).
|
|
103
|
+
`Helix.avatar.updateLoadout` still exists as published contract, but do not use it to change the body: it
|
|
104
|
+
skips the placeholder and is not the contract agents and worlds share. See the character recipe §8.
|
|
105
|
+
|
|
106
|
+
### `Helix.emotes` — the player's carried emote wheel
|
|
107
|
+
|
|
108
|
+
`getEquipped(): Promise<{ emotes: EquippedEmote[] } | null>` — the local player's equipped emotes
|
|
109
|
+
(`{ slot, itemId, clip, playback, audio?, label? }`; `slot` is `r0`..`r7` for the eight radial petals plus
|
|
110
|
+
`sit` / `lie`). `null` for guests / standalone / any failure; an **empty array is a real answer** — this
|
|
111
|
+
player carries none. `onChanged(cb)` fires after the platform's customizer closes. `openCustomizer():
|
|
112
|
+
Promise<boolean>` raises that customizer over the world and resolves `false` when the player is signed out
|
|
113
|
+
or the host mounts no editor — "it did not open" is a normal outcome, not an exception.
|
|
114
|
+
|
|
115
|
+
**Most worlds should not call any of this.** The character system's native avatar runtime reads the same
|
|
116
|
+
loadout off the shell channel itself and drives the wheel, the tap-replay, the posture lane and the
|
|
117
|
+
Customize door with no world code — see the character recipe §8g, which also covers how to refuse those
|
|
118
|
+
features if your world does not want them. Reach for this namespace only when you are drawing your own
|
|
119
|
+
emote UI in place of the native wheel.
|
|
120
|
+
|
|
121
|
+
### `Helix.voice` — voice chat (LiveKit under the hood)
|
|
122
|
+
|
|
123
|
+
Player voice for multiplayer worlds — **the platform default: every multiplayer world should ship voice**
|
|
124
|
+
(scaffolds and templates already include it; a deliberately silent world opts out by removing the permission
|
|
125
|
+
and the join block). Requires: a `voice.proximity` or `voice.room` **permission** in the manifest (each
|
|
126
|
+
implies `multiplayer`), a logged-in player, and a **joined multiplayer room** (the voice room pairs 1:1 to
|
|
127
|
+
the game room instance). The heavy voice client is dynamically imported only when a world calls `join()` —
|
|
128
|
+
no-voice worlds pay zero bundle cost.
|
|
129
|
+
|
|
130
|
+
```ts
|
|
131
|
+
// After Helix.multiplayer.joinRoom() succeeds (mp.room != null with the facade):
|
|
132
|
+
try {
|
|
133
|
+
if (await Helix.voice.join()) mp.attachVoice(Helix.voice, manifest.multiplayer.voice);
|
|
134
|
+
} catch (err) {
|
|
135
|
+
console.info('voice unavailable:', err); // fail-soft — the world stays fully playable
|
|
136
|
+
}
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
- **`join(options?): Promise<boolean>`** — fetches the voice grant and connects. Resolves `false` (never
|
|
140
|
+
throws) when voice is off for this world/environment, so it is safe to call unconditionally. Options:
|
|
141
|
+
`pttKey` (default `'n'`), `channel` (initial channel), `roomId`/`apiBaseUrl` overrides. `n` is a
|
|
142
|
+
platform-reserved key (the character system's `capabilities.reservedKeys`) — if your world needs **N**
|
|
143
|
+
for gameplay, repoint `pttKey` here; never bind a game action over the PTT default. (The default moved
|
|
144
|
+
from `'b'` — KeyB now belongs to the `point` standard action.)
|
|
145
|
+
- **`leave(): Promise<void>`** — releases the mic and disconnects. Safe when not joined.
|
|
146
|
+
- **`setPttDown(down: boolean)`** — programmatic push-to-talk, the pad/touch leg. You normally never call
|
|
147
|
+
it: `mp.attachVoice` auto-registers the `voicePTT` standard action (hold modifier + right bumper, global
|
|
148
|
+
context — keeps working under menus) and forwards it here. Only 'ptt' mic mode opens the mic, and the
|
|
149
|
+
hot-mic safeties (blur/hidden tab) release a programmatic hold too.
|
|
150
|
+
- **Mic behavior belongs to the PLAYER, not the world**: mode (push-to-talk **N**, pad: hold
|
|
151
|
+
modifier+RB / open / muted), device,
|
|
152
|
+
volumes, sensitivity, and per-player mutes are set from the platform tablet's Voice tab and pushed to the
|
|
153
|
+
SDK by the shell. A world never renders mic UI.
|
|
154
|
+
- **Channels (exclusive semantics):** every player is in exactly ONE voice space — the ambient world or a
|
|
155
|
+
named channel. `setChannel(id)` switches (a walkie-talkie frequency, a private call); `setChannel(null)`
|
|
156
|
+
returns to ambient. Ids are free-form — dynamic ids (e.g. `` `call:${[a,b].sort().join(':')}` ``) need no
|
|
157
|
+
declaration and render flat; channels declared in the manifest's `multiplayer.voice.channels` can opt into
|
|
158
|
+
proximity render. Readers: `channel()`, `channelOf(userId)`.
|
|
159
|
+
- **Rendering is the engine's job:** `mp.attachVoice(Helix.voice, manifest.multiplayer.voice)` drives
|
|
160
|
+
per-player volume (ambient proximity falloff or flat, per-channel policies) off synced positions, feeds
|
|
161
|
+
the directional pan when the manifest sets `"spatial": true` (ambient voices heard from where the speaker
|
|
162
|
+
stands; channels stay center-panned), and lights the nameplate mic glyphs. Pass the manifest block straight
|
|
163
|
+
through — one source of truth.
|
|
164
|
+
- Speaking/participant events for custom UI: `onSpeakingChanged(cb)`, `onParticipantsChanged(cb)`,
|
|
165
|
+
`participantIds()` (all keyed by platform userId, which is also `PlayerState.userId` on room state).
|
|
166
|
+
|
|
99
167
|
## Manifest requirements
|
|
100
168
|
|
|
101
169
|
Your bundle root must contain a `helix.json` manifest (see `@hypersoniclabs/helix-manifest`). To use the identity APIs, declare the permission:
|
|
@@ -112,6 +180,257 @@ Your bundle root must contain a `helix.json` manifest (see `@hypersoniclabs/heli
|
|
|
112
180
|
|
|
113
181
|
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.
|
|
114
182
|
|
|
115
|
-
|
|
183
|
+
**There are exactly five permissions**, and most SDK namespaces need none of them:
|
|
184
|
+
|
|
185
|
+
| Permission | Available in | Gates |
|
|
186
|
+
| --- | --- | --- |
|
|
187
|
+
| `auth.profile` | v0.1+ | `Helix.auth` — the player's id, username, display name |
|
|
188
|
+
| `multiplayer` | v0.3 | joining the world's shared room. Forces `requiresAuth: true` |
|
|
189
|
+
| `voice.room` / `voice.proximity` | v0.3 | `Helix.voice`. Each implies `multiplayer` |
|
|
190
|
+
| `camera.capture` | v0.3 | `Helix.camera.savePhoto` — writing a photo to the player's gallery |
|
|
191
|
+
|
|
192
|
+
`wallet`, `marketplace`, `purchases`, `inventory`, `dataStore`, `leaderboard`, `achievements`, `presence`, `notify`, `prompts`, `device` and `analytics` require **no additional manifest permission** — they ride the world-scoped session. Do not invent a permission string for them; the manifest schema is `additionalProperties: false` and an unknown permission is a validation error, not a warning.
|
|
193
|
+
|
|
194
|
+
## `Helix.dataStore` — durable per-world storage
|
|
195
|
+
|
|
196
|
+
The Roblox-DataStore analog, and the right place for anything that must survive a session: player progress, settings, world state. Scoped to THIS world. Free for every world; quotas and write rate limits apply, and a rejected write throws.
|
|
197
|
+
|
|
198
|
+
**The single-key doctrine: a player's client-side save is ONE JSON document, at exactly `player:${user.id}`.** That is the only key a session may write — structure everything as *fields inside it* (`{ level, coins, settings, artifacts: […] }`) rather than spreading a save across `player:${id}:coins`, `player:${id}:settings`, … Sub-keys under a player, world-global keys and room state exist, but they are **server-written** (by the game room or by creator tooling); a session write outside its own exact key is refused with a 403 naming the rule it broke. Reads are open — any client can point-`get` any key in the world.
|
|
199
|
+
|
|
200
|
+
```ts
|
|
201
|
+
await Helix.dataStore.set(`player:${user.id}`, { level: 4, coins: 120, unlocked: ['cave'] });
|
|
202
|
+
const save = await Helix.dataStore.get<{ level: number }>(`player:${user.id}`); // null when unset
|
|
203
|
+
const { keys, nextCursor } = await Helix.dataStore.list('player:'); // keys only, never values
|
|
204
|
+
if (nextCursor) { /* >100 keys match: pass { cursor: nextCursor } for the next page */ }
|
|
205
|
+
await Helix.dataStore.delete(`player:${user.id}`);
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
**Optimistic concurrency** — read the version, hand it back on the write, and a losing race is refused instead of silently clobbering the other writer (two tabs, or the player's own client racing the game room):
|
|
209
|
+
|
|
210
|
+
```ts
|
|
211
|
+
const entry = await Helix.dataStore.getEntry<Save>(`player:${user.id}`); // { value, version } | null
|
|
212
|
+
const next = { ...(entry?.value ?? fresh), coins: (entry?.value.coins ?? 0) + 10 };
|
|
213
|
+
await Helix.dataStore.set(`player:${user.id}`, next, { expectedVersion: entry?.version ?? 0 }); // 0 asserts create-only
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
A stale `expectedVersion` fails with code `version-conflict` (HTTP 409) — re-read with `getEntry` and re-apply, never retry blindly. `delete` accepts the same guard. Omit `expectedVersion` and the write is a last-writer-wins overwrite.
|
|
217
|
+
|
|
218
|
+
**Refusal codes** — a rejected call throws a `HelixRequestError` carrying a machine-readable **`code`**, so branch on the code and not on the message prose (`catch (e) { if (e.code === 'rate-limited') …}`). It is `undefined` when the failure has no classification (or the shell predates the code), so always keep a generic fallback. The ones an agent actually meets:
|
|
219
|
+
|
|
220
|
+
| `code` | Means |
|
|
221
|
+
| --- | --- |
|
|
222
|
+
| `session-foreign-key` | writing `player:{someoneElse}` — a session only ever writes its own key |
|
|
223
|
+
| `session-sub-key` | writing `player:{you}:something` — put it in the one document instead |
|
|
224
|
+
| `session-server-key` | writing a world/room key a session doesn't own (`world:*`, `mp:player:*`, free-form server keys) |
|
|
225
|
+
| `platform-reserved` | the `helix:*` prefix — platform data, never world-writable |
|
|
226
|
+
| `prefix-reserved` | a reserved-but-unbuilt prefix (`mail:*`, `mp:world`) or a route-owned one (`leaderboard:*`) |
|
|
227
|
+
| `counter-server-only` | a session tried to increment a world counter — counters move through server rules only |
|
|
228
|
+
| `leaderboard-self-only` | submitting a score for another player |
|
|
229
|
+
| `leaderboard-cap` | a session minting a NEW board past the world's 8-board cap — declared boards come from the room/creator lane |
|
|
230
|
+
| `version-conflict` | a stale `expectedVersion` — someone wrote first |
|
|
231
|
+
| `rate-limited` | over the per-player or per-world write budget — back off, don't hammer |
|
|
232
|
+
|
|
233
|
+
**There is no multi-key transaction.** Two keys cannot be updated atomically — design so no two keys must move together, or keep the coupled values in one key. In preview mode (opened directly, no shell) `get`/`getEntry` return null, `list` returns an empty page, and writes no-op with a console warning — a no-op `set` is exactly what makes "I verified persistence in preview" a false positive, so test saves under `helix dev`, which persists them and enforces the same key policy. Value-bearing writes should be made server-authoritatively.
|
|
234
|
+
|
|
235
|
+
**Shared surfaces and artifacts (guestbooks, galleries, placements, ghosts).** The tempting shape is one key per contributor — `world:guestbook:{userId}` — and it is an **anti-pattern**: keys scale with *authority boundaries*, never with data cardinality. Every one of those entries shares one writer (the room) and one access rule (world-open), so they never earned separate keys — a thousand visitors would spend a thousand keys on one corkboard. Make the SURFACE the key instead: ONE bounded document (`world:guestbook` = `{ v, entries: […] }`) that the room rewrites via rules, bounded by design — one entry per author, or newest-N with a trim on every write. This is the same shape leaderboards use, for the same reason, and one point-`get` renders the whole thing. In a **single-player** world, per-author content lives as fields inside the author's own `player:{userId}` blob (a `builds` array, say — no new keys; other players can still point-`get` it and render). A `{userId}` in a key name is correct only when that user is the key's WRITER — their own save (`player:{id}`, `mp:player:{id}`) — and never for assembling a shared surface. Splitting a surface across keys is legitimate only when a single entry is too large to share a document (a multi-KB replay ghost against the 256 KB value cap); then key per thing, cap the count, and have the room delete beyond the cap. What you can never do, in any shape, is have one player write into another player's key.
|
|
236
|
+
|
|
237
|
+
## `Helix.leaderboard` — per-world ranked boards
|
|
238
|
+
|
|
239
|
+
Each board is one bounded document keeping its declared top `size` (up to 100), at most one entry per player; keep-best is the backend's atomic verdict, so a worse or below-cutoff score is a harmless no-op and resubmitting is always safe (`best` comes back `null` while you're off the board). Boards are declared per build (`multiplayer.leaderboards`, one named `main` — the world's default board) and are usually written by the game room's `submitScore` rule; the SDK is how a client reads them, and how a single-player world submits its own score.
|
|
240
|
+
|
|
241
|
+
```ts
|
|
242
|
+
const { best, updated } = await Helix.leaderboard.submit('bestTime', lapMs, { order: 'asc' });
|
|
243
|
+
if (!updated) showToast(`Your record of ${best} stands`); // updated:false = an existing better score won
|
|
244
|
+
|
|
245
|
+
const { entries, truncated } = await Helix.leaderboard.top('bestTime', { limit: 10, order: 'asc' });
|
|
246
|
+
for (const e of entries) addRow(e.displayName, e.score); // { subject, displayName, score, source, updatedAt }
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
- `submit(board, score, { order? })` → `{ best, updated }`. The subject is taken from the session token, so a client can only ever score for **itself** (`leaderboard-self-only` otherwise). A board's shape (`order`/`size`) is fixed when the board is CREATED: a session's `order` (`'desc'` default, `'asc'` for times) counts only on the very first submit to a brand-new board — an established board is never re-ranked or trimmed by a session; only the room/creator lane reshapes it. A session may mint at most 8 boards per world (`leaderboard-cap` past that).
|
|
250
|
+
- `top(board, { limit?, order? })` → `{ entries, truncated }`. `limit` ≤ 100; `truncated` is true when the board holds more than the page.
|
|
251
|
+
- `entry.source` records **who wrote the row**: `'session'` = client-claimed (a player's own submit — trust it like any client input), `'room'` = the authoritative game room. Show a leaderboard players compete over from `'room'` submissions; a single-player board is necessarily `'session'`.
|
|
252
|
+
- Both calls need a shell — in preview mode they reject rather than fake a board.
|
|
253
|
+
|
|
254
|
+
## `Helix.achievements` — this world's badges, read-only
|
|
255
|
+
|
|
256
|
+
Every achievement registered to THIS world plus the viewer's earned flags, for a trophy case / progress panel. **There is no award or claim call, deliberately**: a world runs in the player's own browser, so it can never attest that they earned anything. Grants come from the authoritative room's `awardAchievement` rule or from server-evaluated `criteria` — see `multiplayer-logic` §19 for both, and for the registration step every key needs first.
|
|
257
|
+
|
|
258
|
+
```ts
|
|
259
|
+
const { achievements, total, earned } = await Helix.achievements.list(); // all of them + the viewer's tally
|
|
260
|
+
renderTrophyCase(achievements, `${earned}/${total}`);
|
|
261
|
+
|
|
262
|
+
const { achievements: mine } = await Helix.achievements.mine(); // the earned-only subset
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
- Each entry is `{ id, key, name, description, points, hidden, earned, rarityTier, earnPct, iconUrl }`. `rarityTier` is `'unranked'` with a null `earnPct` until the backend has enough earns to rank it (`legendary` · `epic` · `rare` · `uncommon` · `common` otherwise); `description` and `iconUrl` may be null.
|
|
266
|
+
- A **hidden** achievement the viewer has NOT earned arrives **redacted** — the shell replaces name + description with placeholders and blanks `key` + `iconUrl` (the slug spells the name, the icon shows the secret), so nothing identifying reaches world code. Identify such a row by `id`. Render it as the locked mystery slot it is; do not try to reconstruct it.
|
|
267
|
+
- **World-scoped:** the shell supplies the world id, so a world can never enumerate the viewer's earns in other worlds. `mine()` is this world's earns only.
|
|
268
|
+
- Guests read `earned: false` on everything and an empty `mine()`. In preview mode (no shell) both resolve neutral empties (`list()` → `{ achievements: [], total: 0, earned: 0 }`, `mine()` → `{ achievements: [] }`) rather than rejecting — this is display data and must never break the world loop.
|
|
269
|
+
- **Not a notification.** Nothing pushes an unlock into a running world, and the platform raises no banner over it: a room award should `broadcast` its own event for the celebration, and a trophy case re-reads `list()` when it opens.
|
|
270
|
+
|
|
271
|
+
## `Helix.wallet` / `Helix.marketplace` — LIX, Coins, and IWP
|
|
272
|
+
|
|
273
|
+
```ts
|
|
274
|
+
const { lix, coins } = await Helix.wallet.getBalance();
|
|
275
|
+
const off = Helix.wallet.onBalanceChanged((b) => updateHud(b)); // returns an unsubscribe fn
|
|
276
|
+
|
|
277
|
+
const result = await Helix.marketplace.purchaseProduct('coin-pack'); // stable world-product key
|
|
278
|
+
const item = await Helix.marketplace.purchaseDistributionKey('postcard', { quantity: 2 });
|
|
279
|
+
const listing = await Helix.marketplace.purchaseListing('listing-for-serial-12');
|
|
280
|
+
if (item.completed) refreshInventory(); // Granted | Claimed | AlreadyOwned
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
`PurchaseResult.status` is the canonical outcome: `Pending` · `Granted` · `Claimed` · `AlreadyOwned` · `InsufficientFunds` · `MaxPerUserReached` · `ProductInactive` · `ProductNotFound` · `NotInWorld` · `Unauthorized` · `RateLimited` · `Cancelled` · `Failed` · `Timeout` · `NotFound` · `PriceChanged`. `completed` is the convenience for the three that mean the player now owns it (`PriceChanged` is never completed: the displayed price went stale, nothing was charged, and the shell re-prompts at the fresh price). The shell raises the confirm popup and settles the purchase — the world reacts to the result and never handles money itself. `getListings(query?)` browses; `getPurchaseContext(ref)` returns product + live balance + eligibility (+ the product's `grants` effect list) in one call if you are building a custom confirm UI.
|
|
284
|
+
|
|
285
|
+
### Register the product BEFORE you write the code that buys it
|
|
286
|
+
|
|
287
|
+
A world can only sell what its creator **registered on it**: `helix.json` has no products block and nothing in world code can mint one, so `purchaseProduct` on a key nobody registered returns `ProductNotFound` at runtime. Register first — MCP `register_world_product`, or `helix product register <world-slug> --title … --type … --price … --key …` — then name that key in the world:
|
|
288
|
+
|
|
289
|
+
```ts
|
|
290
|
+
const result = await Helix.marketplace.purchaseProduct('speed_boost'); // buys pkey:speed_boost
|
|
291
|
+
if (result.status === 'Granted') applySpeedBoost(); // consumable: YOUR world applies the effect
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
- The **key is required and immutable** — a rename would break every build that names it — so choose it once at registration. A bare key is auto-prefixed `pkey:`. Internal product UUIDs are never purchase references.
|
|
295
|
+
- **The price lives in the registry only.** `purchaseProduct` sends no amount, ever; the shell shows the confirm popup, charges the LIX and settles server-side. `priceLix: 0` is a legitimate registration — a free **Claim** the popup confirms without spending.
|
|
296
|
+
- **Author a World Product's world-local fulfilment as `pass` and `currency` grants.** Several effects = a bundle; an empty array = a tip jar. World Products do not mint universal items. For portable ownership, publish an item and create a Marketplace/World distribution (`read_doc({ name: "items" })`). A permanent-pass-only product is capped at 1; add a timed pass or currency to make it genuinely re-buyable.
|
|
297
|
+
- Registration does **not** require the world to be published — only the all-worlds creator listing filters to Published, so a product registered on a draft world exists and lists fine by slug. **Buying** does need a live world (Published or Unlisted): until then the product is registered and unbuyable.
|
|
298
|
+
|
|
299
|
+
### `Helix.purchases` — read and spend what fulfilment granted
|
|
300
|
+
|
|
301
|
+
```ts
|
|
302
|
+
const ent = await Helix.purchases.getEntitlements();
|
|
303
|
+
// { v: 1, passes: { vip: { since, expiresAt, active } }, balances: { coin: 740 }, owned: { 'starter-pack': 1 } }
|
|
304
|
+
if (ent.passes.vip?.active) unlockLounge();
|
|
305
|
+
|
|
306
|
+
const spent = await Helix.purchases.consume('potion', 1); // server-atomic, floor-at-zero
|
|
307
|
+
if (spent.applied) drinkPotion(); else offerShop(spent.balance);
|
|
308
|
+
|
|
309
|
+
Helix.purchases.onEntitlementsChanged(async () => render(await Helix.purchases.getEntitlements()));
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
- **Server-held, backend-written.** Entitlements are the durable projection of every settled grants purchase in THIS world for THIS player. A world can read them and spend balances — it can never write them, so never mirror paid goods into client state you trust.
|
|
313
|
+
- `consume(code, amount)` is **atomic and floor-at-zero**: `applied: false` with `reason: 'insufficient'` means the balance was left untouched. Each call is one spend (retries of the same call are replay-safe; a new call is a new spend).
|
|
314
|
+
- `onEntitlementsChanged` fires after the shell settles a purchase — it carries no payload, re-read with `getEntitlements()`. Pass expiry is evaluated at read time via `active`.
|
|
315
|
+
- Preview mode (no shell) resolves empty entitlements and an insufficient consume — display data never breaks the world loop.
|
|
316
|
+
- **Multiplayer: the room reads the same entitlements, and a settled purchase reaches your rules.** The shell settles the sale, then the platform forwards the backend's receipt to the room the buyer occupies — **no world code**, including on the `getPurchase` recovery read after a reload — firing a `{"when":{"on":"purchase"}}` rule with `self` = the buyer and `purchase.productKey` / `purchase.purchaseId` readable inside it. One purchase fires it **exactly once**, platform-enforced: a re-forward, a rejoin, a tab that died mid-sale and a second instance of the world all collapse to one firing. Rules read the buyer's entitlement snapshot with `{"op":"hasPass","passKey":"vip","of":"self"}` / `{"op":"balanceOf","code":"coin","of":"self"}` (O(1), refreshed before that purchase rule runs) and spend with `{"do":"consume","code":"coin","amount":10,"player":"self"}` — the same atomic, floor-at-zero backend spend as the call above. Grammar + worked examples: `multiplayer-logic` §20.
|
|
317
|
+
- **Entitlements are the durable truth for anything paid for** (passes, balances, owned). Read them where you need them — client or rule — and never copy one into client state, a playerVar or a roomVar: a session-scoped mirror resets while the entitlement does not, and the two then disagree about money.
|
|
318
|
+
|
|
319
|
+
## `Helix.inventory`
|
|
320
|
+
|
|
321
|
+
```ts
|
|
322
|
+
await Helix.inventory.hasItem(itemId); // works across worlds and creators — the VIP / season-pass basis
|
|
323
|
+
await Helix.inventory.getQuantity(itemId);
|
|
324
|
+
await Helix.inventory.getMyItems(); // InventoryItem[]
|
|
325
|
+
await Helix.inventory.equipItem(itemId); // owned cosmetics
|
|
326
|
+
Helix.inventory.onInventoryChanged(() => refresh());
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
## `Helix.camera` — the world camera's cloud side
|
|
330
|
+
|
|
331
|
+
```ts
|
|
332
|
+
if (Helix.camera.available()) { // false in preview; capture still works, save no-ops
|
|
333
|
+
const blob = await Helix.camera.capture(canvas, { aspect: 'landscape' }); // render in the SAME tick
|
|
334
|
+
const photo = await Helix.camera.savePhoto(blob, { caption: 'Summit' }); // null in preview, never a fake success
|
|
335
|
+
}
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
Photos land in the player's phone Gallery on every device, tagged with the world (the shell stamps the name authoritatively). Requires `camera.capture`. `captureCanvas` is re-exported for non-camera use.
|
|
339
|
+
|
|
340
|
+
## `Helix.presence` — rich presence
|
|
341
|
+
|
|
342
|
+
```ts
|
|
343
|
+
await Helix.presence.setActivity({ state: 'Racing — lap 3/5', party: { current: 3, max: 8 } });
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
Enriches the baseline "In [World]" shown to the player's friends. Server-authoritative by design: it posts as the player's own session, so a world can only ever set activity for the local player, in the world they are actually in. Governed by the player's privacy settings; text is length-capped, rate-limited and moderated server-side. Returns `false` (never throws) for guests, outside a shell, or when rejected.
|
|
347
|
+
|
|
348
|
+
## `Helix.notify` / `Helix.prompts` / `Helix.device` — shell UI
|
|
349
|
+
|
|
350
|
+
```ts
|
|
351
|
+
Helix.notify.success('Checkpoint reached'); // also .failure / .notification / .message
|
|
352
|
+
Helix.prompts.set({ id: 'door', title: 'Open the vault', input: { key: 'E' }, state: 'available' });
|
|
353
|
+
Helix.prompts.clear('door');
|
|
354
|
+
Helix.device.openTablet(); // or openPhone()
|
|
355
|
+
Helix.device.getState(); // { open, form? } — is the Helix OS overlay up?
|
|
356
|
+
const off = Helix.device.onStateChanged((s) => { // fires on open/close edges (replayed after handshake)
|
|
357
|
+
if (s.open) pauseAmbience(); else resumeAmbience(); // the supported way to duck/pause under the overlay
|
|
358
|
+
});
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
These render in the platform's own chrome, so they look native and stay clear of your HUD. The device
|
|
362
|
+
open/close pose also replicates automatically — every replica shows the phone-in-hand stance with no
|
|
363
|
+
world code beyond passing `helix` into `CharacterMultiplayer.create`.
|
|
364
|
+
|
|
365
|
+
## `Helix.analytics`
|
|
366
|
+
|
|
367
|
+
```ts
|
|
368
|
+
await Helix.analytics.track(eventName, { /* input */ });
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
Ships events to the platform collector. Use the platform's event vocabulary rather than minting your own strings.
|
|
372
|
+
|
|
373
|
+
## Local development — `helix dev`, the simulated shell
|
|
374
|
+
|
|
375
|
+
```bash
|
|
376
|
+
npm run build # `helix dev` serves the BUILT bundle, not the vite dev server
|
|
377
|
+
helix dev # ./dist inside a simulated shell — no login, no backend, no room
|
|
378
|
+
helix dev ./my-world --port 4180 --fresh # another dir, another port, a brand-new simulated player
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
`helix dev [dir] [--port <n>] [--reset] [--fresh]` runs your world's built bundle (`<dir>/dist`) inside a HELIX shell simulated on your own machine, entirely offline. The shell page and the world are served from two local origins, so your world meets production's cross-origin `postMessage` semantics rather than an accidental same-origin pass. The page carries a permanent **SIMULATED** banner plus a debug menu, and every answer is console-logged with a `[helix dev · SIMULATED]` prefix. The mode is called **simulated**, never "test" — nothing here spends or earns anything real.
|
|
382
|
+
|
|
383
|
+
**What it simulates** — the established World Product shell lane: `purchaseProduct`, listings/context, entitlements, consume, wallet and inventory. Its confirm dialog covers Buy/Claim, cancel, replay and injected failure states. Universal-item `purchaseDistributionKey` / `purchaseDistribution` / `purchaseListing` require the real shell until the simulator ships registered distribution fixtures; it must return a clear unavailable result rather than inventing supply, serials or receipts.
|
|
384
|
+
|
|
385
|
+
**What it refuses**, with a clear error rather than a plausible fake: the four `Helix.avatar` methods and `Helix.camera.savePhoto` — ``<method> is not simulated in `helix dev` — it needs the real asset pipeline.`` Those ride real asset pipelines, and a fake would teach your world the wrong shape.
|
|
386
|
+
|
|
387
|
+
**It enforces production's caps, loudly** — 30 data-store writes/min per player and 600/min per world (`rate-limited`), 120 consumes/min, 256 KB per value, and the real key-policy refusals (`session-foreign-key`, `session-sub-key`, `platform-reserved`, …). A world that trips a cap locally trips it live; a simulator more permissive than production only manufactures false positives.
|
|
388
|
+
|
|
389
|
+
**State persists across reloads** — `.helix/dev-state.json` in the world dir (git-ignored) holds the simulated player, which is the only way a save is testable at all. `--fresh` mints a new player (the first-run path); `--reset` wipes. Products seed from an optional `.helix/dev-products.json`, shaped like the registration you will make for real:
|
|
390
|
+
|
|
391
|
+
```jsonc
|
|
392
|
+
{ "products": [
|
|
393
|
+
{ "key": "coin-pack", "title": "Coin Pack", "priceLix": 25,
|
|
394
|
+
"grants": [{ "kind": "currency", "code": "coin", "amount": 500, "display": "wallet" }] }
|
|
395
|
+
] }
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
**Achievements UNLOCK locally.** Seed them from an optional `.helix/dev-achievements.json`, registration-shaped and validated with the **same rules** `helix achievement register` / `create_achievement` applies (closed criteria vocabulary, the two datastore key templates, the dot-path `field`, `unlockMode` coherence) — a seed that boots is the same JSON you will register for real, and a row registration would refuse fails the boot instead of silently never unlocking:
|
|
399
|
+
|
|
400
|
+
```jsonc
|
|
401
|
+
{ "achievements": [
|
|
402
|
+
{ "key": "summit-club", "name": "Summit Club", "points": 25,
|
|
403
|
+
"criteria": { "signal": "datastore", "op": "gte", "value": 3,
|
|
404
|
+
"key": "player:{userId}", "field": "stats.summits" } },
|
|
405
|
+
{ "key": "room-badge", "name": "Room Badge" } // no criteria ⇒ unlockMode "room": debug-menu award only
|
|
406
|
+
] }
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
Unlocking mirrors production's poke → re-derive model, per signal:
|
|
410
|
+
|
|
411
|
+
| Signal | Locally fed by | Latency |
|
|
412
|
+
|---|---|---|
|
|
413
|
+
| `datastore` over `player:{userId}` | every `Helix.dataStore.set` — the real gameplay loop | ~3 s debounce, as production |
|
|
414
|
+
| `datastore` over `mp:player:{userId}` | debug menu **Set MP player var** (writes the room's `{v, vars}` envelope by hand — no rules run) | immediate |
|
|
415
|
+
| `iwp` (`count` \| `lix`) | Granted/Claimed settles of seeded world products (never `AlreadyOwned`, never marketplace listings) | ~3 s debounce |
|
|
416
|
+
| `playtime` | accrues 15 s/15 s while the shell page is open, plus debug **Add 5 min playtime** | immediate on tick |
|
|
417
|
+
| `analytics` | debug **Bump analytics event** ONLY — `Helix.analytics.track` never reaches a shell, so real events cannot flow locally | immediate |
|
|
418
|
+
|
|
419
|
+
Measurement is **fail-closed** exactly as production's: a missing document or field, a stringified `"12"`, or an array in the dot-path measures `null` and never unlocks — `helix dev` is the first place you can actually watch a wrong-typed leaf fail. Earned is **add-only** (achievements are soulbound; deleting evidence never revokes), a boot-time pass mirrors the registration backfill (evidence that already qualifies unlocks immediately), and no event is pushed to your world — poll `Helix.achievements.list()` / `mine()` to observe an unlock, exactly as on the portal. The host page shows a SIMULATED unlock toast; the debug menu adds **Award achievement** (refuses a key outside the seeded registry), **Print achievement progress** (measured-vs-target per row), and **Reset earned achievements** for re-testing.
|
|
420
|
+
|
|
421
|
+
**From the seed to the real registry.** After the local pass, register each row with `register_achievement` / `helix achievement register` — same values, two shape differences: the seed stores criteria NESTED (`criteria: { signal, op, value, key, field, metric }` — the stored shape the backend keeps), while the tool flattens `key`/`field` to `criteriaKey`/`criteriaField` (the CLI: `--criteria-key`/`--criteria-field`) because `key` is already the badge's own slug; and real registration REQUIRES a 2D icon file the seed never needs (generate one with `generate_image`; blank or sub-16px placeholders are refused). Products go the same way: the `.helix/dev-products.json` rows are `register_world_product` rows. Registration works against a Draft row (`create_world` first on a brand-new project) and **takes effect on a live world immediately — no republish**: products become buyable the moment the world is live, and achievement registration runs its backfill pass at once. Confirm with `list_world_products` / `list_achievements` — the only proof a key really exists.
|
|
422
|
+
|
|
423
|
+
**Never import the dev shell in world code.** `@hypersoniclabs/helix-sdk/dev-shell` is the CLI's half of `helix dev`, not a world API — and `checkBundle` refuses to publish any bundle containing it, precisely so a simulated shell can never ship inside a world.
|
|
424
|
+
|
|
425
|
+
**The debug menu is the reason to run it** — none of this is reachable against real services without hand-editing a database. *Grant All Entitlements* · *Grant One* · *Force Remove* · *Print Owned* · **Purchases Always Fail** (cycles `InsufficientFunds` → `PriceChanged` → a throw that leaves the purchase `Pending`) · *Reset Player Document* · **Force Save Failure** · *Exhaust Consume Fence* · a signed-in toggle · a wallet setter. Drive your world through each one: failure paths are where worlds actually break, and a happy-path pass never executes them.
|
|
426
|
+
|
|
427
|
+
**Everything a world only READS has a debug-menu writer.** Several surfaces are written by the room or the backend in production, so with no room they would sit empty forever — each has a stand-in that writes it by hand, loudly: **Seed leaderboard score** (declared boards are room-written live; a world calling only `leaderboard.top` renders an empty board until you seed entries — any player name, `source: "server"`, board shape rules kept), **Set data-store doc** (ANY key including the ones a session may not write: world-global keys, `mp:world:*` shared room state, the reserved namespaces — the session-side key policy stays enforced for the world itself), **Set MP player var** (the `mp:player:{userId}` persistent-var envelope), **Award achievement**, and **Push shell event** (`avatar-changed`, `equipment-changed`, `overlay-open`/`overlay-closed`). If your world renders something and it stays blank under `helix dev`, find its writer in this list before suspecting your code.
|
|
428
|
+
|
|
429
|
+
**What it cannot prove** — read this before trusting a green local run:
|
|
430
|
+
|
|
431
|
+
- that a product or achievement is actually **registered**. Nothing local can: use `list_world_products` / `list_achievements` against the real backend, and `create_world` first if the world row does not exist yet.
|
|
432
|
+
- that a purchase **settles** — no charge, no escrow, no fulfilment, no receipt.
|
|
433
|
+
- anything **room-lane**: DSL rules, the `consume` / `save` / `saveRoom` verbs, persistent playerVars, the real `awardAchievement` effect, or multiplayer of any kind. `helix dev` simulates the shell lane only — the debug menu's award and MP-var controls stand in for two room-lane *outcomes* without running any rules.
|
|
434
|
+
- achievement **rarity** (`rarityTier` is always `unranked`, `earnPct` null — no population to rank against), or that an `analytics` criteria's `eventName` is in the platform enum (backend-validated at registration only).
|
|
116
435
|
|
|
117
|
-
`
|
|
436
|
+
End-to-end settlement against real services still runs on the engine repo's dev stack (`dev-stack.mjs up iwp-shop`) — see `read_doc({ name: "purchases" })` §9. When `helix dev` and the dev stack answer differently, that is signal, not noise: one of them is wrong, and it is worth knowing which.
|