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:
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 (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
- Join:
POST /v1/games/{slug}/matches/join(bearer auth, optional{"playerCount": N}body whereNis one of the catalog entry'splayerCountOptions) →{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 generatedroomCodefor you to share (e.g. via a link, seebackend/app/main.py's own/joinfallback page for a worked example), and{"roomCode": "ABC123"}joins that specific private match instead of the open pool. - Open a WebSocket from your host page (not the iframe) to
wss://your-backend/v1/ws/match/{matchId}. A native client setsAuthorization: Bearer <token>as a real header on the handshake, but a plain browsernew 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 (seebackend/app/security/deps.py'sget_ws_player), checked as a fallback whenever noAuthorizationheader 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. - 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.