PlayKit

Web integration

There is no separate "PlayKit Web SDK" package. A game bundle is a plain static HTML/JS/CSS site; your host page plays the role that native code plays on iOS/Android — it fetches the catalog, authenticates a device over plain HTTP/WS, embeds the bundle in an iframe, and implements the same native-bridge contract the bundle already expects, using postMessage/ direct contentWindow calls instead of WKScriptMessageHandler or addJavascriptInterface.

This doc is written directly against the real contract in game-core/src/core/lifecycle.ts and game-core/src/core/bridge.ts — no part of it is invented.

How the contract actually works (read this first)

From lifecycle.ts, every game bundle installs, on its own window:

window.PlayKit = {
  init(configBase64: string): void,      // base64-encoded JSON: {seed, theme?, debug?, spectateMatchId?}
  start(): void,
  pause(): void,
  resume(): void,
  onRemoteEvent(eventBase64: string): void, // base64-encoded JSON, for realtime match pushes
};

From bridge.ts, every game bundle reports events outward by checking, in order:

window.webkit?.messageHandlers?.playkit?.postMessage(message)   // iOS convention
// else
window.PlayKitNative?.postMessage(JSON.stringify(message))       // Android/web convention — a JSON *string*
// else
console.debug('[PlayKit -> native]', message)                    // standalone browser fallback

where message is { type, payload, seq, ts } and type is one of score, gameOver, achievement, error, or matchAction (see game-core/src/core/events.ts's PlayKitEventMap).

The faithful web-host equivalent: your host page defines window.PlayKitNative = { postMessage(json) { ... } } on the iframe's own contentWindow, before the bundle's script runs, and communicates with the iframe's window.PlayKit object directly (same-origin) or via postMessage (cross-origin) to call init/start/onRemoteEvent. This mirrors exactly how native does it — native never uses postMessage either, it evaluates window.PlayKit.init(...) directly because it fully controls the WebView's JS context. A web host page embedding a same-origin-permitted iframe can do the same by reaching into iframe.contentWindow.

1. Authenticate the device

const res = await fetch(`${BASE_URL}/v1/auth/device`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json', 'X-PlayKit-Key': API_KEY },
  body: JSON.stringify({ deviceHash: deviceHash(), externalUserId: null }),
});
const { playerId, token, expiresIn } = await res.json();

deviceHash can be any stable per-browser identifier you generate and persist (e.g. a random UUID in localStorage, sha256'd — there's no platform-provided hardware id on the web). Cache token and refresh it before expiresIn seconds elapse; use it as Authorization: Bearer <token> on every other call.

2. Fetch the catalog

const res = await fetch(`${BASE_URL}/v1/catalog`, {
  headers: { Authorization: `Bearer ${token}` },
});
const entries = await res.json();
// [{ slug, gameId, displayName, latestVersion, minSdkVersion, bundleUrl, bundleChecksum, iconUrl,
//    playerCountOptions }, ...]

playerCountOptions is null for a single-player game, otherwise every player count that game's real-time engine accepts ([2], [2, 3, 4], [1, 2, 3, 4] for 2048 Versus where 1 is a solo real-time match) — use it to tell the two kinds of game apart and to offer a player-count choice before joining a match.

bundleUrl is a base directory ending in / — e.g. https://playkit.in/static/bundles/2048/1.0.0/. Point your iframe's src at {bundleUrl}index.html directly; the browser serves the bundle's own assets relative to that (no need to download/verify each file yourself the way native does its own on-disk cache — the browser's HTTP cache does this for you).

gameId is the backend's own stable primary key for the game — use this (not slug) as the key for any of your own per-game data (favorites, analytics, ...), since a slug is a display/URL convenience the catalog could in principle rename later.

