PlayKit

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}, ...]

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:

  1. Join. POST /v1/games/{slug}/matches/join (bearer auth) with an optional {"playerCount": N} body (N must be one of the catalog's playerCountOptions; omitted = the game's largest supported count) reserves a seat, creating a new "waiting" match or joining one, and returns {"matchId", "seat", "requiredPlayers", "status"}.
  2. 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 a matchState snapshot with status: "waiting" and a lobby object.
  3. Lobby. A full roster does not start the match on its own. Every seat sends ready, then the host — always the lowest seat number — sends start (see the wire protocol below). The host can also kick a 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 with playerCount: 1) skips the lobby: the bundle readies and starts immediately.
  4. Play. The server broadcasts matchStarted; each client then sends requestState to get the in_progress snapshot. 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 via window.PlayKit.onRemoteEvent(base64Json) and the page's outbound matchAction events onto the socket.
  5. End. matchEnded (Rummy and Dominoes, being single-hand matches, send handEnded instead) carries standings; 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):

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).