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.
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; 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.
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 direct catalog write credentials. A bundle reaches the live catalog only through the BodyLink developer portal's fail-closed pipeline:
register -> upload <slug>@<ver>.zip -> automated validation (the §3/§4 gate, fail-closed)
-> REVIEWER APPROVAL in the portal -> publish to the catalog -> live
- Register. Self-registration in the developer portal.
- Upload. POST your bundle zip (≤ 50 MiB). The portal stores it in a quarantine bucket — never the production catalog — and triggers validation.
- Automated validation (fail-closed). The portal drives the same gate you ran locally (§3) on a CI runner with a real browser. Pass ⇒ eligible for approval; anything else ⇒ reject, and the portal renders the named-check + recommended-fix report (§4) so you can fix and resubmit.
- Reviewer approval. A submission goes live only on an explicit BodyLink reviewer approval — developers cannot self-approve. The decision is auditable (who / when) and revertible.
- Publish. Only on approval does the portal write the approved bundle to the production catalog. The catalog republishes within about a minute, with no host redeploy. Your game then appears in any consuming host page.
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 at 50 MiB; export a proper <slug>@<ver>.zip. |
| 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.