Integrating PlayKit into a host app
PlayKit is a catalog of 200+ mini-games (single-player and real-time multiplayer) served as versioned web bundles (HTML/JS/CSS) from a FastAPI backend. A host app — an existing iOS app, Android app, or website — embeds PlayKit's games without owning any of the game logic itself: the backend issues players, serves the catalog, hosts the game bundles, validates single-player sessions server-side, and runs real-time multiplayer matches over WebSockets. The host app just needs to authenticate, launch a game view, and react to events.
The three integration paths
| Platform | What you use | Doc |
|---|---|---|
| Native iOS app | The PlayKit Swift Package (wraps a WKWebView) |
ios.md |
| Native Android app | The com.playkit.sdk Gradle module (wraps a WebView via Jetpack Compose) |
android.md |
| Web app | No SDK — embed a game bundle URL in an iframe and speak the same JS contract directly |
web.md |
There is no separate "web SDK" package. Every game bundle is a plain static
site driven by window.PlayKit.init/start/pause/resume/onRemoteEvent
(defined in game-core/src/core/lifecycle.ts) and it reports events back out
via window.webkit.messageHandlers.playkit (iOS) or
window.PlayKitNative.postMessage(json) (Android/web) as implemented in
game-core/src/core/bridge.ts. The iOS and Android SDKs are thin, real
implementations of exactly this contract; a web host app implements the same
contract itself, as documented in web.md.
Shared backend concepts
All three platforms talk to the same HTTP + WebSocket API. Read
backend/app/routers/auth.py, backend/app/routers/catalog.py,
backend/app/realtime/router.py, and backend/app/realtime/runtime.py if
you want to go straight to source.
Project / API key
Every request is scoped to one project (one games catalog + database).
The project's public key is sent as the X-PlayKit-Key header on the one
endpoint that doesn't yet have a player token: POST /v1/auth/device. A key
is minted with python -m scripts.create_project --name "..." on the
backend and looks like pk_live_<hex>. A key is permanently tied to one
project's database — see Troubleshooting below.
Device auth → player JWT
Every device gets an anonymous player record the first time it calls:
POST /v1/auth/device
Header: X-PlayKit-Key: pk_live_...
Body: {"deviceHash": "<sha256 hex>", "externalUserId": "<optional string|null>"}
200: {"playerId": "<uuid>", "token": "<jwt>", "expiresIn": <seconds>}
deviceHash is any stable per-device identifier the client hashes itself
(iOS uses UIDevice.identifierForVendor, Android a UUID persisted in
SharedPreferences). The returned token is a bearer JWT used as
Authorization: Bearer <token> on every other call, including the WebSocket
handshake. externalUserId lets a host app attach its own logged-in user id
to the anonymous player record later (an "identify" call); it's optional and
can be null.
Catalog
GET /v1/catalog
Header: Authorization: Bearer <player token>
200: [{"slug": "...", "gameId": "<uuid>", "displayName": "...",
"latestVersion": "...", "minSdkVersion": "...",
"bundleUrl": "https://host/static/bundles/<slug>/<version>/",
"bundleChecksum": "<hex>",
"iconUrl": "https://host/static/game-icons/<slug>.png",
"playerCountOptions": [2, 3, 4] | null}, ...]
bundleUrlis a base directory, not an archive. Fetch{bundleUrl}files.json({"files": [{"path", "sha256", "size"}, ...]}), then GET each listed file individually and verify its sha256.minSdkVersionis checked by native SDKs against their own build version before loading a game bundle.gameIdis the backend's stable primary key for the game — persist this (notslug) if you key your own data off a game.iconUrlis a plain 512×512 PNG for a picker grid.playerCountOptionsis how you tell single-player and multiplayer games apart:nullfor a single-player game, otherwise every player count that game's real-time engine accepts (Tic-Tac-Toe Versus:[2], Ludo:[2, 3, 4], 2048 Versus:[1, 2, 3, 4]—1is a genuine solo real-time match, currently only 2048 Versus offers it). Use it to drive a "how many players?" choice before joining; both native demo apps do exactly that.
Single-player: seed + replay
Single-player games are deterministic from a server-issued seed. The
host launches the game with a seed (a uint32 it can choose, or reuse), the
web bundle plays out entirely client-side, and on game over the SDK itself
(not the host app) POSTs the full move log to POST /v1/games/{slug}/replay.
The backend replays the same seed + moves server-side and returns the
authoritative score/board/validity — this is the anti-cheat boundary. A
client-supplied board is never trusted; only seed + moves are replayed.
Each game has its own replay schema (see backend/app/schemas.py, e.g.
ReplayRequest/ReplayResponse for 2048), but the shape and flow are
identical across games.
Real-time multiplayer
17 games are server-authoritative real-time matches: Ludo, Rummy, Poker, Checkers (Live), Dots and Boxes, Uno, Dominoes, and the ten "Versus" games (2048, Tic-Tac-Toe, Snakes and Ladders, Connect Four, Reversi, Territory Grab, Nim Wars, Word Chain, Auction House, Backgammon). The flow is the same for all of them:
- Join.
POST /v1/games/{slug}/matches/join(bearer auth) with an optional{"playerCount": N}body (Nmust be one of the catalog'splayerCountOptions; omitted = the game's largest supported count) reserves a seat, creating a new "waiting" match or joining one, and returns{"matchId", "seat", "requiredPlayers", "status"}. - Connect. Open a WebSocket to
/v1/ws/match/{matchId}(bearer token on the handshake — a header, never the query string). The first message you receive is always amatchStatesnapshot withstatus: "waiting"and alobbyobject. - Lobby. A full roster does not start the match on its own. Every
seat sends
ready, then the host — always the lowest seat number — sendsstart(see the wire protocol below). The host can alsokicka seat, which frees it for a new player via ordinary matchmaking. The game bundle renders the lobby UI and sends these messages itself (game-core/src/core/lobby.ts); a host app only has to relay messages. A 1-player match (2048 Versus withplayerCount: 1) skips the lobby: the bundle readies and starts immediately. - Play. The server broadcasts
matchStarted; each client then sendsrequestStateto get thein_progresssnapshot. Gameplay is small JSON messages over that socket; the web bundle never opens the socket itself — native (or the web host page) owns it and relays inbound pushes into the page viawindow.PlayKit.onRemoteEvent(base64Json)and the page's outboundmatchActionevents onto the socket. - End.
matchEnded(Rummy and Dominoes, being single-hand matches, sendhandEndedinstead) carriesstandings; the server settles the match itself — there is no replay submission for real-time games.
Lobby wire protocol
Inbound (client → server) while status == "waiting"; anything else is
rejected with MATCH_NOT_STARTED:
| Message | Who | Rejections |
|---|---|---|
{"type": "ready", "matchId", "payload": {"ready": true\|false}} (ready defaults to true) |
any seat | — |
{"type": "start", "matchId", "payload": {}} |
host only | NOT_HOST, ROSTER_NOT_COMPLETE (a seat is unfilled or not connected), NOT_ALL_READY |
{"type": "kick", "matchId", "payload": {"seat": N}} |
host only | NOT_HOST, MALFORMED, CANNOT_KICK_SELF, SEAT_NOT_FOUND |
Outbound (server → client):
matchState— the snapshot sent on connect and on everyrequestState. While waiting it carrieslobby: {seats: [{seat, playerId, connected, ready, isHost}], hostSeat, requiredPlayers, rosterComplete, allReady}; once started it carries the game's own state instead.playerJoined {seat, playerId, connectedCount, requiredPlayers}andlobbyUpdate {…same shape as lobby…}— broadcast to the other seats as players connect, ready up, disconnect, or are kicked.kicked {seat}— sent only to the removed player, immediately before the server closes their socket with close code 4403.matchStarted {startedAt}, then gameplay events, thenmatchEnded/handEnded {standings, …};playerLeft {seat, reason}/playerReconnected {seat}during play.actionRejected {actionId, code, message, currentStateVersion}for any refused inbound message;pongin reply to{"type": "ping"}.
Every gameplay action carries the client's last-seen stateVersion; a stale
one is rejected with STALE_STATE_VERSION and the client should
requestState. Turn timers run server-side (rules.turnDeadlineMs in the
snapshot): a seat that stalls has a safe default move played for it, and
three consecutive timeouts or 90 s disconnected marks the seat abandoned.
WebSocket close codes the server uses, all terminal (do not reconnect):
4000 replaced by a newer connection from the same player, 4403 kicked /
not a participant, 4404 match not found, 4410 match already finished or
started without you. Both native SDKs stop reconnecting on these and report
them to the host app as a non-fatal MATCH_CLOSED error event.
Spectator mode
Anyone holding a valid player token for the match's own project can watch a
match read-only via /v1/ws/spectate/{match_id} — the matchId doubles as
the unguessable "invite code." A spectator connection gets an initial
snapshot, then live pushes, and can send only {"type":"ping"} or
{"type":"requestState"}; every other inbound message is silently ignored
(there is no handle_message path for spectators — they cannot mutate match
state). See backend/app/realtime/router.py's spectate_socket. Neither
native SDK has a dedicated spectator method yet — see the platform docs for
how to open this socket directly.
Content safety & audience
PlayKit's game catalog is built for a 10–15 year old audience. If your host app serves a different or broader audience, review the catalog's content and copy for fit before embedding it, and consider gating which games from the catalog you actually surface to your own users.
Troubleshooting (applies to every platform)
Matches silently hang under multiple backend workers. The real-time
multiplayer connection manager is in-process and per-worker. If you run
PlayKit's backend behind your own reverse proxy or load balancer (e.g.
self-hosting it behind your own gateway) and that backend runs with more
than one worker process, or your LB isn't sticky, players can land on
different workers and never see each other's moves or the match ever start.
The backend's own docker-entrypoint.sh runs with --workers 1 for
exactly this reason — treat that as a hard constraint, not a tuning knob,
for any deployment your host app points at.
The match never leaves the lobby. Every seat must be connected and ready, and only the host (lowest seat number) can start. A seat that disconnects is un-readied and must ready up again after reconnecting. If a seat was kicked, the roster is incomplete until a new player joins that same match through matchmaking.
401 on /v1/auth/device. An API key is permanently tied to one
project's Postgres database — the key hash and the project's tables live
together. If a deployment's database is ever recreated or the key rotated
without updating your app's embedded key, every device auth call starts
failing with 401. When self-hosting, treat the pk_live_... key you embed
as environment-specific and re-verify it after any redeploy or migration
(see the root DEPLOY.md for a worked example of this exact failure mode).
Base URL
For a quick start against the already-deployed instance, use
https://playkit.in as the base URL (see the root DEPLOY.md). For
your own deployment, substitute your own backend's base URL
(https://your-playkit-host.example.com or http://10.0.2.2:8000 for the
Android emulator against local dev).