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.

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 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 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
        
  1. Onboard. Register/sign in. The BodyLink team provisions organization/game membership and gives you public OAuth client, organization, and game IDs.
  2. 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.
  3. Direct agent evidence. The installed CLI uses Authorization Code + S256 PKCE and a temporary 127.0.0.1 callback. 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 say source=agent-upload and productionEligible=false.
  4. 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 /submit upload for that exact candidate.
  5. 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.
  6. 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 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 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.