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.

  1. entry.kind: "iframe" + entry.html. Runtime v1 bundles are iframe entries, not module entries. The manifest entry is a tagged union; a missing entry.kind defaults to "module" (legacy) and is rejected for a v1 bundle. entry.html must be a bundle-relative path (no absolute, URL-like, or .. paths).

  2. The launcher block. Catalog-ready bundles need a launcher block (display + tracking metadata: description, color, gridSize, tags, howToPlay, trackingFocus, …). Missing the block is bundle.launcher_missing; a missing field is bundle.launcher_field_missing.

  3. Capabilities for iframe + SDK proxy. Iframe entries MUST declare both iframe.bundle.v1 and sdk.proxy.v1 in capabilities.

  4. A valid iframeSession. channel: "postmessage", a non-empty allowedParentOrigins (explicit origins — "*" is forbidden), and a sandbox token array that includes allow-scripts and never allow-same-origin.

  5. 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 dynamic import() in an allow-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 pass is 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

  1. Run node scripts/validate-submission.mjs <bundle-dir> --report.
  2. Find the [FAIL] check and read each [<code>] line — the code tells you which requirement failed; the -> fix: line tells you how.
  3. 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
        
  1. Register. Self-registration in the developer portal.
  2. Upload. POST your bundle zip (≤ 50 MiB). The portal stores it in a quarantine bucket — never the production catalog — and triggers validation.
  3. 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.
  4. 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.
  5. 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 at games/<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 new version under the same slug. Publishing into a games/<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.