Protocol v2
Protocol v2 opens the shell → game direction. Instead of baking a session token, a locale, or TURN credentials into your bundle, the shell hands them to your game at boot — and your game can report structured learning telemetry back on a closed, privacy-reviewed envelope.
It arrived in @carverjs/embed-sdk 1.1.0 and it is fully additive. No v1 message changed shape, subscribe() keeps its exact 1.0.x signature, and a 1.0.x game recompiles against 1.1.0 with no edits at all. If you do not need any of this, you can stop reading — nothing below is required.
import { carver } from "@carverjs/embed-sdk";
const off = carver.onInit((msg) => {
if (msg.type === "carver:init") boot(msg.payload); // acknowledged for you
else refreshIce(msg.payload.iceServers); // carver:ice
});
carver.requestInit(); // subscribe first, then askAsking for configuration #
Two calls, in this order:
carver.onInit(handler)subscribes tocarver:initand every latercarver:icerefresh, and returns an unsubscribe function. It sendscarver:init-ackback automatically, so the shell can prove your game actually took its configuration.carver.requestInit()asks the shell to send (or re-send)carver:init.
A shell may answer carver:init-request synchronously. Call onInit() first and the listener is already installed when the answer arrives; call requestInit() first and you can miss your own reply. requestInit() is safe to call again, so a retry is fine — shells treat a repeat as a re-send, never an error.
Some shells push carver:init unprompted as soon as the iframe loads. Subscribing early covers both, and a shell that sends both an unprompted and a requested carver:init is normal — treat the newest one as current.
What carver:init carries #
Every field is optional. A shell sends what it has, your game requires what it needs, and the two do not have to agree for the channel to work.
| Field | Type | What it is |
|---|---|---|
sessionToken | string | Bearer token for your own backend. A secret. |
locale | string | Language tag the shell wants the game rendered in. |
consent | { granted: boolean } | Whether the player (or their guardian) consented to data collection. |
ice | { iceServers, expiresAt? } | ICE servers for WebRTC, as RTCPeerConnection takes them. TURN entries carry a credential. |
player | { displayName?, id? } | Who the shell says is playing. Display data — not proof of identity. |
room | { roomId, role, signalingToken?, signalingNamespace? } | A multiplayer room the shell already placed this player in. role is "host" or "peer". |
api | { baseUrl: string } | Where your game's backend lives, when the shell chooses it. |
extra | Record<string, unknown> | Shell-specific extras. Unvalidated — narrow it before you trust it. |
The payload type is deliberately generic: this is the engine contract, so it carries no single product's vocabulary. Narrow it to your own strict type on arrival rather than pushing product fields down into the protocol.
player is what the shell chose to tell you about the person on screen. If you need to prove to your own backend which marketplace account is playing, that is still getIdentity(), which returns a signed token you verify server-side.
ICE refreshes #
TURN credentials expire. carver:init may carry an ice.expiresAt instant, and a shell will normally push a fresh carver:ice before that passes — it arrives on the same onInit handler, so keep the handler installed for the whole session rather than unsubscribing after the first message.
If your credentials stop working before a refresh lands, ask for one:
peerConnection.addEventListener("icecandidateerror", () => {
carver.iceExpired(); // "these stopped working" — carries no credential material
});Pause and resume #
carver:pause and carver:resume tell you the shell backgrounded or foregrounded the game — a tab switch, a phone call, a native app going to the background. They arrive through subscribe() like any other inbound message:
carver.subscribe((msg) => {
if (msg.type === "carver:pause") { stopLoop(); muteAudio(); }
if (msg.type === "carver:resume") { startLoop(); unmuteAudio(); }
});Halt timers, audio, and your render loop on pause. A game that keeps a requestAnimationFrame loop and a Web Audio graph running in a backgrounded WebView is the single most common cause of battery complaints on mobile.
Learning telemetry #
carver:telemetry reports one learning interaction as a closed tuple:
carver.telemetry({
objectId: "task-3", // the object the learner acted on
kcCode: "NCERT.G6.FRAC.EQUIV", // knowledge component, or null
success: true,
attempts: 2,
hintsUsed: 1,
latencyBucket: "lt15s", // "lt5s" | "lt15s" | "lt60s" | "gte60s"
misconceptions: ["frac.denominator-added"], // optional
probeItemId: 7, // optional
});There is deliberately no free-form field, because the closed type is the privacy whitelist. A game cannot attach a name, a device id, a raw timestamp, or a session token to a learning event, because there is nowhere to put one. telemetry() copies those eight keys out by hand rather than forwarding your object, so the guarantee holds even in the case TypeScript cannot catch — excess-property checking only fires on a fresh object literal, and carver.telemetry(someVariable) would otherwise have shipped every extra property on it.
Latency is bucketed on purpose: an exact millisecond reading is a behavioural fingerprint, a bucket is not.
carver:telemetry is the only message a shell may relay to a learning-telemetry endpoint. carver:event stays free-form for play stats and — being author-controlled and unvalidated — must never leave the shell. Aggregate it, log it, chart it; do not forward it.
The native bridge #
A game also runs top-level inside a native WebView, where there is no parent frame to post to. When window.parent === window and the shell has injected window.__carverNativeBridge, every outbound message goes through that bridge as JSON text instead:
window.__carverNativeBridge = {
postMessage(json: string) { /* hand to the native side */ },
};That is the shape both WKWebView's messageHandlers and Android's addJavascriptInterface already expose, so a native shell aliases one onto that global. Inbound, the native shell calls window.__carverShellDeliver(msg) — installed by the SDK the moment anything subscribes, and accepting either the object or its JSON string.
The message schemas are identical on both transports. There are no bridge-specific types and no bridge-specific fields; the same carver:init reaches your onInit whichever way it travelled. An iframe always wins — if there is a parent frame, postMessage is the transport, bridge or no bridge. With neither, every call stays the same safe no-op it has always been.
The iframe path structured-clones; the bridge path JSON.stringifys. Every field of every message defined here is a JSON primitive, so those are identical either way. carver:event.payload is free-form and the two serializers disagree at the edges — a cycle clones but does not stringify (the bridge drops the message), a function stringifies-by-omission but does not clone (the iframe drops it), and NaN / Infinity / undefined survive a clone but become null or vanish in JSON. Keep carver:event payloads to plain JSON data and the transports stay indistinguishable.
getIdentity() stays iframe-only and resolves "not-embedded" over the bridge. That is deliberate: it is the marketplace's identity feature, and a native shell already holds the player's session and hands you a sessionToken through carver:init — the same job, done by the transport that actually has the credential.
Message reference #
The whole protocol, v1 and v2. A yes in the v column means the message is new in v2 and always carries the revision; optional marks the two v1 messages that started sending a v in 1.1.0, which an older shell simply ignores.
| Message | Direction | v | What it is for |
|---|---|---|---|
carver:ready | game → shell | no | First frame rendered — the shell hides its loader. |
carver:progress | game → shell | no | Load / level progress, clamped to 0–100. |
carver:error | game → shell | no | Fatal error — the shell swaps in an error card. |
carver:event | game → shell | no | Free-form play data. Shell-local; never relayed. |
carver:score | game → shell | no | Score for player-profile stats. |
carver:request-fullscreen | game → shell | optional | Ask the shell to go fullscreen. |
carver:exit | game → shell | optional | Game finished — hand control back to the shell. |
carver:identity-request | game → shell | no | Internal to getIdentity(). |
carver:identity-result | shell → game | no | Internal to getIdentity(). |
carver:init-request | game → shell | yes | Ask the shell to send (or re-send) carver:init. |
carver:init | shell → game | yes | Runtime configuration — the table above. |
carver:init-ack | game → shell | yes | Sent automatically by onInit(). |
carver:ice | shell → game | yes | Fresh ICE servers, replacing what carver:init carried. |
carver:ice-expired | game → shell | yes | Current ICE credentials stopped working. No credential material. |
carver:pause | shell → game | yes | Backgrounded — halt timers, audio, render loop. |
carver:resume | shell → game | yes | Foregrounded again. |
carver:telemetry | game → shell | yes | One learning interaction, in the closed tuple. |
PROTOCOL_VERSION is exported if you need the number: import { PROTOCOL_VERSION } from "@carverjs/embed-sdk".
Unknown message types are no-ops on both sides, which is what makes the matrix below work.
Compatibility #
| old shell (v1) | new shell (v2) | |
|---|---|---|
| old game (1.0.x) | Unchanged. Nothing in this release touches this pair. | The shell sends carver:init, the game never subscribes, and nothing happens. A v2 shell must therefore not block on carver:init-ack — treat a missing ack as an old game and play it anyway. |
| new game (1.1.0) | The game sends carver:init-request, no answer ever comes, and onInit never fires — so fall back to your own defaults rather than waiting on config that will not arrive. The optional v on carver:exit and carver:request-fullscreen is one extra field an old shell's parser ignores. | Full v2: config on request, ICE refreshes, pause / resume, telemetry. |
Both degrade to no-ops. There is no version negotiation to get wrong.
Pin your parent origin #
By default the only inbound check is source === window.parent — sound, since nothing but the real embedder can be that object, but it does not tell you who the embedder is. A published game is loadable by anyone, so anyone can iframe it, and by framing it their page is window.parent. It can then send a carver:init of its own and choose your signaling backend, your TURN servers, and your API base URL.
carver.configure({ parentOrigin: "https://carverjs.dev" });event.origin is set by the browser and cannot be forged, so this is the difference between "the shell said so" and "somebody said so". Call it before you subscribe. Full trust model: Security & Origins.
What's next #
API Reference — signatures for
requestInit,onInit,telemetry, andiceExpired.Security & Origins — which direction secrets travel, and why.
Player Identity — proving which signed-in player is playing, to your own backend.