@helix3/helix-sdk
v0.1.6-helix3.116
Published
HELIX Instant SDK — identity (and later multiplayer/voice/wallet) for worlds running on HELIX Instant
Readme
@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
npm install @hypersoniclabs/helix-sdkUsage
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.runtimeFidelityis the validated versioned presentation intent, ornullfor an older shell or malformed payload; in that case the engine retains its Auto fallback.Helix.auth.getUser(): Promise<HelixUser | null>Helix.auth.isAuthenticated(): booleanHelix.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? }): booleanHelix.notify.success|failure|notification|message(title, message?): boolean— returnsfalsewhen the world is running standalone without a shell.Helix.prompts.set({ id, title, description?, input?, actionLabel?, state?, priority? }): booleanHelix.prompts.clear(promptId?): booleanHelix.getSessionToken(): string | null— the world-scoped token, for advanced direct API calls.
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).
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 UUIDHelix.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.
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.
// 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>—trueon success;falsefor 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.
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>—nullwhen 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: 0asserts 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; passnextCursorback ascursorfor 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.
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 }, wheresourceis'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.
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 worldHelix.achievements.list()→Promise<{ achievements, total, earned }>— every achievement of this world;totalis the world's count,earnedhow 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 replacesname/descriptionwith placeholders and blankskey/iconUrl, so neither a hidden name nor the slug that spells it reaches world code. Identify such a row byid. - 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.
Protocol
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:
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. 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.
// 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.
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.
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.
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:
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.
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
npm install
npm test # jest + jsdom
npm run build # tsc → dist/ (ESM)
npm run lint