Camera Preview & Coordinates
A BodyLink game runs in an opaque-origin sandboxed iframe and never touches
the camera. The host owns the sensor: it draws its own webcam + skeleton pixels
behind your transparent game frame, and streams you pose landmarks over
tracking.frame. What your game controls — declaratively and at runtime — is:
- Which coordinate space your landmarks arrive in (
coordinate.mode). - Which body region tracking requires (
coordinate.framing). - Where the host draws its camera preview (the
camera.preview.setslot).
The manifest coordinate block is a plain declarative field; the preview
slot channel and the runtime setCoordinateMode bind are gated by the
camera.preview capability. Declare it in your manifest capabilities (and
set the camera.preview manifest field to true if your game sends preview
slots), or the channel is refused and the host default governs. No camera
pixels ever cross into your iframe — the slot API carries geometry and
intent only. (The single, narrow exception is the
separate single-shot photo capability.)
1. Coordinate modes: full vs roi
The host stamps every game-facing tracking.frame with a coordinateSpace.
Your game binds which one it gets:
coordinate.mode |
coordinateSpace stamped |
Origin | Landmark range |
|---|---|---|---|
"full" (default) |
camera-fov-v1 |
camera-center (top-left) | x/y are screen fractions in [0, 1] across the whole camera frame |
"roi" |
roi-centered-v2 |
body-center | x/y are body-centered in [-1, 1] around the player |
The default — no declaration, no call — is full. A game that reads
landmark.x as a screen fraction gets exactly that. ROI is an explicit
opt-in; when you request roi before any ROI transform exists (no
calibration yet), the host degrades gracefully to camera-fov-v1 — it never
fabricates a transform.
Declaring the mode
Two paths, and the runtime call supersedes the manifest:
// bodylink.game.json — declarative default
{
"capabilities": ["tracking.frame", "camera.preview"],
"coordinate": {
"mode": "roi",
"framing": "upper-body"
}
}
// Runtime — supersedes the manifest declaration.
game.tracking.setCoordinateMode('roi', { framing: 'upper-body' });
game.tracking.setCoordinateMode('full'); // back to camera-FOV [0,1]
setCoordinateMode throws on anything other than 'full' / 'roi' (and
'full-body' / 'upper-body' for framing), so a typo fails loud in
development rather than silently resolving to a default.
2. The framing axis: which landmarks are required
coordinate.framing is orthogonal to the mode. Since the 0.6 contract
update, framing means which landmarks are required — it no longer changes
the shape of the coordinate space:
| framing | required landmarks | profile region |
|---|---|---|
"full-body" (default) |
whole body — hips/legs contribute to readiness and calibration | fullBody |
"upper-body" |
head, shoulders, arms, wrists only | upperBody |
Both framings share the uniform 4:4 square framing box
({ halfWidthUnits: 2, halfHeightUnits: 2 }), so coordinates are isotropic:
the same physical motion produces the same normalized delta on both axes, and
a hand raised overhead stays inside the nominal box.
upper-body makes seated play first-class. A seated player with no
visible hips or legs is fully supported — readiness never waits on lower-body
visibility, and an upperBody calibration installs the same transform whether
the player calibrates seated or standing. The two framings differ by anchor
(shoulder center for upper-body, hip center for full-body) and by required
landmarks — not by box shape.
Per mode: for "roi", framing sets the ROI box (1:1 for both); for
"full", it is the calibration body-region hint (the stamped space stays
camera-fov-v1).
All four {full, roi} × {full-body, upper-body} combinations are valid.
3. Consuming frames: onNormalizedTrackingFrame
Rather than branching on coordinateSpace yourself, let the SDK normalize.
game.onNormalizedTrackingFrame(listener, { replaceFrame: true }) converts
landmarks into one unified screen-normalized [0, 1] space regardless of
the underlying mode (roi-centered-v2 values are mapped (v + 1) / 2 and
clamped):
const game = createBodyLinkGame({ gameId: 'my-game' });
game.onNormalizedTrackingFrame((event) => {
const { frame, pointer } = event;
// frame.landmarks are screen-normalized [0,1] (replaceFrame: true).
const rightWrist = frame.landmarks?.[LANDMARKS.RIGHT_WRIST];
if (rightWrist) {
drawCursor(rightWrist.x * canvas.width, rightWrist.y * canvas.height);
}
// pointer is a ready-made [0,1] cursor (fallback chain: right wrist ->
// left wrist -> nose, then hand points).
if (pointer) aimAt(pointer.x, pointer.y);
}, { replaceFrame: true });
One sharp edge: frame.hands is NOT converted. With replaceFrame: true
the frame's landmarks, body, tracks, and pointer are all
screen-normalized, but the raw frame.hands block keeps the source
coordinate space. Do not mix frame.hands.right.index with normalized
landmarks — derive hand positions from the converted landmarks instead
(LANDMARKS.LEFT_WRIST 15, RIGHT_WRIST 16, LEFT_INDEX 19,
RIGHT_INDEX 20), or read the converted copy at frame.normalizedHands.
The full normalization result — raw frame, transform metadata, per-track
normalization — rides on event.normalized if you need it.
4. Directing the host preview: the slot API
game.cameraPreview declares a preview slot — a normalized rectangle the
host composites its own camera pixels into. All coordinates are normalized
[0, 1] in display space, top-left origin.
game.cameraPreview.show(slot); // declare (or replace) the slot
game.cameraPreview.update(patch); // patch the live slot (animate rect, swap roi)
game.cameraPreview.hide(); // remove it — host returns to its opaque default
On the wire this is a single camera.preview.set message; hide() sends
visible: false. Two established patterns cover almost every game:
Pattern A — full-bleed camera background
The slot covers the whole surface, so the host camera shows through behind your (transparent-background) game:
game.cameraPreview.show({
rect: { x: 0, y: 0, w: 1, h: 1 },
mirror: true,
fit: 'cover',
});
Pattern B — positioned picture-in-picture
A corner badge, optionally cropped to a source region:
game.cameraPreview.show({
rect: { x: 0.66, y: 0.66, w: 0.32, h: 0.32 },
roi: { x: 0.30, y: 0.10, width: 0.40, height: 0.70 }, // optional source crop
shape: 'circle', // 'rect' | 'rounded' | 'circle'
mirror: true,
});
Slot fields: rect (destination), roi (optional source crop; omitted ⇒
full-frame cover-crop), mirror (default true), fit ('cover' default |
'contain'), shape (default 'rect'), and showSkeleton. The host-drawn
skeleton is off by default for the play-time preview (a clean camera);
pass showSkeleton: true to opt in — your game already receives landmarks
and can draw its own overlay.
The slot is intent, not command: the host clamps rect to the visible
surface and roi to [0, 1], and drops malformed geometry. The preview slot
and the coordinate mode are independent — a full-bleed slot with roi
coordinates (or vice versa) is perfectly legal.
5. Preview precedence
When multiple preview intents exist in one session, the host resolves them in this order — first match wins:
camera.preview.set slot (API)
> legacy diagnostic camera-preview marker
> manifest launcher.cameraPreview declaration
> roi-mode default (a game whose resolved coordinate mode is 'roi'
gets a player-following preview even with no declaration)
> none (opaque frame, host default)
A live slot always wins; hiding it falls back down the chain. The diagnostic
marker and launcher.cameraPreview paths remain supported for
already-shipped games — new games should use the slot API.
Related pages
- No-Calibration Opt-In — stream pose frames
with no get-ready ceremony (
coordinate.calibration: "none"). - Debug Overlay — the host tracking view (F8), and the
coordinate.debugOverlaymanifest field. - Camera Photo — the single-shot still-photo capability.