Skip to content

Host protocol ​

This reference defines wire protocol 2: transport, host commands and game messages. For a working handshake, follow Host integration. For game behavior, use Game lifecycle and Game requirements. Manifest metadata is defined in Game manifest. See Packages and compatibility for version identities and distribution.

Transport ​

Messages are plain JSON objects {t: <name>, ...payload}. One message is at most 4 KB.

  • In an iframe (the normal case). When its module first runs, the kit posts {pp: 2, t: 'hello', id, build, kit, caps} to window.parent with target origin location.origin. The shell answers by posting {pp: 2, t: 'init', ...} (host commands) to the frame's window with one MessagePort in the transfer list. From then on both sides use only that port. The kit repeats hello every 250 ms until init arrives (the shell may attach its listener late), for at most 10 s; after that it runs stand-alone. The shell checks event.origin and event.source.
  • Top-level in a native web view. If window.parent === window and window.__pp exists (or the portal:host event fires), the kit uses the v1 host bridge (__pp.send(name, payload) / portal:command) with the same message names.
  • Stand-alone (no parent, no host): the kit invents an init from env(safe-area-inset-*) and URL parameters and sends itself start on the first tap. ?pptest=1 turns on the test hook.

Native bridge ​

Install window.__pp.send(name, payload) before the game SDK starts. Outbound messages pass the message name as the first argument and the remaining fields as the payload; they do not use the iframe's pp envelope. For inbound commands, dispatch portal:command on the game window with detail: { cmd, payload, seq }:

js
window.dispatchEvent(new CustomEvent('portal:command', {
  detail: { cmd: 'pause', payload: {}, seq: 7 },
}));

Respond to hello with the init payload within 500 ms of host detection. Otherwise the SDK synthesizes initialization with container: 'app'. Initialization is accepted once per document. If the native bridge is installed later, dispatch portal:host after installation to announce it; the SDK sends hello again. A late announcement does not replace initialization that already happened in standalone mode.

Same-origin calls ​

A game in a same-origin iframe shares the shell's main thread and its user gestures. Three things cannot wait for a message to be delivered, so the kit exposes them as plain functions on the game's window. A shell that cannot reach them (a game in its own native web view) simply does not call them; a game must work without them.

window.__ppKit = { v, gesture(), pointer(ev), busy(on) }

