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:
win.PlayKit.init(...)must be called with a base64-encoded JSON string, exactly like native'scallInit— the bundle doesJSON.parse(atob(configBase64))internally and treats a malformed value as a fatalINVALID_CONFIGerror.- Because this shim defines
PlayKitNativeon the iframe's window rather thanwindow.webkit.messageHandlers.playkit, the bundle'sbridge.tstakes the Android/web branch and callswindow.PlayKitNative.postMessage(JSON.stringify(message))— a JSON string, matching the snippet above. - If your bundle is served cross-origin from your host page, you cannot
reach
iframe.contentWindow.PlayKitdirectly due to same-origin policy — in that case the game bundle itself would need to be modified to also acceptwindow.postMessagefrom the parent, which the current bundles do not implement. Serve the bundle same-origin (e.g. proxy/static/bundles/through your own domain) to use the direct-call approach above as-is.
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
- Join:
POST /v1/games/{slug}/matches/join(bearer auth, optional{"playerCount": N}body whereNis one of the catalog entry'splayerCountOptions) →{matchId, seat, requiredPlayers, status}. - Open a WebSocket from your host page (not the iframe) to
wss://your-backend/v1/ws/match/{matchId}withAuthorization: Bearer <token>— note this must be set as a real header on the handshake, which a plain browsernew 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. - Relay every inbound server push into the iframe:
win.PlayKit.onRemoteEvent(btoa(JSON.stringify(pushPayload))), but only afterinit/start()have already run — an earlier push throws inside the bundle sincewindow.PlayKitdoesn't fully exist yet. Buffer pushes that arrive beforestart()completes and flush them after, exactly like both native SDKs'MatchSocket.beginDelivering(). - Relay the bundle's outbound
matchActionevents (payload:{matchId, action}) onto that same socket asJSON.stringify(action). - 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/kickmessages travel through the samematchActionpath, and the server'slobbyUpdate/matchStartedpushes come back through step 3. Full message shapes are inREADME.md, "Lobby wire protocol". A 1-player match (2048 Versus withplayerCount: 1) skips the lobby. - 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 akickedpush 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'sgameOverevent carries the final standings inboard.
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
- Matches never start / hang forever. If you run your own reverse proxy
or gateway in front of a self-hosted PlayKit backend, confirm that
backend runs with
--workers 1(see rootDEPLOY.md) — the realtime connection manager is in-process, so a second worker never sees matches created on the first, and any non-sticky load balancing in front of it has the same effect. - 401 on
/v1/auth/device. YourX-PlayKit-Keydoesn't match the project database this backend deployment actually runs against — a key is permanently bound to one project's database. Re-verify after any backend redeploy/migration. window.PlayKitisundefinedwhen yourloadhandler runs. Some browsers fireiframe.onloadbefore the bundle's own script has finished executing in slow-network conditions; poll foriframe.contentWindow.PlayKitor wait a tick before callinginit, matching how native's WebView navigation-finished callback already accounts for this timing.- A bundle served cross-origin never receives
init. Same-origin policy blocksiframe.contentWindowproperty access entirely; you'll see a browser console security error, not a PlayKit error. Serve the bundle same-origin as described in step 3.