BodyLink Developer Guide
The end-to-end path for a developer to build a BodyLink game and get it live: prerequisites, local development, testing, validation, deployment, and the common errors you will hit on the way.
The two things that trip up most first submissions are (1) a manifest missing
entry.kind / launcher, and (2) uploading unbuilt source instead of a
built bundle. Both are covered below, and both map to a single named
validation check with a recommended fix.
Reading order. Skim §1–§2 once, then keep §6 (common errors) and the Validation Reference open while you build. Most rejections map to one named check and one recommended fix.
1. Prerequisites
| Requirement | Value | Notes |
|---|---|---|
| Node | ≥ 20 | The SDK toolchain, the bundle validators, and the submission gate all run under Node 20+. |
| npm | ships with Node 20 | Used to install the SDK and run your game's dev / build / export scripts. |
| The SDK package | the BodyLink platform package | The build-time SDK: you install it into your game with npm. The scaffolder wires it in for you. |
| The runtime SDK | sdk-v1.js |
The runtime iframe bridge (~22 KB), loaded by your game cross-origin at runtime and SRI-pinned. You do not vendor it into a default bundle — the export pins it by integrity hash and the host injects its origin. |
| (optional) a headless browser | Playwright / Chromium | Only needed if you want the full local validation (the runtime-boot probe). Without it, the runtime check is skip-clean, not a failure (§3). |
The bundle format in one paragraph
A shipped game is a Runtime v1 iframe bundle: an offline, opaque-origin
iframe rooted at games/<slug>/, containing index.html (the entry), a
bodylink.game.json manifest, a bodylink.bundle.lock.json lock that SRI-pins
the SDK, and bundle-relative assets. All bundle files are immutable per
<slug>@<version> — a new release is a new version.
2. Local development — building a bundle that passes
2.1 Scaffold
Start from the canonical template instead of hand-copying an example:
npx create-bodylink-game ring-rally
The scaffolded project is a standalone repo with a two-manifest split:
bodylink.game.json— the GAME CONTRACT (id, version, runtimeRange, entry, iframeSession, capabilities, calibration,launcher). Ships in the bundle.bodylink.project.json— dev-kit project state (SDK pin, dev prefs, version history). Committed, but excluded from the export bundle.
2.2 The inner loop
cd ring-rally
npm install # installs the SDK (+ the dev bundler)
npm test # host-free template tests
npm run dev # standalone dev server — open the in-game developer tour
The dev server hosts an in-game developer tour as your starting point. Games
import only the SDK surface — game-kit and iframe-sdk. Everything else in
the platform is host-internal; importing it is a boundary violation that the gate
will reject.
2.3 What makes an iframe bundle valid
A bundle the gate accepts must satisfy all of the following. These are exactly the things first submissions most often get wrong.
entry.kind: "iframe"+entry.html. Runtime v1 bundles are iframe entries, not module entries. The manifestentryis a tagged union; a missingentry.kinddefaults to"module"(legacy) and is rejected for a v1 bundle.entry.htmlmust be a bundle-relative path (no absolute, URL-like, or..paths).The
launcherblock. Catalog-ready bundles need alauncherblock (display + tracking metadata:description,color,gridSize,tags,howToPlay,trackingFocus, …). Missing the block isbundle.launcher_missing; a missing field isbundle.launcher_field_missing.Capabilities for iframe + SDK proxy. Iframe entries MUST declare both
iframe.bundle.v1andsdk.proxy.v1incapabilities.A valid
iframeSession.channel: "postmessage", a non-emptyallowedParentOrigins(explicit origins —"*"is forbidden), and asandboxtoken array that includesallow-scriptsand neverallow-same-origin.Self-contained, BUILT output — no bare imports. This is the wall most first submissions hit. Raw game source uses bare module imports and dev-server-only paths (unresolved source modules, the dev client). A real bundle is the built output where every specifier is resolved and inlined. Opaque-origin iframes cannot fetch modules at runtime, so the gate also rejects module
<script>entrypoints,import.meta, and dynamicimport()in anallow-scripts-only bundle.
Beyond the required fields, the manifest's optional coordinate block
declares your tracking defaults: coordinate.mode / coordinate.framing
(which coordinate space and body region you receive — see
Camera Preview & Coordinates),
coordinate.calibration: "none" (skip the get-ready ceremony — see
No-Calibration Opt-In), and
coordinate.debugOverlay (the host debug overlay's initial mode at entry —
see Debug Overlay). All are additive and optional; an
undeclared bundle keeps the defaults.
You do not hand-assemble this. The export / export:zip scripts build the game
and emit a downloadable bundle (default delivery: the SDK is loaded cross-origin
and SRI-pinned, not vendored):
npm run export:zip # build -> SDK-only bundle -> <id>@<ver>.zip
2.4 The iframe SDK handshake & lifecycle
At runtime the bundle loads sdk-v1.js and runs the Runtime v1 lifecycle —
init → ready → run ⇄ suspend, with any non-terminal state able to reach
teardown. The host requests every transition; the game acknowledges.
load sdk-v1.js (cross-origin, SRI-pinned)
GAME -> HOST sdk.hello (+ optional trackingReadiness)
HOST -> GAME sdk.ready (negotiated wire version)
HOST -> GAME manifest.load / GAME -> HOST manifest.loaded
--- state: init -> ready ---
HOST -> GAME lifecycle.run (+ pregame calibration gate, if any)
--- state: run ---
HOST -> GAME tracking.frame (~30 Hz) / GAME -> HOST gesture.event
HOST -> GAME input.remote / input.pointer
HOST -> GAME lifecycle.suspend / lifecycle.resume
HOST -> GAME lifecycle.teardown --- state: teardown · bridge closed ---
Message directions are authoritative: game→host = sdk.hello,
calibration.start, camera.preview.set, camera.photo.capture; either
direction = diagnostic.log, sdk.error (the host emits both on rejects
too); host→game = everything else. Most games use the high-level kit
createBodyLinkGame(...); a lower-level client,
createBodyLinkIframeClient(...), is available when you need direct control of
the bridge. The kit handles the handshake, the lifecycle transitions, and the
tracking / calibration / preview API for you.
Two host surfaces ride alongside the lifecycle during run: a game with the
camera.preview capability can direct the host-composited camera preview and
bind its coordinate space
(Camera Preview & Coordinates), and
the host Debug Overlay (F8) shows the live tracking
view over any session — including player mode — with an optional
manifest-declared initial mode.
3. Testing — run the gate the portal runs
Validate your built bundle locally with the exact gate the developer portal runs in CI. This is the single most useful habit: it fails the same way the portal will, with the same named-check + recommended-fix report.
# Validate an unpacked games/<slug>/ bundle directory
node scripts/validate-submission.mjs <bundle-dir> --report
# add --json for the machine-readable report
What it does:
- It composes the existing validators — it adds no new rules. STATIC: the offline + SDK-only bundle validators. RUNTIME: an optional headless boot that drives a real from-zip boot of your bundle.
- It is fail-closed: overall
passis true only if every static check passes AND the runtime check passed or was skip-clean. - No browser? The runtime check is skip-clean, not a failure — an explicit
note (
runtime check skipped: no headless browser), so the static gate still runs everywhere. With a headless browser present, the boot asserts the SDK handshake, tracking frames, and a clean console.
The portal cannot run the heavy runtime-boot probe in its own request path, so the full gate runs on a CI runner with a real browser that the portal drives — same script, same report.
Developer vs player mode
The dev-mode helper gates dev-only capabilities behind one developerMode flag:
the perf overlay (FPS / inference latency / frame drops), the skeleton overlay,
pose-source emulation (live / clip / keyboard), and FTUE/menu skip. Default is
OFF (player mode): every dev capability is hidden, FTUE/menu stay mandatory,
and the pose source is forced to live. Use it to drive your game from a recorded
clip or the keyboard while developing, without a camera.
A self-hosted runner gives you a real hosted boot locally:
npm run dev:hosted # empty self-hosted game-runner: Open -> Run -> Stop -> Export
4. Validation — the fail-closed gate & the named-check report
The submission gate is fail-closed: the default is reject, never publish. Every failure names the failing check and a one-line recommended fix. The portal surfaces this same named-fix report in its UI, so a rejection a reviewer sees is the same report you get locally.
The report shape is
{ slug, pass, checks: [{ check, pass, issues: [{ code, message, fix }] }] }.
A --report run prints, per failing check, each issue's code, message, and
-> fix: line.
How to read a rejection and fix it
- Run
node scripts/validate-submission.mjs <bundle-dir> --report. - Find the
[FAIL]check and read each[<code>]line — the code tells you which requirement failed; the-> fix:line tells you how. - Apply the fix to the manifest / HTML / build, re-export, re-run. Repeat until
the gate reports
PASS.
The full code → fix list lives in the Validation Reference. Any code not in the table falls back to a generic "review the message + the Runtime v1 contract" fix — a failing check is never left fix-less.
5. Deployment — the developer-portal flow
You do not get catalog-write credentials. For a nonprogrammer, the canonical entry is the External Developer Agent Release Runbook. It separates two current submissions:
one-shot OAuth direct upload -> validation/staging evidence
-> source=agent-upload + productionEligible=false (cannot be promoted)
new higher version -> authenticated Portal /submit upload -> validation -> staged
-> distinct BodyLink human plays and reviews -> production catalog
- Onboard. Register/sign in. The BodyLink team provisions organization/game membership and gives you public OAuth client, organization, and game IDs.
- Preflight and freeze. Run the repository's tests/build/validate/export plus
npm run release:preflight. Verify the ZIP (≤50 MiB), exact bytes, SHA-256, source commit, and worktree state. Stop on any failed gate. - Direct agent evidence. The installed CLI uses Authorization Code + S256
PKCE and a temporary
127.0.0.1callback. You type credentials only in the Portal browser, authorize the displayed client/org/game, then separately confirm the exact artifact digest. A successful result must saysource=agent-uploadandproductionEligible=false. - Prepare production separately. Keep the slug and create a new higher
version because submitted versions are immutable. Rebuild every gate, freeze
the new digest, and explicitly approve the visible authenticated
/submitupload for that exact candidate. - Distinct reviewer approval. A BodyLink reviewer uses a different account, plays the production submission in the real staging launcher, and approves or rejects it. External developers cannot approve their own submission.
- Verify production. Say LIVE only when the production catalog exposes the exact approved slug@version. Direct evidence, upload, validation, and staging are not LIVE.
The detailed browser actions, exact approval phrases, failure recovery, and final evidence format are in the Agent Release Runbook. Portal states and reviewer UX are in the Developer Portal Guide.
Slug, versioning, and removal
- Slug is identity. A game is keyed by its
slug; the bundle lives atgames/<slug>/*and the catalog entry is keyed by slug. - Update model = same slug, higher
version. Bundles are immutable per<slug>@<version>— you never overwrite a published<slug>@<version>. A new release is a newversionunder the same slug. Publishing into agames/<slug>/that already holds objects for that version is rejected fail-closed. - Unpublish / deprecate. Removing a game = dropping its catalog entry; the immutable bundle objects may remain, unreferenced. Unpublish/deprecate is a reviewer action today.
6. Common errors & troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
Gate FAIL with bundle.entry_kind |
manifest is a legacy module entry | Set entry.kind: "iframe" + entry.html (§2.3). |
Gate FAIL with bundle.launcher_missing / _field_missing |
no launcher block / a field absent |
Add the launcher block; fill the named field. |
Gate FAIL with bundle.capability_missing |
iframe entry without iframe.bundle.v1 / sdk.proxy.v1 |
Declare both capabilities. |
Gate FAIL with bundle.sdk_only_unresolved_module_import or bundle.sdk_only_forbidden_reference |
you submitted unbuilt source (bare imports, dev-server paths) or imported a host-internal package | Build the game and export the bundle (npm run export:zip); import only game-kit / iframe-sdk. A submission is built, not source. |
Gate FAIL with bundle.opaque_origin_module_script / _dynamic_import / _import_meta |
module <script>, runtime import(), or import.meta in an opaque-origin bundle |
Remove them; an allow-scripts-only iframe cannot fetch modules at runtime. |
Gate FAIL with bundle.parent_origin_wildcard / bundle.sandbox |
allowedParentOrigins: "*" or allow-same-origin in sandbox |
Use explicit origins; allow-scripts only, never allow-same-origin. |
Gate FAIL with bundle.lock_missing / bundle.index_missing / bundle.zip_* |
the zip wasn't produced by the exporter, or is stale/malformed | Re-run npm run export:zip; do not hand-edit the bundle or lock. |
runtime check FAIL (runtime.boot) |
the bundle booted but didn't reach a healthy state | npm run dev:hosted, open the bundle, resolve the SDK-handshake / tracking-frame / console errors, then resubmit. |
runtime check SKIP (no headless browser) |
a headless browser is not installed locally | Expected — not a failure. The static gate still ran; the portal's CI runner does the full boot. |
SDK <script> fails to load / SRI mismatch |
the SDK origin or SRI is wrong | The origin is host-injected, not baked; the SRI is pinned at export. Rebuild the SDK artifact and re-export so the lock's SRI matches the live SDK. |
| A committed file breaks every game's SRI | a CRLF rewrite | The SDK artifact is byte-pinned over LF bytes; keep core.autocrlf false. A CRLF rewrite changes the hash and breaks the integrity pin. |
| Upload rejected before validation | not a zip / over 50 MiB | The portal structurally checks content-type + PKZIP magic bytes and caps portal uploads at 50 MiB; export a proper <slug>@<ver>.zip. npm run release:preflight reports your zip size against the cap; the large-game story is in the Developer Portal Guide §2. |
npm run release:preflight prints a ✗ |
a submission requirement isn't met yet | Apply the fix: line printed under the ✗ and re-run until the last line is PREFLIGHT: PASS — never submit (or tag) on a FAIL, and never tag from an unmerged branch. |
| Publish didn't appear in the catalog | projector lag, or the publish failed | The catalog updates within about a minute of the publish; a failed publish never partially publishes and is retryable. |
For the authoritative code → fix list, see the Validation Reference.