PlayKit

Web integration

@playkit/web is the same configure()/present() shape as the native Android/iOS SDKs, packaged from web/playkit/ in this repo — it owns device auth, the catalog, bundle loading, the real-time match socket, and single-player replay submission internally, so your host page never talks to any of those backend routes directly. Use it unless you have a specific reason not to take an npm dependency, in which case the manual integration section below documents the exact same underlying contract the SDK implements, with nothing hidden.

Quickstart

npm install @playkit/web
import { configure, gameCatalog } from '@playkit/web';

configure({ apiKey: 'pk_live_...', baseUrl: 'https://playkit.in' });

const games = await gameCatalog();
const game = games.find((g) => g.slug === '2048')!;

gameCatalog() resolves to an array shaped exactly like GET /v1/catalog's response (the SDK does the device-auth + catalog fetch for you internally) — here's one real entry from it:

{
  "slug": "2048",
  "gameId": "1b3b4a85-e1ec-45b1-8545-9cb42ea44624",
  "displayName": "2048",
  "latestVersion": "0.1.0",
  "minSdkVersion": "0.1.0",
  "bundleUrl": "https://playkit.in/static/bundles/2048/0.1.0/",
  "bundleChecksum": "79ee7f6748f6478b7be6862d701178afcad10af3abd09f0a16df49c3dadfc665",
  "iconUrl": "https://playkit.in/v1/game-icons/2048",
  "playerCountOptions": [1, 2, 3, 4]
}

Now embed it and listen for events:

import { present } from '@playkit/web';

const handle = present(document.getElementById('game')!, game, { seed: Date.now() }, (event) => {
  switch (event.type) {
    case 'score':
      console.log('score', event.payload.value, event.payload.delta);
      break;
    case 'gameOver':
      console.log('gameOver', event.payload);
      break;
    case 'replayResult':
      // The SDK already submitted the replay for you — this is the server's verdict.
      console.log('replay verdict', event.payload.valid, event.payload.serverScore);
      break;
    case 'error':
      console.error('PlayKit error', event.payload.code, event.payload.message);
      break;
  }
});

// handle.pause() / handle.resume() / handle.restart(seed) / handle.destroy()

Every event handed to that callback is a real, discriminated { type, payload } object — no parsing needed. Two worth seeing in full, since they carry the most fields: a score event fires on every scoring move, and gameOver is immediately followed by a replayResult (or an error with code REPLAY_SUBMIT_FAILED if the submission itself failed) once the SDK's own internal replay POST comes back:

// event.type === "score"
{ "type": "score", "payload": { "value": 24, "delta": 8 } }

// event.type === "gameOver"
{
  "type": "gameOver",
  "payload": {
    "seed": 1758783,
    "finalScore": 292,
    "board": [[2, 4, 0, 0], [8, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0]],
    "movesPlayed": 41,
    "won": false,
    "moves": [{ "dir": "up", "t": 812 }, { "dir": "left", "t": 1190 }]
  }
}

// event.type === "replayResult" (arrives right after the gameOver above)
{
  "type": "replayResult",
  "payload": {
    "valid": true,
    "serverScore": 292,
    "serverBoard": [[2, 4, 0, 0], [8, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0]],
    "movesApplied": 41,
    "noopMoves": 0,
    "gameOverReached": true,
    "mismatches": [],
    "rewardsGranted": []
  }
}

For a real multiplayer match, join first and pass the result as config.match — the SDK opens and owns the match WebSocket from your host page itself (never inside the game's iframe, so the player's bearer token never reaches the less-trusted game bundle's JS context):

import { joinMatch } from '@playkit/web';

const match = await joinMatch(game, { playerCount: 2 }); // or { createRoom: true } / { roomCode: 'ABC123' }
present(document.getElementById('game')!, game, { seed: Date.now(), match }, onEvent);

joinMatch() resolves to:

{
  "matchId": "2ccc2030-4c1c-4ac0-9159-aea71f7bc08d",
  "seat": 0,
  "requiredPlayers": 2,
  "status": "waiting",
  "roomCode": null
}

status is "waiting" until every seat has both joined AND connected over the match's own WebSocket (which present() opens for you the instant it's called) — roomCode is only non-null when you passed createRoom: true or joined an existing one with roomCode: "ABC123".

configure/identify/gameCatalog/joinMatch/present mirror the native SDKs' calls of the same name field-for-field; getProfile/updateProfile/ blockPlayer/unblockPlayer/blockedPlayers/reportPlayer/ submitContactRequest are the same profile/player-safety passthroughs too. Full types are in the package's own .d.ts (web/playkit/src/types.ts in this repo).

Cross-origin embedding (CORS)

If your host page is served from a different origin than your PlayKit backend, the backend must allow it explicitly — by default every project is same-origin only (no CORS headers sent at all, identical to how the API always behaved before this existed). An admin adds your page's origin (e.g. https://mygame.example.com) to your project's allow-list from the admin panel's Projects screen (or PUT /v1/admin/projects/{id}/allowed-origins directly), scoped per project — not a global switch — so a leaked API key still can't be used from an arbitrary attacker-controlled origin. See backend/app/security/cors.py for exactly what this does and doesn't cover (game bundle asset files themselves are always loadable cross-origin, since they carry no credential and aren't sensitive — only the /v1/* data API is gated per origin).

Manual integration (no SDK)

Everything below is what @playkit/web does internally, 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. Read this if you're building your own integration instead of taking the npm dependency, debugging the SDK itself, or just curious what's under the hood.

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 (and unlike @playkit/web's own present(), which does this step for you internally), a manually-integrated web host page owns the replay submission itself — there is no networking layer doing it for you outside the SDK. 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, roomCode}. Two more request fields cover private rooms, mutually exclusive with each other and with plain open matchmaking: {"createRoom": true} always creates a brand-new private match and returns its generated roomCode for you to share (e.g. via a link, see backend/app/main.py's own /join fallback page for a worked example), and {"roomCode": "ABC123"} joins that specific private match instead of the open pool.
  2. Open a WebSocket from your host page (not the iframe) to wss://your-backend/v1/ws/match/{matchId}. A native client sets Authorization: Bearer <token> as a real header on the handshake, but a plain browser new WebSocket(url) cannot set custom headers at all — for exactly this reason, the backend already accepts the same bearer token as a ?token=<token> query parameter on this one route (see backend/app/security/deps.py's get_ws_player), checked as a fallback whenever no Authorization header is present. No gateway or signing step is needed — pass the player token you already have straight through. The one tradeoff: unlike the header form, this puts a short-lived, player-scoped token in the URL, so it can show up in server access logs and browser history — acceptable for a token that expires within the hour, but worth knowing.
  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