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.

ts
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 ask

Asking for configuration #

Two calls, in this order:

  1. carver.onInit(handler) subscribes to carver:init and every later carver:ice refresh, and returns an unsubscribe function. It sends carver:init-ack back automatically, so the shell can prove your game actually took its configuration.

  2. carver.requestInit() asks the shell to send (or re-send) carver:init.

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.

FieldTypeWhat it is
sessionTokenstringBearer token for your own backend. A secret.
localestringLanguage 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.
extraRecord<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.

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:

ts
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:

ts
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:

ts
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.

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:

ts
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.

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.

MessageDirectionvWhat it is for
carver:readygame → shellnoFirst frame rendered — the shell hides its loader.
carver:progressgame → shellnoLoad / level progress, clamped to 0–100.
carver:errorgame → shellnoFatal error — the shell swaps in an error card.
carver:eventgame → shellnoFree-form play data. Shell-local; never relayed.
carver:scoregame → shellnoScore for player-profile stats.
carver:request-fullscreengame → shelloptionalAsk the shell to go fullscreen.
carver:exitgame → shelloptionalGame finished — hand control back to the shell.
carver:identity-requestgame → shellnoInternal to getIdentity().
carver:identity-resultshell → gamenoInternal to getIdentity().
carver:init-requestgame → shellyesAsk the shell to send (or re-send) carver:init.
carver:initshell → gameyesRuntime configuration — the table above.
carver:init-ackgame → shellyesSent automatically by onInit().
carver:iceshell → gameyesFresh ICE servers, replacing what carver:init carried.
carver:ice-expiredgame → shellyesCurrent ICE credentials stopped working. No credential material.
carver:pauseshell → gameyesBackgrounded — halt timers, audio, render loop.
carver:resumeshell → gameyesForegrounded again.
carver:telemetrygame → shellyesOne 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 #

What's next #