# @hypersoniclabs/helix-sdk

The **HELIX Instant browser SDK** — the runtime a world embeds to talk to the HELIX shell
(the play page that iframes it). v0.1 provides identity; later versions add multiplayer, voice,
wallet, and inventory.

A world is a static bundle that runs inside a **sandboxed iframe**. The shell hands it a
short-lived, world-scoped session token (minted by the backend at
`POST /api/v1/instant-worlds/:worldId/session`) over `postMessage`. The SDK wraps that handshake.

## Install

```bash
npm install @hypersoniclabs/helix-sdk
```

## Usage

```ts
import { Helix } from '@hypersoniclabs/helix-sdk';

const { embedded, world, user, runtimeFidelity } = await Helix.init(); // call once, at startup

// Pass the versioned intent to the engine's canonical fidelity resolver. The
// SDK never detects the device or derives subsystem budgets itself.
engine.resolveFidelity(runtimeFidelity);

if (Helix.auth.isAuthenticated()) {
  const me = await Helix.auth.getUser(); // { id, username, displayName }
}

// Raise the shell's login overlay (no-op reload — world state is preserved):
loginButton.onclick = async () => {
  try {
    const user = await Helix.auth.requestLogin();
    console.log('signed in as', user.username);
  } catch {
    /* dismissed or unavailable */
  }
};

Helix.auth.onAuthChanged((user) => updateUi(user)); // login + logout

// Ask the HELIX shell to render native status notifications.
Helix.notify.success('Prize claimed', 'Welcome Chip added to inventory');
Helix.notify.failure('Purchase failed', 'Not enough LIX');
Helix.notify.notification('Quest updated', 'Return to the VIP lounge');
Helix.notify.message('Nova', 'Meet me by the arcade machines');

// Publish a current interaction prompt without tying your world to a renderer.
Helix.prompts.set({
  id: 'fortune-arcade.vip',
  title: 'VIP Room',
  description: 'Buy a keycard to enter.',
  input: { key: 'E', label: 'Interact' },
  actionLabel: 'Buy keycard',
  state: 'available',
});
Helix.prompts.clear('fortune-arcade.vip');
```

When the world is opened directly (e.g. a local `vite dev` server with no shell), `init()` resolves
with `embedded: false` and all identity APIs return `null`/`false` — so the same build runs locally
and embedded.

### API

- `Helix.init(): Promise<{ embedded, world, user, runtimeFidelity }>` — handshake; call once before
  anything else. `runtimeFidelity` is the validated versioned presentation intent, or `null` for an
  older shell or malformed payload; in that case the engine retains its Auto fallback.
- `Helix.auth.getUser(): Promise<HelixUser | null>`
- `Helix.auth.isAuthenticated(): boolean`
- `Helix.auth.requestLogin(): Promise<HelixUser>` — resolves on login, rejects on dismiss/unavailable.
- `Helix.auth.onAuthChanged(fn): () => void` — fires on login and logout; returns an unsubscribe fn.
- `Helix.notify.show({ kind, title, message?, timeoutMs?, actionLabel?, actionHref? }): boolean`
- `Helix.notify.success|failure|notification|message(title, message?): boolean` — returns `false` when
  the world is running standalone without a shell.
- `Helix.prompts.set({ id, title, description?, input?, actionLabel?, state?, priority? }): boolean`
- `Helix.prompts.clear(promptId?): boolean`
- `Helix.getSessionToken(): string | null` — the world-scoped token, for advanced direct API calls.
- `Helix.hasLeft(): boolean` — `true` once the player has left this world through the shell (Exit,
  browser Back). Room joins are refused from then on. See "When the player leaves the world" below.
- `Helix.onLeave(fn): () => void` — called once, synchronously, when the player leaves this world;
  returns an unsubscribe fn. Not called for a subscriber added after the leave: read `hasLeft()`.

### Economy & items (v2, shell-mediated)

All of these are **server-authoritative**: the world *requests*, the shell (which holds the player's
session) settles with the backend and renders the purchase popup. Reads degrade to empty defaults
when not embedded; a purchase returns `Unauthorized` (never a fake success).

```ts
const { lix, coins } = await Helix.wallet.getBalance();
Helix.wallet.onBalanceChanged((b) => updateHud(b));

// World code names a registered distribution key; the shell shows Claim/Buy.
const res = await Helix.marketplace.purchaseDistributionKey('harbor_postcard', { quantity: 2 });
if (res.completed) displaySouvenirs(res.fulfilledQuantity ?? 0); // Granted | Claimed | AlreadyOwned
else showToast(res.status);                            // InsufficientFunds | Cancelled | …

await Helix.marketplace.purchaseProduct('golden_key'); // stable world-product key -> pkey:golden_key
await Helix.marketplace.purchaseDistribution('public_distribution_id'); // exact public primary route
await Helix.marketplace.purchaseListing('listing_for_serial_12');        // exact secondary serial
const items = await Helix.marketplace.getListings({ kind: 'home_item' });

const context = await Helix.marketplace.getPurchaseContext('dkey:harbor_postcard', { quantity: 2 });
// The shell/API own price, edition, fixed supply, remaining count and next serial.

// Ownership gating — works across worlds & creators (VIP / season passes).
if (await Helix.inventory.hasItem('vip_pass_001')) openBackstage();
const owned = await Helix.inventory.getMyItems();
await Helix.inventory.equipItem('vip_hat_001');
Helix.inventory.onInventoryChanged(refreshInventory);
```

- `Helix.wallet.getBalance()` / `onBalanceChanged(fn)`
- `Helix.marketplace.purchaseDistributionKey(key, options?)` / `purchaseDistribution(id, options?)` / `purchaseListing(id)`
- `Helix.marketplace.purchaseProduct(productKey)` for world-local products/effects; it does not mint universal items or accept an internal product UUID
- `Helix.marketplace.getListings(query?)` / `getPurchaseContext(ref, options?)`
- `Helix.inventory.hasItem(id)` / `getQuantity(id)` / `getMyItems()` / `equipItem(id)` / `onInventoryChanged(fn)`