CallWho calls it, whenWhat the kit does
gesture()the shell, synchronously inside every pointerdown, pointerup, touchend and click handler of its own, for the live game and the warm onecreates or resumes the one AudioContext inside the user's gesture, silently. Never starts a sound, never starts a run
pointer({type: 'move'|'up'|'cancel', id, x, y})the shell, for the pointer named in start.input when it was sent with held: true, until that pointer lifts. x, y normalised 0..1 in the game's documentfeeds the event into the same input book as a real pointer in the game's document, so a hold, a drag, an aim or a stick goes on from the touch that started the run. up or cancel releases it. The kit also releases it on pause, blur, hidden, over, dispose and on the next start
busy(on)the shell: true the moment a finger lands on the feed or the column moves, false when the column is at rest and no finger is downwhile busy the kit starts no new loading step (ctx.breathe() and the kit's own loader wait), compiles no shader and uploads no texture; a step already running finishes. Play is not affected. The kit drops the flag by itself after 3 s without a new busy(true)

Loading must be cooperative for this to work: see cooperative loading.

How the shell uses them, so that a game is not surprised: busy(false) goes out when no finger of the feed is down, also while a finger handed over to the game is still down; busy(true) is repeated every second while it holds; gesture() may also be called inside a pointercancel handler.

Shared audio ​

In WebKit an AudioContext runs only when it was made or resumed inside a tap, and a frame that is mounted after the last tap cannot start one of its own: such a game stays silent until it is tapped. So in the feed the sound of every game goes through one AudioContext that the shell's document owns. Once a tap has started it, it runs for the session.

window.__ppAudio = { v: 1, ctx, open(win), shut(win), unlock() } on the shell's window, there before the first game frame is mounted. The kit looks for it as window.parent.__ppAudio inside a try, once, when the game's audio is made.

MemberWhat it is
ctxthe session's AudioContext. No game suspends, resumes or closes it
open(win)the GainNode that is the channel of the game whose window is win. The game's whole output goes into it and nowhere else. The shell holds its gain at 0 and opens it only for the game in front that is neither paused nor leaving
shut(win)the game is done (dispose); the shell disconnects the channel
unlock()asks the context to run. The shell calls it inside every gesture handler of its own; the kit calls it from its own listeners in the game's document while the sound is locked

What follows for a game: audio.ctx of the kit is a facade over the session's context, not a context. Nodes are made with its factory methods (audio.ctx.createGain(); new GainNode(audio.ctx) throws), its destination is the game's own output, suspend() and close() do nothing, and everything made or listened to through it is let go at dispose. A game that finds no seam (run alone, in a native web view of its own, or with a shell that has none) builds its own context as before and must work that way too. Rule for game code: audio requirements.

Game messages ​

tPayloadWhen
hello{id, build, kit, caps: string[]}first thing (see transport). caps may include restart, quality, attract, haptic
progress{p: 0..1, stage: 'assets'|'shaders'|'world'}while loading, at most 10 per second. A duty, not a nicety: it is the sign of life the shell's watchdog counts (startup failures). The kit sends it for its own warm-up steps; the game calls ctx.progress after each file of its own
loaded{ms}everything that does not need frames is done (assets decoded, shaders compiled, world built)
ready{ms, frames: 10}loaded plus at least 10 consecutive frames of 50 ms or less; the armed frame is on screen. ms = performance.now() in the game document
state{s: 'loading'|'armed'|'playing'|'paused'|'over'|'disposed', run, why?}on every change. run counts runs from 1. why on paused: 'cmd'|'hidden'|'blur'
first{run, ms}the first frame of a run in which the game has applied a player input. Honest: sent only after an update that consumed a press some input scheme took (the starting tap counts when start.input was given). A run that ends untouched sends no first; the shell must cope with that. Also carries sound: 'running'|'locked': whether the game's AudioContext runs at that moment. T_control is measured from the touch-down of the starting gesture (the shell's stamp) to this message
sound{run, sound: 'running'|'locked'}when that changes after first. While the game in front is locked the shell shows "Tap for sound"; the next tap in the game unlocks
score{run, value}the kit's HUD shows it; sent to the shell at most 4 per second (for the strip, if it wants it)
payoff{run, kind: 'reward'|'milestone'|'nearmiss'|'combo'|'win'|'fail', value?}each reward or fail moment that has a sound and a visual; at most 10 per second
over{run, score, best, newBest, unit, summary?}once per run, before the state change to over, with the final score. best is after this run. summary is one short plain line, optional
haptic{kind: 'tick'|'light'|'medium'|'heavy'|'success'|'fail'}a container with haptics plays it; elsewhere it is dropped
perf{fps, p95, over50, scale, dpr, tier, throttled}every 2 s while playing
beat{n, frames}every 1 s while not paused and not disposed (the native watchdog's heartbeat)
ack{seq, s, muted, tier}within 250 ms of every command
error{fatal: boolean, msg}fatal: the game cannot go on (also on webglcontextlost that is not restored within 1 s). The shell skips or replaces the card

Host commands ​

Every command carries seq (a counter). The game answers each with ack.

tPayloadThe game must
init{v: 2, muted, insets: {top, right, bottom, left}, tier: 'auto'|'low'|'mid'|'high', dprCap: number|null, reducedMotion, best: number|null, seed: number, test: boolean, capture?: boolean, container: 'browser'|'app'}once, with the port. Start loading if it has not; lay out with these insets. The shell sends tier: 'auto' only to the first game of a session and afterwards the tier that game reported in ack.tier, so the device probe runs once (a second game's init may wait up to 1.5 s for it). capture: true (the pipeline's preview recording): HUD, control glyphs and toasts are not drawn
start{muted, seed?, input?: {x, y, type, id?, held?}}from armed or over (or playing: restart): begin a run within one frame. If input is given, the touch that started the run is also the run's first input at that point (normalised 0..1 in the game's document). held: false or absent: a short tap (press and release) for every scheme whose area holds the point. held: true: the finger is still down; it is a press that stays down and is continued by __ppKit.pointer with the same id (same-origin calls). The shell ignores taps for 500 ms after over, so a tap meant for the game does not start the next run; a held start from the over card begins after that guard
pause{}freeze as in lifecycle requirements; keep the run
resume{}continue the same run; clamp the first frame's delta; if armed or over, stay there
mute / unmute{}master volume 0 / back. unmute never starts sound the game had not started. mute is binding in every state (loading, armed, playing, paused, over): silence within 250 ms
layout{insets}re-lay out (rotation, strip change)
quality{tier, dprCap?}apply at run time (Low Power Mode, heat)
dispose{}tear down as in lifecycle requirements, then ack; after that the shell blanks and removes the frame

The shell pairs pause with silence: after pause no sound may be heard within 250 ms.

When the shell starts a run from a touch: a tap gives input.held false. A touch that stays down (a press held 300 ms without moving, on a game whose scheme is hold; or a drag that leaves the 10 px slop mostly sideways, on a game whose scheme is drag, aim, stick or swipe) starts the run at once with held: true and is handed over. A drag that leaves the slop mostly up or down always moves the feed and never starts a run. A drag that leaves the slop and comes back is not a tap either.

Startup failures ​

  • The first game of a session is created when the first card has shown motion (or autoplay was refused), at the latest 4 s after the open; a game the player asks for (a tap, a hand-over, a retry) is created at once.
  • A boot: "blocking" game is created on the tap only (cooperative loading).
  • Signs of life: a message hello, progress or state of the game, and a resource that finished loading in the game's frame. A beat is not one. The shell fails a card after 4 s of silence before hello and 10 s of silence before ready, and in any case 45 s after the mount. Silence is counted, not time since the mount: a game that is still arriving on a slow link is not a failed game.
  • Reasons the failed card can give: slow (the game had shown a sign of life), timeout (it had shown none; also a document that is not there), offline (the network is gone), crash (a fatal error, or the frame died). "Try again" loads the same hashed files, which are cached, so it does not start from zero.
  • While it waits the card shows its loop, the cue "Starting" and the progress.

Wire protocol 2 · Manifest 2 · SDK runtime 0.2.1