iconUrl is a plain 512×512 PNG (e.g. https://playkit.in/static/game-icons/2048.png) suitable for a game picker grid/list — just <img src="{iconUrl}">. It's deliberately PNG, not SVG, so it renders with zero extra work in every context (an <img> tag, a native AsyncImage/Coil/UIImage, ...). Regenerated via backend/scripts/generate_game_icons.py whenever a game's assigned emoji changes.

3. Embed the bundle and speak the bridge contract

<iframe id="game" src="https://your-backend/static/bundles/2048/1.0.0/index.html"></iframe>
<script>
  const iframe = document.getElementById('game');

  iframe.addEventListener('load', () => {
    const win = iframe.contentWindow;

    // Faithful native-bridge shim: define PlayKitNative on the IFRAME'S window,
    // exactly like Android's addJavascriptInterface registers "PlayKitNative"
    // on the WebView's page before any script runs.
    win.PlayKitNative = {
      postMessage(json) {
        const { type, payload } = JSON.parse(json);
        handleBundleEvent(type, payload);
      },
    };

    // Same base64(JSON) shape native uses for init/onRemoteEvent.
    const config = { seed: 42 }; // or { seed, theme, debug, spectateMatchId } as needed
    win.PlayKit.init(btoa(JSON.stringify(config)));
    win.PlayKit.start();
  });

  function handleBundleEvent(type, payload) {
    switch (type) {
      case 'score':
        console.log('score', payload.value, payload.delta);
        break;
      case 'gameOver':
        console.log('gameOver', payload);
        submitReplay(payload); // see below — the SDK does this for you natively; on web you own it
        break;
      case 'achievement':
        console.log('achievement', payload.id, payload.value);
        break;
      case 'error':
        console.error('bridge error', payload.code, payload.message, payload.fatal);
        break;
      case 'matchAction':
        // Realtime multiplayer only — forward payload.action onto your own match WebSocket.
        matchSocket.send(JSON.stringify(payload.action));
        break;
    }
  }
</script>

Notes:

4. Single-player: submitting the replay

Unlike the native SDKs, a web host page owns the replay submission itself — there is no SDK-internal networking layer doing it for you. On gameOver, POST the exact seed + move log to the per-game replay endpoint:

async function submitReplay(payload) {
  const res = await fetch(`${BASE_URL}/v1/games/2048/replay`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
    body: JSON.stringify({
      schemaVersion: 1,
      seed: payload.seed,
      moves: payload.moves,           // opaque, game-specific — forward verbatim
      clientScore: payload.finalScore,
      clientBoard: payload.board,     // optional cross-check, game-specific shape
    }),
  });
  const verdict = await res.json();
  // { valid, serverScore, serverBoard, movesApplied, noopMoves, gameOverReached, mismatches, rewardsGranted }
}

The endpoint path and request/response field names are per-game (see backend/app/schemas.py — e.g. ReplayRequest/ReplayResponse for 2048 at POST /v1/games/2048/replay, MemoryMatchReplayRequest for /v1/games/memory-match/replay, etc.) but the shape and flow are identical: seed + the full move log in, an authoritative server-recomputed score/board/validity out. Never trust clientScore/clientBoard yourself — they're only sent for the server's own optional cross-check.

5. Real-time multiplayer

  1. Join: POST /v1/games/{slug}/matches/join (bearer auth, optional {"playerCount": N} body where N is one of the catalog entry's playerCountOptions) → {matchId, seat, requiredPlayers, status}.
  2. Open a WebSocket from your host page (not the iframe) to wss://your-backend/v1/ws/match/{matchId} with Authorization: Bearer <token> — note this must be set as a real header on the handshake, which a plain browser new WebSocket(url) cannot do. Send the token some other way your backend deployment supports (e.g. a signed short-lived query param your own gateway validates and strips before proxying to PlayKit, or a WebSocket subprotocol) — this is a genuine web-platform limitation the native SDKs sidestep by using a real WebSocket client library that does support custom headers.
  3. Relay every inbound server push into the iframe: win.PlayKit.onRemoteEvent(btoa(JSON.stringify(pushPayload))), but only after init/start() have already run — an earlier push throws inside the bundle since window.PlayKit doesn't fully exist yet. Buffer pushes that arrive before start() completes and flush them after, exactly like both native SDKs' MatchSocket.beginDelivering().
  4. Relay the bundle's outbound matchAction events (payload: {matchId, action}) onto that same socket as JSON.stringify(action).
  5. That relay is all the lobby needs too. A full roster does not start the match: the bundle shows its own lobby overlay, every player readies up, and the host (lowest seat number) starts — those ready/start/ kick messages travel through the same matchAction path, and the server's lobbyUpdate/matchStarted pushes come back through step 3. Full message shapes are in README.md, "Lobby wire protocol". A 1-player match (2048 Versus with playerCount: 1) skips the lobby.
  6. Treat these server close codes as final and do not reconnect: 4000 (this player connected again from elsewhere), 4403 (kicked by the host / not a participant — the bundle receives a kicked push first), 4404 (no such match), 4410 (match already finished or started without this player). Reconnect with backoff on anything else (network drops). There is no replay submission for real-time games — the server settles the match itself and the bundle's gameOver event carries the final standings in board.

6. Spectator mode

Open a read-only WebSocket to wss://your-backend/v1/ws/spectate/{matchId} with the same bearer-token caveat as above. matchId itself is the "invite code" — any player token valid for the match's project can watch. Only {"type":"ping"} and {"type":"requestState"} are honored inbound; everything else is silently ignored server-side (spectators cannot mutate match state — there is no handle_message path for them at all). Relay inbound pushes into a read-only-rendering iframe the same way as step 3 above, initializing that iframe's config with spectateMatchId set ({ seed, spectateMatchId: matchId }) so the bundle's own controller renders read-only and never emits matchAction.

Content safety & audience

PlayKit's catalog targets a 10–15 year old audience. If your site serves a different or broader audience, review the catalog's content and copy for fit, and consider gating which games you actually surface.

Troubleshooting