The SDK mints a stable idempotency key per purchase (reused across the popup's retries) so a dropped
network can never double-charge. Item-definition ids are deliberately not purchase references: use only
`dkey:`, `distribution:` or `listing:` through the typed methods above. See the backend `/api/v1/iwp/*`
endpoints for the settlement layer.

Collectible acquisition results expose `resaleEligibleAt`; exact listing context exposes a separate
seller `holdUntil`. The unified relisting cooldown is 24 hours after every acquisition. Basic requires
email; `verified` means unique verified mobile plus good standing. The type also accepts reserved
`identity_verified` and future `business_verified`. Payout holds are 3 days base and may be risk-extended
up to 15 days; never treat the relisting timestamp as payout availability.

### Interaction prompts

`Helix.prompts` is intentionally engine-agnostic. A Three.js, Phaser, Unity WebGL, or plain DOM world
can all publish the same contract: a stable prompt id, display text, optional keyboard/gamepad labels,
and a coarse state (`default`, `available`, `locked`, or `busy`). The shell decides how to render it.

### XR signals (engine ↔ shell)

Two document events connect an engine's XR module and the shell without either importing the other.
Both are sticky: the last detail is also recorded on `document`, so a late listener starts from the
current state.

- `XR_SESSION_EVENT` (`'helix:xr-session'`) — dispatched by the engine XR module on every immersive
  session change, `detail: { state, reason?, source: 'humanoid-character' }`, sticky at
  `document.__helixXrSession`. After `Helix.init()` the SDK forwards it to the shell as the
  `helix:xr-session` message (and reports a sticky value once at init). Other sources are ignored.
- `XR_INTERACTION_PROMPT_EVENT` (`'helix:interaction-prompt'`) — dispatched by the SDK on every
  `Helix.prompts.set/clear`, `detail: { prompt: { id, label, state? } | null, source: 'helix-sdk' }`
  (`label` is the prompt's `title`), sticky at `document.__helixInteractionPrompt`, so the engine can
  draw the prompt in-scene. The shell messages are unchanged.

### Rich presence

Push a **live activity** for the local player that enriches the baseline "In [World]" their friends
already see (in the friends sidebar and on their profile) — e.g. `Racing — lap 3/5`. It's **dynamic**:
call it whenever the moment changes, no registration, no per-activity setup.

```ts
// Enrich presence with whatever the player is doing right now.
await Helix.presence.setActivity({
  state: 'Racing — lap 3/5',       // required, ≤128 chars — the line friends see
  details: 'Sunset Circuit',        // optional second line, ≤128 chars
  party: { current: 2, max: 8 },    // optional party sizing
  // image: 'my-mode-icon',         // optional; omitted → the world's thumbnail (zero-config)
  // joinable: true,                // hint only; joining an instance ships with the MP runtime
});

// e.g. drive it off game state
raceEvents.on('lap', (n, total) =>
  Helix.presence.setActivity({ state: `Racing — lap ${n}/${total}` }),
);

// Back to plain "In [World]". Also happens automatically when the player leaves.
await Helix.presence.clearActivity();
```

- `Helix.presence.setActivity(activity)` → `Promise<boolean>` — `true` on success; `false` for guests,
  outside a shell, when not in a world, or when rejected. **Never throws** into your game loop.
- `Helix.presence.clearActivity()` → `Promise<boolean>`.

**Server-authoritative (by design).** `setActivity` posts to the authenticated presence endpoint using
the player's own session. The server sets activity **only for that player** and **only for the world
they're currently in** — a world can never write another player's status, or a world they aren't in.
This is the interim of the "the world's server sets it" model: when the multiplayer room runtime lands
it will set activity server-to-server after authenticating the player into its instance; the API and
validation are already shaped for that. Backend: `POST` / `DELETE /api/v1/users/me/presence/activity`.

**Privacy & safety.** Activity is governed by the player's presence privacy (visibility
`Everyone / Friends only / Nobody`, plus a *hide-activity* toggle) — if they've opted out, the server
never emits it, to anyone. Text is length-capped, rate-limited, and screened by the shared text
moderation layer server-side (a bad-actor world can't push junk into a friend's status card).

### Durable storage

`Helix.dataStore` is per-world key–value storage — the place for anything that must survive a
session. A player's own session may write exactly **one** key, `player:${user.id}`, so put the whole
save in that single JSON document; sub-keys, world keys and room state are written by the game
server. Reads are world-open point-gets, and `list` returns keys only — values are never handed out
in bulk.

```ts
await Helix.dataStore.set(`player:${user.id}`, { level: 4, coins: 120 });
const save = await Helix.dataStore.get<Save>(`player:${user.id}`);       // null when unset

// Optimistic concurrency — read the version, hand it back, and a losing race is refused
// instead of silently clobbering the other writer.
const entry = await Helix.dataStore.getEntry<Save>(`player:${user.id}`); // { value, version } | null
const next = { ...(entry?.value ?? fresh), coins: (entry?.value.coins ?? 0) + 10 };
await Helix.dataStore.set(`player:${user.id}`, next, { expectedVersion: entry?.version ?? 0 });
```

- `Helix.dataStore.get<T>(key)` → `Promise<T | null>` — `null` when unset, and in preview mode.
- `Helix.dataStore.getEntry<T>(key)` → `Promise<{ value: T; version: number } | null>`.
- `Helix.dataStore.set<T>(key, value, { expectedVersion? })` → `Promise<void>` — `expectedVersion: 0`
  asserts create-only. Omit it and the write is last-writer-wins.
- `Helix.dataStore.delete(key, { expectedVersion? })` → `Promise<void>`.
- `Helix.dataStore.list(prefix?, { cursor?, limit? })` → `Promise<{ keys: string[]; nextCursor?: string }>` —
  keys only, one page (≤100) per call; pass `nextCursor` back as `cursor` for the next page.

There is no multi-key transaction: design so no two keys must move together. In preview mode reads
return `null`/empty and writes no-op.

### Leaderboards

`Helix.leaderboard` is keep-best per player, decided atomically by the backend — a worse score is a
harmless no-op, so resubmitting is always safe. Each board is ONE bounded document keeping its
declared top `size` (up to 100): scores below the cutoff are not stored, and `best` comes back
`null` when the player is off the board. Boards are declared per build (one must be named `main` —
the world's default board) and are usually written by the authoritative room; a session submits
only for **itself**.

```ts
const { best, updated } = await Helix.leaderboard.submit('bestTime', lapMs, { order: 'asc' });
if (!updated) showToast(best === null ? 'Didn\'t place' : `Your record of ${best} stands`);

const { entries, truncated } = await Helix.leaderboard.top('bestTime', { limit: 10, order: 'asc' });
for (const e of entries) addRow(e.displayName, e.score);
```

- `Helix.leaderboard.submit(board, score, { order? })` → `Promise<{ best, updated }>` — `order`
  (`'desc'` default, `'asc'` for times) only decides direction while the board is new.
- `Helix.leaderboard.top(board, { limit?, order? })` → `Promise<{ entries, truncated }>`; `limit`
  ≤ 100. Each entry is `{ subject, displayName, score, source, updatedAt }`, where `source` is
  `'session'` (client-claimed) or `'room'` (written by the authoritative room).
- Both need a shell — in preview mode they reject rather than fake a board.

### Achievements

`Helix.achievements` is a **read-only** view of the achievements registered to **this world** and
whether the viewer has earned them — the world id comes from the shell, so a world can never
enumerate the viewer's earns anywhere else. Awarding is server-side only: write-time criteria, or
the multiplayer room's `awardAchievement` rule. There is no award/claim method here by design.

```ts
const { achievements, total, earned } = await Helix.achievements.list();
setProgressLabel(`${earned}/${total} earned here`);
for (const a of achievements) addBadge(a.name, a.iconUrl, a.earned, a.points);

const mine = await Helix.achievements.mine();   // the earned-only subset for this world
```

- `Helix.achievements.list()` → `Promise<{ achievements, total, earned }>` — every achievement of
  this world; `total` is the world's count, `earned` how many the viewer holds (0 when signed out).
- `Helix.achievements.mine()` → `Promise<{ achievements }>` — the earned ones only. Empty for a
  signed-out player (they have earned nothing).
- Each row is `{ id, key, name, description, points, hidden, earned, rarityTier, earnPct, iconUrl }`.
  A **hidden** achievement the viewer has **not** earned arrives **redacted** — the shell replaces
  `name`/`description` with placeholders and blanks `key`/`iconUrl`, so neither a hidden name nor the
  slug that spells it reaches world code. Identify such a row by `id`.
- Preview mode (no shell) resolves neutral empties rather than rejecting — this is display data and
  must never break the world loop. A real backend failure still rejects (see below).

**Failures carry a code.** A rejected call throws a `HelixRequestError` whose `code` classifies it,
so a world branches on the code rather than matching error prose: `version-conflict` (stale
`expectedVersion` — re-read and re-apply), `rate-limited`, `session-foreign-key` (another player's
key), `session-sub-key` (a key under your own — use the one document), `session-server-key`,
`platform-reserved` (`helix:*`), `prefix-reserved` (`mail:*`, `mp:world`, `leaderboard:*`),
`counter-server-only`, `leaderboard-self-only`. `code` is `undefined` when the shell is older than
the code it would have sent, or the failure has no classification.

### Multiplayer rooms and room types

```ts
import { Helix, HelixRoomJoinError } from '@hypersoniclabs/helix-sdk';

// The world's default room. No options, no request body — unchanged from every earlier SDK.
let room = await Helix.multiplayer.joinRoom();

// The room hands this player a ticket when one of its rules opens a ticketed room for them.
room.onTicket(async (ticket) => {
  // ticket: { roomType, ticketId, instanceId, expiresAt }
  // expiresAt (Unix epoch ms) is when the TICKET stops being redeemable, not when the room ends.
  try {
    room = await Helix.multiplayer.switchRoom({ ticket: ticket.ticketId });
  } catch (e) {
    // The old room was left before the join was tried, so you are in no room now (see the table below).
    // joinRoom() returns to the match if it is still reachable, otherwise to the default room.
    if (e instanceof HelixRoomJoinError) console.warn('could not enter the match:', e.code);
    room = await Helix.multiplayer.joinRoom();
  }
});
```

A world declares extra **room types** in its manifest (`multiplayer.roomTypes`). The top-level
`multiplayer` block stays the default type. An `open` type is joined by name; a `ticketed` type is
entered only by redeeming a ticket the platform issued to that player.

- `Helix.multiplayer.joinRoom(worldId?, options?)` → `Promise<HelixRoom>`. `options.roomType` joins an
  open type and `options.ticket` redeems a ticket; pass one or neither, never both. With neither the
  join is the default room and nothing about it changed: no request body, the same join options.
- `room.roomType` is the room's type (`null` for the default room) and `room.instanceKey` the ticketed
  instance id (`null` otherwise).
- `room.onTicket(cb)` → unsubscribe. Tickets are not replayed, so subscribe right after joining.
  Redeem a ticket before `ticket.expiresAt` (a few minutes); after that the redeem fails with
  `ticket-invalid`. The room it leads to may run much longer than the ticket lasts.
- `Helix.multiplayer.switchRoom(target, options?)` → `Promise<HelixRoom>`. `target` is `{ ticket }`, or
  `{ roomType?, preferInstanceId? }` (omit `roomType` for the default room). It leaves the current room
  with a consented close, then joins the target. `preferInstanceId` is tried first and is not strict: if
  that room is gone or full, the hop matchmakes into the same type instead. `options` takes the same
  per-join fields as `joinRoom` (`uploadHz`, `apiBaseUrl`, `reservationPath`).

**What a failed hop costs.** Every `switchRoom` rejection is a `HelixRoomJoinError`. The target and
the session are checked first, so `left`, `invalid-target`, `not-ready`, `switch-in-progress` and
`join-in-progress` reject with you still in the room you were in. Any other code means the old room
has already been left and you are in no room. `Helix.multiplayer.currentRoomId()` tells you which.
`joinRoom` throws the same error type when the platform refuses the request. Branch on `error.code`:

| `code` | Meaning | Still in the old room? |
| --- | --- | --- |
| `left` | The player has left this world (`Helix.hasLeft()`). Checked before anything else; nothing is sent. Also thrown by `joinRoom`. | the call changed nothing (the leave itself already left the room) |
| `invalid-target` | Both `roomType` and `ticket`, or an empty id. | yes |
| `not-ready` | No `Helix.init()`, no session, no world id, or no API base. | yes |
| `switch-in-progress` | A `switchRoom` is still running (also thrown by `joinRoom` during a hop). | yes |
| `join-in-progress` | A `joinRoom` is still running: `switchRoom` was called under it, or a second `joinRoom` was. One join runs at a time. | yes |
| `unknown-room-type` | The active build declares no such type, or room types are off for the world. | no |
| `ticket-required` | The type is ticketed; it needs a ticket. | no |
| `ticket-invalid` | The ticket is unknown, expired, already used, or was issued to another player. | no |
| `instance-closed` | The ticketed room has closed: the match is over. Not retried. | no |
| `rejoin-failed` | Resuming a ticketed room failed. See "Reloads" below for what is kept. | no |
| `room-type-unavailable` | The platform reserved a default room for a typed request (it predates room types). | no |
| `cancelled` | `Helix.multiplayer.leave()` (or the shell's leave) arrived during the hop. Also thrown by a `joinRoom` that was left while joining. Any seat already taken was released. | no |
| `join-failed` | Anything else. `error.cause` holds the original failure when there is one. | no |

The codes come from the backend's own error `code`, not from the HTTP status, so an unrelated 404 is
`join-failed` and not `ticket-invalid`.

**Which errors are `HelixRoomJoinError`.** Every `switchRoom` rejection is one. From `joinRoom`, these
are:

- the platform refusing the join request (any non-2xx answer from the join endpoint). This used to be a
  plain `Error`; the message is unchanged, `error.name` is now `HelixRoomJoinError`, and `error.code` /
  `error.status` are available. This is the only change to a default join.
- `left`, `invalid-target`, `room-type-unavailable` and `switch-in-progress`;
- a failed resume of a ticketed room (`instance-closed`, `rejoin-failed`).

These `joinRoom` failures are still a plain `Error`, as before:

- a refused strict `?instance=` join. Its message is `could not join instance <id> — it may be full,
  locked, or no longer exist (<server's reason>)`, and the shell is told through
  `helix:multiplayer-join-error`;
- a missing precondition (no `Helix.init()`, no session, no world id, no API base, already in a room, a
  bad `reservationPath`);
- the room connection itself failing after the platform reserved a seat (the Colyseus error is thrown
  as is).

**Do not auto-rejoin on a leave you caused.** `room.onLeave((code, reason) => …)` now passes a
`reason`:

| `reason` | Meaning |
| --- | --- |
| `'switch'` | You called `switchRoom`. The hop is already joining the next room; do nothing. |
| `'leave'` | You called `room.leave()`, or the player used the shell's Exit. |
| `'dropped'` | Kicked, the room closed, or the connection was lost for good. Rejoin here if you want to. |

A world that rejoins on every `onLeave` would fight its own hop. `joinRoom()` during a hop is refused
with `switch-in-progress` and opens no second connection, but check `reason` rather than rely on that.

**When the player leaves the world.** The shell's Exit button and the browser's Back off the play page
(and, for some worlds, the play page being unloaded) reach the world as one message. The SDK then leaves
the room and voice and stops matchmaking and parties. A room handle only hears about this when there is
a room: `onLeave` with reason `'leave'`, or `cancelled` for a join in flight. A world that runs its own
room lifecycle can be between rooms at that moment, in a local bot match or waiting before its own next
rejoin attempt, and would then rejoin a room after the player has gone. The SDK covers that case
itself:

- `Helix.hasLeft()` turns `true` and every `Helix.onLeave` subscriber is called once, synchronously.
  The subscribers are a snapshot taken when the leave arrives: unsubscribing another subscriber from
  inside a callback does not stop it being called for this leave.
- From then on `joinRoom()` and `switchRoom()` reject with `left` before doing anything: no request,
  no connection. That holds whether or not a room was held when the leave came.
- It lasts for the rest of the world document. The shell never re-enters a world in the same document:
  entering again loads a new iframe, so a new SDK and a new handshake, and `hasLeft()` starts at `false`
  there. The one exception is the play page coming back from the browser's back/forward cache, which
  shows the same document again without a handshake. Then `hasLeft()` is `false` again and rooms can be
  joined. Matchmaking and parties are not restarted by that; they stay stopped until a new session.
- Only the shell that initialised the world can end it. A leave message that arrives before the
  handshake, or from another origin, is ignored for this purpose.

```ts
Helix.onLeave(() => game.stopEverything()); // the player is going; show nothing new

async function rejoinLater() {
  await wait(backoffMs);
  if (Helix.hasLeft()) return; // the player left while we waited
  try {
    room = await Helix.multiplayer.joinRoom();
  } catch (e) {
    // `left` if the leave came first, `cancelled` if it came while this join was in flight.
    if (Helix.hasLeft()) return;
    throw e;
  }
}
```

A world that never calls these sees no difference until the player leaves, and the SDK adds nothing for
it: no request, no timer, no listener. After a leave it holds one `pageshow` listener, for the
back/forward-cache case above.

`left` is a new member of the `HelixRoomJoinErrorCode` union. TypeScript code that switches over
`error.code` and ends in an exhaustive `never` check stops compiling against this version until it
handles `left`. That is a compile-time break only; nothing changes at run time for such code until a
leave actually happens.

**`leave()` cancels a join or hop in flight.** Three things follow from that:

- Do not call `Helix.multiplayer.leave()` from the old room's `onLeave` when `reason` is `'switch'`.
  It cancels the hop you just started: `switchRoom` rejects with `cancelled` and you end in no room.
- `joinRoom(); leave(); joinRoom()` without awaiting ends in no room. The first join rejects with
  `cancelled`, and the second is refused with `join-in-progress` because the first has not finished
  winding down. Await `leave()` before joining again.
- `leave()` waits for the cancelled call to finish, but for no longer than 3 seconds
  (`ROOM_LEAVE_TIMEOUT_MS`). If the join's request or the room handshake has still not answered by
  then, the SDK gives up on the call: `joinRoom` / `switchRoom` rejects with `cancelled` at that
  moment and `leave()` resolves with you in no room. You can call `joinRoom()` again as soon as
  `leave()` has resolved; it is no longer refused with `join-in-progress` while the old request hangs.
  The abandoned request is not aborted on the network. Its answer is ignored when it arrives, and a
  seat a late handshake delivers is released at once and never becomes your room.
- A `leave()` that cancels a join into a ticketed room also forgets that room. The SDK drops its
  record of the instance when `leave()` is called, and the cancelled join cannot write it back, so
  a page reload followed by a plain `joinRoom()` makes an ordinary join. It does not rejoin the
  match you left.

`cancelled` is a new member of the `HelixRoomJoinErrorCode` union. A `switch` over `error.code` that
ends in an exhaustive `never` check needs a case for it before it compiles against this version.

**A handle belongs to one room.** After a hop, or after you leave, the old handle is stale and cannot
act on the room that came after it. Its `leave()` resolves and does nothing. `sendState`, `sendAction`,
entity uploads and module/vehicle actions do nothing (the boolean ones return `false`), and
`requestDistributionCapability` and the physics-group calls reject. Its subscriptions stop firing and
new ones register nothing. `onLeave` fires once. Its ids and `state` / `vars` keep the last values that
room replicated. Use the handle `joinRoom` or `switchRoom` returned most recently.

**A leave cannot hang on a dead connection.** This holds for `room.leave()`,
`Helix.multiplayer.leave()`, the shell's Exit and a hop alike. On a healthy connection nothing changed:
the leave resolves when the server confirms it. If the room does not confirm within 3 seconds
(`ROOM_LEAVE_TIMEOUT_MS`), for example because the connection is dead but has not noticed, the SDK
detaches the room locally and resolves; a hop then carries on with the join. A connection already known
to be closed is detached at once. A room the SDK has left never reconnects afterwards: its
auto-reconnection is switched off, so it cannot hold a second seat behind your back.

**Reconnecting after a drop follows the room's grace.** When the connection drops, the room holds
the seat for 20 seconds (`ROOM_RECONNECT_GRACE_MS`, the room server's reconnection grace) and the SDK
reconnects on its own: `room.onDrop` fires, then `room.onReconnect` when the same seat is back. The
20 seconds start when the SERVER notices the drop. In the common drops the client notices first (a
wifi switch, a network change, the browser going offline) and the server only on its ping timeout,
up to 9 seconds later (`ROOM_SERVER_DROP_DETECTION_MS`: a ping every 3 s, the connection cut after 2
unanswered). So a seat can be resumed until about 29 seconds after the drop the client saw
(`ROOM_RECONNECT_WINDOW_MS`). Two timings changed in this version, on every room including the
default one:

- A drop in the first 5 seconds after the join is reconnected too. Before, Colyseus did not try at
  all in that window: `onLeave` fired at once with `'dropped'` while the room still held the seat.
- While the network stays down, the SDK stops trying once that window has passed. Before, Colyseus
  kept trying for about a minute (15 tries, up to 5 seconds apart), long after the seat was gone.
  Retries keep Colyseus's own backoff inside the window; none is started after it, and an attempt
  still connecting 2 seconds after it (`ROOM_RECONNECT_ATTEMPT_ALLOWANCE_MS`) is closed. The give-up
  is `room.onLeave(4003, 'dropped')`, between 29 and about 31 seconds after the drop. The window is
  measured on a monotonic clock, so changing the device's clock does not move it.

When the server is reachable and no longer holds the seat, it refuses the reconnection with 4003, and
`room.onLeave(4003, 'dropped')` fires at once, as before. Either way that is the moment to take a new
seat with `joinRoom()` (or a ticketed room's rejoin, which `joinRoom()` does for you). Each new drop
after a successful reconnect gets a fresh window. Nothing else about the default room changed.

**`?instance=` and room types.** The play URL's `?instance=` names a default-type room. A first default
`joinRoom()` still joins exactly that room or fails loudly, as before; the error now carries the
server's own reason instead of a guess that the room is full. A typed or ticket join ignores
it. A `switchRoom` back to the default type with no `preferInstanceId` uses it as the preferred room,
with the matchmaker fallback.

**Reloads resume the room you were in.** Each room type has its own reconnect slot in
`sessionStorage`. After a reload, a plain `joinRoom()` first resumes the typed room the tab was in, and
only then the default room, so a reload during a match returns to the match even though the URL still
names the lobby. Read `room.roomType` to see where you landed. Four details:

- Everything remembered is bound to the player it was remembered for (the World session's issuer,
  subject, world and guest flag). After a sign-in, a sign-out or an account switch, the next
  `joinRoom()` is an ordinary join: the other player's seat, typed room and ticketed instance are
  forgotten, never resumed. A token refresh for the same player keeps them.

- If the player picks a different server in the shell (a different `?instance=`), that choice wins: the
  typed room is forgotten and the default join to the new server happens.
- A ticketed room is remembered from the moment its ticket is redeemed. If the seat is gone, or the
  connection failed after the ticket was spent, `joinRoom()` asks the platform for a fresh ticket to
  the same instance. When the platform says the instance is closed (`instance-closed`) or is not yours
  (`rejoin-failed`, HTTP 403 or 404), it is forgotten and the next `joinRoom()` is an ordinary join.
  On a failure that says nothing about the match (`rejoin-failed` with `error.status` `null` for no
  answer, 5xx, 429, or 401 while the session refreshes) it is kept, and the next `joinRoom()` asks again.
- To stop returning to a remembered match, hop elsewhere: `switchRoom({})` goes to the default room and
  forgets it.

**Voice follows a hop.** If `Helix.voice` was joined to the room, `switchRoom` leaves it before the hop
and joins the new room's voice with the same push-to-talk key; the channel, mic mode and mutes carry
over. The new room handle is returned as soon as the room is joined. Voice re-joins in the background
and a voice failure is logged, not thrown. A `Helix.voice.join()` still connecting when the hop starts
is cancelled (it resolves `false`) and re-targeted at the new room. If the hop fails, voice stays
left, because there is no room to pair it with; call `Helix.voice.join()` after your next join. Voice
joined with an explicit `roomId` is left alone.

**What the shell is told.** The room status the SDK publishes to the shell
(`helix:multiplayer-state`) carries `roomType` for a typed room and `ticketed: true` for a ticketed
one. Both are absent for the default room, so that message is unchanged for worlds without room
types. The shell uses them to avoid offering a private ticketed room as a public join target. During
a hop the SDK publishes `null` and then the new room's state.

**Local dev.** `helix dev` has no room server, so `joinRoom()` does not work there. The dev shell can
answer the join table and mint a ticket for tests and the debug menu (`roomTypes` option,
`simulateRoomJoin`, `issueRoomTicket`, `rejoinRoomInstance`, `closeRoomInstance`). Refusals carry the
backend's `{ statusCode, code, message }` body. Real typed joins need the dev-stack.

### Matchmaking and parties

A world declares **queues** in its manifest (`matchmaking.queues`). Each queue names a ticketed room
type and the team layout. A player enqueues, the platform forms the match and opens the room, and
every matched player gets a room ticket to redeem with `switchRoom`.

```ts
import { Helix, HelixMatchmakingError } from '@hypersoniclabs/helix-sdk';

let room = await Helix.multiplayer.joinRoom(); // the lobby (default room)

// Subscribe once, at startup. This also picks up a ticket that survived a reload.
Helix.matchmaking.onStatus((s) => {
  // s: { status: 'none' | 'waiting' | 'matched', queueId?, waiting?: { players, needed }, match?, reason? }
  ui.showQueue(s.status === 'waiting' ? s.waiting : null);
  if (s.reason === 'heartbeat-lost' || s.reason === 'heartbeat-lapsed') ui.toast('Lost your place in the queue. Try again.');
  if (s.reason === 'member-absent') ui.toast('A party member went away. Queue again when they are back.');
});
Helix.matchmaking.onMatch(async (match) => {
  // match: { ticketId, instanceId, roomType, expiresAt } — redeem before expiresAt (Unix epoch ms).
  try {
    room = await Helix.multiplayer.switchRoom({ ticket: match.ticketId });
  } catch {
    room = await Helix.multiplayer.joinRoom(); // the hop failed after leaving the lobby: go back
  }
});

async function play() {
  try {
    await Helix.matchmaking.enqueue('duel'); // → { status: 'waiting', queueId: 'duel' }
  } catch (e) {
    if (e instanceof HelixMatchmakingError) ui.toast(e.code); // 'guests-cannot-queue', 'already-queued', …
  }
}

async function matchOver() {
  room = await Helix.multiplayer.switchRoom({}); // back to the default room
}
```

- `Helix.matchmaking.enqueue(queueId)` → `Promise<QueueStatus>`. In a party only the leader queues,
  and the whole party is queued together. `enqueue` and `cancel` calls run one at a time, in the order
  you made them. While a match still stands unredeemed (yours or a party member's) it rejects with
  `already-matched`, exactly as a late `cancel()` does: `error.match` is the match, the status is
  `matched` and `onMatch` fires if you had not been told yet. Redeem it or let it expire first. If
  `error.match` is `null`, the pending match is another party member's (`error.memberUserId`): someone
  in your party still has a match to redeem. Your own status does not change; try again once they have joined it or it
  has expired.
- `Helix.matchmaking.cancel()` → `Promise<void>`. Resolves when a waiting ticket was cancelled or there
  was none. A party member cancelling cancels the party's ticket. If the ticket has already matched,
  it rejects with `already-matched`: `error.match` is the match, the status is `matched` and `onMatch`
  fires, so redeem it. If the request gets no usable answer, the SDK asks the platform what happened
  before rejecting, so a match that formed in the meantime is still delivered.
- `Helix.matchmaking.status()` → `Promise<QueueStatus>`. Asks the platform now. What it finds is
  handled like a poll result: the listeners fire if it is news.
- `Helix.matchmaking.onStatus(cb)` → unsubscribe. Fires when the status changes, not on every poll.
  `waiting.players` counts the players currently eligible to be matched (every member of their party
  polled recently), so it can be lower than the number who pressed the button.
- **Why it ended.** A `none` carries a `reason` when one is known, and `onStatus` fires once per
  ending. The platform reports how your ticket in this world closed: `cancelled`, `timeout` (the
  queue's waiting limit), `heartbeat-lapsed` (nobody on the ticket polled for 15 s), `member-absent`
  (one party member did not poll for 60 s), `party-changed`, `build-changed`, `queue-removed`, or
  `replaced` (you queued in another world; a player has one waiting ticket), or `match-expired` (the
  match's room ticket ran out, or its room closed, before you redeemed it). A reason this SDK does
  not know is `ended`. `serverReason` always has the platform's own word. When the SDK cannot ask, it
  concludes one itself: `heartbeat-lost`, `match-expired`, `refused` (the platform turned the poll
  down for good), `session-changed` or `left`. After a reload, the first check tells you once how
  the ticket you had ended. A `none` after a match was redeemed has no reason.
- `Helix.matchmaking.onMatch(cb)` → unsubscribe. Fires once per match (per room `ticketId`), whether a
  poll, a `status()` call, a late `cancel()` or the first check after a reload found it. A match found
  before any listener existed is handed to the first one, unless it has expired or the status has gone
  back to `none`. Nothing joins the room for you: call `switchRoom`.

**Polling and the heartbeat.** There is no push in this version. While a ticket is `waiting` or
`matched` the SDK asks the platform for its status every 3 seconds, and stops when the status is
`none`. For a waiting ticket that request is also the heartbeat: the platform cancels a waiting
ticket that was not polled for 15 seconds. So:

- The poll keeps running while the tab is hidden. Browsers slow a hidden page's timers (to once a
  minute after about five minutes), so the SDK runs the 3 second tick in a small dedicated worker,
  which is not slowed. If the page's CSP forbids `blob:` workers it falls back to an ordinary timer,
  and a tab hidden for several minutes can then lose its place.
- The SDK polls at once when the tab becomes visible, the page is restored, or the network returns.
- A tab the browser fully suspends or discards (mobile background, a sleeping laptop, tab
  discarding) cannot poll at all. After 15 seconds the ticket is gone and `onStatus` reports `none`
  when the tab wakes. Enqueue again.
- A request with no answer after 8 seconds is abandoned, so one hung request cannot stop the loop.
  The status poll itself is given up after 5 seconds, so a single hung poll leaves at most 9 seconds
  without a heartbeat.
- If `enqueue()` gets no usable answer (a timeout, a lost connection, a 5xx) it still rejects, but the
  SDK then asks the platform whether the ticket exists. If it does, `onStatus` reports `waiting` and
  the heartbeat starts, so a ticket the platform did create is not left to lapse.
- An answer that was already on its way when `enqueue()` or `cancel()` finished never overrides their
  result.
- A poll refused for good (`missing-permission`, `unavailable`, a guest session) ends the queue at
  once: `none` with `reason: 'refused'`.
- A failed poll (no network, a 5xx, a 429, a 401 while the shell refreshes the session) does not end
  the queue at once. The SDK retries with a growing gap. When nothing has got through for 15 seconds
  the platform has cancelled the ticket, so `onStatus` reports `none` with `reason: 'heartbeat-lost'`
  at the 15 second mark and polling stops. The SDK has no way to refresh the session itself; it uses the new token as soon
  as the shell sends one.
- Time the page spent asleep (a suspended laptop, a frozen tab) is not counted as failed polls. When
  the page runs again after 6 seconds or more of not running, the SDK polls at once and reports what
  the platform answers, whether the ticket survived or not. `heartbeat-lost` is reported only if
  nothing gets through for 15 seconds after waking.
- That allowance is for one sleep, not for every one. If the page wakes, polls, gets no answer, and
  then goes 6 seconds or more without running again, the 15 seconds keep counting from the first
  wake. The next wake still polls and an answer is still believed. If that poll fails too, or two
  wakes in a row have sent a poll that was never answered, the status becomes `none` with
  `reason: 'heartbeat-lost'`. This is what a hidden tab does when the page cannot start the
  heartbeat worker (a CSP without `worker-src blob:`), the browser lets its timers run about once a
  minute, and the network is down: it reports the lost ticket on the second or third run instead
  of showing `waiting` until the network returns.
- A matched ticket is polled until its room ticket expires. After one last poll the status becomes
  `none` with `reason: 'match-expired'`.
- **Expiry is judged by the platform's clock, not the device's.** A match's `expiresAt` (and an
  invite's) is the platform's time. A device clock running fast by more than a ticket's life used to
  skip every match, and one running slow was handed tickets already dead. The SDK now learns the
  platform's time from what it already receives: the state of a joined room (`serverTimeMs`, stamped
  by the room server every tick) and, when the browser lets the page read it, the `Date` header of the
  platform's answers. It keeps the offset after a room is left, so a match found later is still
  judged right, but when both are known the source heard from most recently decides. One stray answer
  cannot move it: the estimate is the median of the recent samples and is used only while most of
  them agree. With nothing to go by (no room joined yet and the header not
  readable) the device clock is used, as before. A cross-origin world can read `Date` only when the
  platform API lists it in `Access-Control-Expose-Headers`, so for now a world that has never joined
  a room is judged by the device clock. The platform has the last word either way: a dead ticket is
  refused at redeem.
- After a reload the SDK has no ticket in memory. The first `onStatus`, `onMatch` or `status()` call
  asks the platform once and resumes polling if a ticket is still waiting or matched. Subscribing
  before `Helix.init()` has resolved works: the check runs when it does.
- When the session ends or another player signs in, subscribers get `none` (`session-changed`) and
  the new session's ticket is looked up.
- On the shell's leave message subscribers get `none` (`left`), polling stops and later calls reject
  with `not-ready`. Polling also stops on page teardown; a page restored from the back/forward cache
  resumes. A world that never calls `Helix.matchmaking` or `Helix.party` makes no request and holds
  no timer. A world that listens to matchmaking pays for one `GET /parties/current` every 5
  seconds while the tab is visible (see "Queueing as a party").

**Queueing as a party.** The platform keeps a heartbeat for every member, not only the leader. A
party's ticket can only be matched while every member has polled the queue status in this world
within the last 15 seconds, and it is cancelled when any member has not polled for 60 seconds. Only
the leader calls `enqueue`, so the other members' SDKs find the ticket themselves:

- Every party member's world must have subscribed to `Helix.matchmaking.onMatch` (or `onStatus`).
  Do it at startup, as in the example above. A member whose world never subscribes makes the party
  unmatchable.
- **What a listener costs.** While a world has a matchmaking listener the SDK sends one
  `GET /parties/current` every 5 seconds, whether or not the world uses `Helix.party`. That is how
  it learns the player joined a party; `Helix.party.accept()` and `join()` tell it at once. While the
  tab is hidden this poll rests, unless the player is in a party of two or more, has a waiting
  ticket, or the world subscribed to party events itself. It resumes when the tab is visible.
- While the player is in a party of two or more and is not queued, the SDK asks for the queue status
  every 5 seconds. When the leader queues, the member's `onStatus` reports `waiting` and the SDK
  switches to the 3 second heartbeat. `onMatch` then fires for every member.
- A solo player, or a party of one, never makes those idle requests.
- A member whose tab is suspended or closed stops polling. The party cannot be matched until they
  return, and after 60 seconds the ticket is cancelled for everyone.
- The idle requests stop when the last matchmaking listener unsubscribes or the party drops below
  two members.

**Parties.** A party is a platform-level group of accounts with one leader. It is not tied to a
room or a world. There is no shell UI for it in this version; the world draws its own. The world's
manifest needs the `multiplayer` permission: without it every matchmaking and party call is refused
with `missing-permission`.

```ts
import { Helix, HelixPartyError } from '@hypersoniclabs/helix-sdk';

const { party, invites } = await Helix.party.get(); // party is null when you are in none
Helix.party.onChange((state) => ui.showParty(state.party));
Helix.party.onInvite(async (invite) => {
  // invite: { id, partyId, from: { userId, displayName }, expiresAt }
  if (await ui.confirm(`${invite.from.displayName} invited you`)) await Helix.party.accept(invite.id);
  else await Helix.party.decline(invite.id);
});

Helix.party.onError((e) => ui.hidePartyPanel(e.code)); // the background poll was refused and stopped

// Grouping up with anyone: the leader shares a code, the others type it.
const { code } = await Helix.party.createCode(); // e.g. show "Party code: 7KQ2MX9P"
await Helix.party.join(typedCode);               // on the other players' side

await Helix.party.invite(userId); // an accepted friend (see below for anyone else)
```

**Codes are the default way for strangers to party up.** `invite()` reaches an accepted friend. A
non-friend can be invited only if they are in this world and have public presence with activity
sharing on. The account default is friends-only, so for most non-friends `invite()` fails with
`cannot-invite`. A code (8 characters) has no such limit.

- `Helix.party.get()` → `Promise<PartyState>`: `{ party: { id, leaderId, maxSize, members } | null, invites }`.
- `Helix.party.onChange(cb)` / `Helix.party.onInvite(cb)` → unsubscribe. The platform is polled every
  5 seconds only while at least one of them is subscribed. `onChange` fires when the state differs
  from the previous answer; the first answer after subscribing is the baseline, so read the starting
  state with `get()`. When the last subscriber leaves, the baseline is forgotten. `onInvite` fires
  once per invite id, including invites already pending, always from a fresh answer and never for an
  expired invite. If the session changes, `onChange` reports no party and the new session is polled.
- `Helix.party.onError(cb)` → unsubscribe. The background poll retries a failure with a growing gap
  (up to 30 seconds) and says nothing: no answer, a 5xx, a 429, a 401 from an expired token, and any
  other refusal (a 403 or 404 with no code, a code this SDK does not know). One such answer is not
  taken as the next one. A session refresh from the shell retries at once. Only a final answer stops
  the poll: `missing-permission`, `unavailable` (parties switched off) or a guest session. Then `cb`
  gets the `HelixPartyError` once, and a listener added later gets it too. The poll starts again by
  itself when the shell sends a new or refreshed session, and as soon as one of your own party calls
  (`get()`, `invite()`, …) is answered successfully, so a world that asks again after a deploy turned
  parties off for a moment gets its updates back. Errors from calls you make yourself reject those
  calls and are not sent here. (Before this version any 4xx stopped the poll, and only a new session
  started it again.)
- `Helix.party.createCode()` → `Promise<{ code, expiresAt }>`. Leader only. Creates a party of one
  if you have none. The leader also sees the code as `party.code` in `get()` and `onChange`; other
  members do not.
- `Helix.party.revokeCode()` → `Promise<void>`. Leader only.
- `Helix.party.join(code)` → `Promise<Party>`. The code is trimmed and upper-cased before it is sent.
  An empty code, one with a space in it or one longer than 64 characters is rejected locally with
  `invalid-argument`.
- `Helix.party.invite(userId)` → `Promise<{ inviteId, expiresAt }>`. Creates the party if you have
  none and makes you its leader. The recipient has to accept. Inviting someone who already has a
  pending invite from your party returns that same invite.
- Sending an invite or creating a code puts you in a party of one. From a party of one, accepting an
  invite or joining by code moves you into the new party. `already-in-party` only means you are in a
  party of two or more: call `Helix.party.leave()` first.
- `Helix.party.accept(inviteId)` → `Promise<Party>`; `Helix.party.decline(inviteId)` → `Promise<void>`.
  `decline` resolves even when the invite no longer exists.
- `Helix.party.leave()`, `Helix.party.kick(userId)` → `Promise<void>`; `Helix.party.promote(userId)` →
  `Promise<Party>`. Kick and promote are leader only. When the leader leaves, the longest-standing
  member becomes leader.
- A party change (someone joins, leaves or is kicked) cancels that party's waiting queue ticket.

**Errors.** Every rejection is a `HelixMatchmakingError` or a `HelixPartyError` with `code`,
`status` (the HTTP status, or `null` when nothing was sent or nothing answered) and `backendCode`
(the platform's own code, when it sent one).

| `code` | Thrown by | Meaning |
| --- | --- | --- |
| `unknown-queue` | matchmaking | The manifest declares no such queue. |
| `already-queued` | matchmaking | You already have a waiting ticket in this world. The call rejects; the SDK makes sure that ticket is polled and `onStatus` reports it. A ticket in another world is not an error: enqueueing here replaces it. |
| `already-matched` | matchmaking | A match already stands (`cancel()` was too late, or `enqueue()` while it is unredeemed). `error.match` is the match and `onMatch` fires. From `enqueue()` it can be `null`: a party member's match is pending, and `error.memberUserId` names them. |
| `not-party-leader` | both | Only the leader queues, invites, kicks and promotes. |
| `party-too-large` | matchmaking | The party does not fit one team of this queue. |
| `party-changed` | both | The party changed while the request ran. Nothing happened; call again. The SDK already retried twice. |
| `missing-permission` | both | The world's manifest lacks the `multiplayer` permission. |
| `guests-cannot-queue` | matchmaking | Guest session. Checked locally first, so no request is sent. |
| `guests-cannot-party` | party | Guest session. Checked locally first, so no request is sent. |
| `cannot-invite` | party | Not a friend, and not invitable as a stranger (that needs them in this world with public presence and activity sharing on), or a block applies. Use a code. |
| `party-full` | party | No room left, when inviting or accepting. |
| `too-many-invites` | party | The party has its limit of pending invites. |
| `already-in-party` | party | You are in a party of two or more. Leave it before accepting an invite or joining by code. |
| `code-not-found` | party | No party has that join code (mistyped, expired or revoked). |
| `not-in-party` | party | You are in no party (leave, kick, promote). |
| `unknown-party-member` | party | That player is not in your party (kick, promote). |
| `invite-not-found` / `invite-expired` | party | The invite is unknown, or it ran out. |
| `rate-limited` | both | Too many enqueues, invites or code attempts (any HTTP 429). |
| `unavailable` | both | The feature is not enabled on this platform. |
| `not-ready` | both | No `Helix.init()`, no session, no world id, no API base, or the world was left. Nothing was sent. |
| `invalid-argument` | both | An empty id. Nothing was sent. |
| `request-failed` | both | Anything else: no answer within 8 s, a 5xx, a 401 from an expired token, a 404 with no code, or a code this SDK does not know. |

**Local dev.** `helix dev` gives the SDK no API base, so these calls reject with `not-ready` there.
The dev shell answers the same routes for tests and the debug menu: pass `queues` (and the
`roomTypes` they point at), call `simulatePlatformRequest(method, path, body?)` for any of the
routes, and use `formMatch()` to play the matcher. It issues a room ticket from the simulated
room-types table, which `simulateRoomJoin({ ticket })` then redeems. Until it is redeemed, cancelling
or enqueueing answers 409 `already-matched` with the match, as the backend does. `receivePartyInvite()`,
`acceptPartyInvite(inviteId)`, `openPartyCode()` and `usePartyCode(code)` play the other players. Refusals carry the backend's
`{ statusCode, code, message }` body.

## Protocol

[`src/protocol.ts`](src/protocol.ts) is the wire contract (`postMessage` messages between world and
shell) and is imported by both sides. Any breaking change must bump `PROTOCOL_VERSION`. The
`HelixSession` shape matches the backend's `InstantWorldSessionResponseDto`, so the shell forwards
the backend response verbatim.

## Engine-neutral runtime contract

The public Web surface is also captured in one generated, engine-neutral contract for Web, Unreal
and future bindings. Import the typed metadata without changing existing SDK behavior:

```ts
import {
  HELIX_RUNTIME_CONTRACT,
  RUNTIME_CONTRACT_DIGEST,
  getRuntimeMethod,
} from '@hypersoniclabs/helix-sdk/runtime-contract';

const purchase = getRuntimeMethod('marketplace.purchaseProduct');
// purchase.transportOwner === 'shell'
// purchase.authority === 'platform'
```

The canonical source, generated C++ header, known-answer fixtures, authority rules and update
workflow are documented in [`docs/runtime-contract.md`](docs/runtime-contract.md). Audited broken
or authority-drifted calls are explicitly `unavailable` or `correction_required`; generators must
not reproduce their current false-success behavior.

## Native avatar camera

`Helix.camera` carries two separate things. `capture`/`savePhoto` are the album pipeline: pixels a
world already rendered, on their way to the player's account, gated on the `camera.capture` manifest
capability. `getState`/`onStateChanged`/`capabilities`/`reportCapability`/`control` are the CAMERA —
the device the player's avatar carries, which HELIX OS opens.

HELIX OS owns the camera UI and pushes the whole state down; the runtime owns the rig and reports
what it can actually do back up.

```ts
// The runtime declares its abilities at boot, and again whenever one is LOST.
Helix.camera.reportCapability({
  photo: true, video: false, drone: false, twoHandPose: true,
  focusPoint: true, exposureBias: false, worldAudio: false,
  unavailableReason: 'this world ships photo only',
});

Helix.camera.onStateChanged((state) => {
  // state.shutter.sequence is monotonic — fire on the CHANGE, never on a boolean.
  applyCamera(state.spatial, state.zoom, state.filter);
});

// A world script driving the player's camera needs the `camera.control` capability, and a call the
// runtime already said it cannot serve fails locally with RUNTIME_CAPABILITY_UNAVAILABLE.
await Helix.camera.control({ spatial: 'selfie', zoom: 2.4, filter: 'vivid' });
```

Reading the camera never requires a world manifest capability — it is an avatar ability, not
something the world asks for. The ownership split, the dynamic-capability model, the drone's
Phase-2 status, and the reasoning behind that permission boundary are in
[`docs/camera-contract.md`](docs/camera-contract.md).

## Universal interaction action surface

`UniversalActionSurface` renders the shared `run`, `choice`, `number`, `text`, `point`, `asset`, and
`takeover` forms from a caller-scoped action snapshot. Labels, options, visibility, permissions,
and busy/occupied state come from authority; the snapshot is only a presentation cache, so every
invocation must still be re-authorized server-side.

```ts
import {
  UniversalActionSurface,
  createActionSurfaceMode,
} from '@hypersoniclabs/helix-sdk';

const surface = new UniversalActionSurface({
  host: document.querySelector('#ui')!,
  hints: {
    activeDevice: () => character.services.input.activeDevice(),
    hint: (action) => character.services.input.hint(action),
  },
  onInvoke: ({ action, ...value }) => {
    // Send action.call ?? action.id plus value to authority. Re-authorize there.
  },
});

surface.setSnapshot(await actionsForNearbyItem());

// Proximity may show the offer without capturing movement. Push the engaged
// menu mode only after the player chooses to focus it.
await modeStack.push(createActionSurfaceMode({ surface }));
```

The mode consumes only `move`, `primary`, and `cancel`; it never installs keyboard, gamepad, or
raw-touch bindings. A host whose input router has no change subscription calls `refreshHints()`
after an active-device or rebind change. Sustained number changes stream live while the native
range remains open, native text input preserves focus across authority refreshes, and choice/asset pickers keep the saved value visually distinct from
keyboard/gamepad focus. The port source is the frozen interactive-items prototype's
`src/client/ui.ts`; new development lives here.

`DeviceDefinitionV1.interactions` uses the same portable forms. A Device `text` control declares
its typed `stateKey` plus optional `placeholder`, `maxLength`, and Action Surface `inputMode`; the
enclosing interaction `label` is the player-facing prompt. Submitted text goes through normal
Device authority, which must validate it before writing state.

## Vault assets

The Vault client lets a web world search and consume all 14 resource kinds
without coupling the SDK to Three.js or another renderer. Persist the returned
`{ assetId, version }` handle. Never persist `artifact.url`: it is a resolved
serving pointer, while the Vault ID and immutable integer version are the
durable identity.

```ts
import { createVaultClient } from '@hypersoniclabs/helix-sdk';

const vault = createVaultClient({
  apiBaseUrl: 'https://api.helixgame.com',
  accessToken: () => Helix.getSessionToken(),
});

const result = await vault.search({
  q: 'weathered stone',
  kind: ['material', 'texture'],
  performanceTier: 'mobile',
  rank: 'performance',
});

const asset = await vault.resolve({
  assetId: result.items[0].assetId,
  version: result.items[0].currentVersion,
});
const plan = vault.createLoadPlan(asset);
const installed = await vault.install(asset.handle);

// `plan` is a typed handoff such as:
// - gltf (prop, character, animation, environment, terrain)
// - image (texture, decal)
// - audio
// - hdr-environment (HDR/EXR)
// - gaussian-splat (SPZ, with object/environment scope)
// - helix-descriptor (material, sky, terrain, VFX, scene)
//
// Pass `installed.primary.bytes` and `plan` to the world's chosen loader.
```

Downloads follow the Vault's version-aware download route and verify the
server-provided SHA-256 by default when Web Crypto is available. A runtime
without Web Crypto may inject a portable `sha256` function. Related renditions
are resolved from asset detail and downloaded only when their roles are
explicitly requested:

```ts
await vault.install(asset.handle, {
  relatedRoles: ['ktx2-albedo', 'ktx2-normal'],
});
```

The SDK deliberately does not report a client-side "usage" event. Vault usage
attribution is authoritative and derived when a world containing durable Vault
IDs is published. There is currently no separate install, usage-track, or
related-rendition API endpoint; `install()` is the local verified-download plus
loader-plan operation, and related artifacts come from asset detail.

### Generate audio and consume the published Vault asset

Creator tools can use the SDK's typed Dreamer audio client with an authenticated
creator/client token. Sound effects, music, and text to speech all call the same
HELIX route; the SDK never calls ElevenLabs directly or handles a provider key.
Completed jobs are automatically published to Vault unless the creator has
explicitly opted out.

```ts
import { createDreamerAudioClient } from '@hypersoniclabs/helix-sdk';

const audio = createDreamerAudioClient({
  apiBaseUrl: 'https://api.helixgame.com',
  accessToken: creatorToken,
});

const effect = await audio.generate({
  audioMode: 'sound_effect',
  prompt: 'heavy metal door slamming shut',
  durationSeconds: 3,
  promptInfluence: 0.4,
});

const music = await audio.generate({
  audioMode: 'music',
  prompt: 'hopeful orchestral exploration theme',
  durationSeconds: 180,
  forceInstrumental: true,
});

const voice = await audio.generateAndResolve({
  audioMode: 'text_to_speech',
  text: 'Welcome to HELIX.',
  voiceId: '21m00Tcm4TlvDq8ikWAM',
  languageCode: 'en',
  voiceSettings: { stability: 0.5, speakerBoost: true },
});

// The exact auto-published asset is now resolved through the normal Vault path.
audioElement.src = voice.loadPlan.primary.url!;
console.log(effect.vaultAssetId, music.vaultAssetId, voice.resolved.handle);
```

`generate()` polls for up to 30 minutes by default so long music generation has
time to render, ingest, and publish. It returns measured `units`,
`providerCostUsd`, server-stamped `billingEvidence`, and `vaultAssetId`.
`generateAndResolve()` additionally requires default-on Vault publication and
returns the renderer-neutral audio load plan for that same durable asset.

## Develop

```bash
npm install
npm test       # jest + jsdom
npm run build  # tsc → dist/ (ESM)
npm run lint
```
