PlayKit

Android integration

The Android SDK is a Gradle module at android/playkit/ (package com.playkit.sdk). It wraps a WebView inside a Jetpack Compose composable (PlayKitScreen), bridges JS events back to your callback via addJavascriptInterface, and owns the network calls (device auth, catalog, replay submission is triggered from JS but the match WebSocket is owned by native via OkHttp).

1. Add the module

Add android/playkit as a module dependency (via settings.gradle include(":playkit") + your app module's implementation(project(":playkit")), or publish it to your own Maven repo if you vendor it that way). The module depends on kotlinx-coroutines, kotlinx-serialization-json, androidx.webkit, and OkHttp (for the match WebSocket) — make sure those resolve in your app.

2. Configure the SDK

Call once, early — e.g. Application.onCreate or your launch Activity's onCreate — before calling anything else:

PlayKit.configure(
    context = applicationContext,
    apiKey = "pk_live_...",
    baseUrl = "https://playkit.in", // "http://10.0.2.2:8000" for the emulator against local dev
)
PlayKit.identify(userId = "your-user-id") // optional, safe to call any time

PlayKit (android/playkit/src/main/java/com/playkit/sdk/PlayKit.kt) is the only public entry point — everything else under com.playkit.sdk (PlayKitClient, BundleCache, PlayKitJsBridge, MatchSocket) is internal.

3. Fetch the catalog

Coroutine form:

val entries: List<GameCatalogEntry> = PlayKit.gameCatalog()
// entry.slug, .gameId, .displayName, .latestVersion, .minSdkVersion,
//       .bundleUrl, .bundleChecksum, .iconUrl, .playerCountOptions

Callback form (for non-coroutine callers):

PlayKit.gameCatalog { result ->
    result.onSuccess { entries -> /* ... */ }
    result.onFailure { error -> /* ... */ }
}

Both call GET /v1/catalog under the hood, authenticating with a player JWT fetched/cached automatically via POST /v1/auth/device.

playerCountOptions (List<Int>?) is how you tell the two kinds of game apart: null for a single-player game, otherwise every player count the game's real-time engine accepts (e.g. [2], [2, 3, 4], or [1, 2, 3, 4] for 2048 Versus, where 1 is a genuine solo real-time match). iconUrl is a plain PNG — Coil/Glide render it directly.

4. Identify which games exist

PlayKitGame (android/playkit/src/main/java/com/playkit/sdk/model/PlayKitGame.kt) is the fixed enum of games the SDK knows how to host, keyed by catalog slug:

enum class PlayKitGame(val slug: String, val bundledAssetsPath: String?) {
    Game2048("2048", "2048"),
    MemoryMatch("memory-match", null),
    TicTacToe("tic-tac-toe", null),
    // ... one entry per hosted game ...

    // Real-time multiplayer (isRealtimeMultiplayer == true):
    Ludo("ludo", null),
    Rummy("rummy", null),
    Poker("poker", null),
    CheckersLive("checkers-live", null),
    DotsAndBoxes("dots-and-boxes", null),
    Uno("uno", null),
    Dominoes("dominoes", null),
    Game2048Versus("2048-versus", null),
    TicTacToeVersus("tic-tac-toe-versus", null),
    SnakesAndLaddersVersus("snakes-and-ladders-versus", null),
    ConnectFourVersus("connect-four-versus", null),
    ReversiVersus("reversi-versus", null),
    TerritoryGrabVersus("territory-grab-versus", null),
    NimWarsVersus("nim-wars-versus", null),
    WordChainVersus("word-chain-versus", null),
    AuctionHouseVersus("auction-house-versus", null),
    BackgammonVersus("backgammon-versus", null);
}

Map a catalog entry to it with PlayKitGame.fromSlug(entry.slug); a null result means the catalog has a game this SDK build doesn't know yet. PlayKitGame.isRealtimeMultiplayer is true for exactly the 17 real-time games listed above — those are the games that need joinMatch + a matchId (section 6); everything else is single-player.

5. Launch a single-player game

PlayKitScreen is a @Composable (android/playkit/src/main/java/com/playkit/sdk/PlayKitWebView.kt):

@Composable
fun PlayKitScreen(
    game: PlayKitGame,
    configJson: String,
    onEvent: (PlayKitEvent) -> Unit,
    matchId: String? = null,
)

configJson is a raw JSON string matching the shared config contract: {"seed": <uint32>, "theme": {...}?, "debug": {...}?}. Example:

PlayKitScreen(
    game = PlayKitGame.Game2048,
    configJson = """{"seed": 42}""",
    onEvent = { event -> /* handle */ },
)

PlayKitScreen resolves the bundle (disk cache → download-and-verify → package-bundled fallback, only "2048" ships bundled), loads it into a WebView via WebViewAssetLoader, and calls window.PlayKit.init(...) / .start() once the page finishes loading (onPageFinished).

6. Launch a real-time multiplayer game

Pick a player count from the catalog entry's playerCountOptions, reserve a seat, then pass the returned matchId into PlayKitScreen:

// entry: the GameCatalogEntry the player tapped; game = PlayKitGame.fromSlug(entry.slug)
val count = entry.playerCountOptions?.first() // or a value the player chose from that list
val joined: MatchJoinResult = PlayKit.joinMatch(game, playerCount = count)
// joined.matchId, .seat, .requiredPlayers, .status

PlayKitScreen(
    game = game,
    configJson = """{"seed": 1}""", // seed is still required by the shared contract but carries no semantics here
    matchId = joined.matchId,
    onEvent = { event -> /* handle */ },
)

playerCount is optional; null asks for the game's largest supported count. A callback overload of joinMatch also exists for non-coroutine callers. Internally, PlayKitScreen opens its own MatchSocket (an OkHttp WebSocket to /v1/ws/match/{matchId}, Authorization: Bearer <token> header — never a page-owned new WebSocket(), since a browser socket can't set that header and this also keeps the player JWT out of JS entirely) once the page finishes loading, and relays inbound pushes into the page via window.PlayKit.onRemoteEvent(...) only after init/start() have run (earlier pushes are queued in MatchSocket.beginDelivering(), not dropped).

What happens next needs nothing from your app. A full roster does not start the match: once every seat is connected the game bundle shows its own lobby overlay, each player taps "I'm Ready", and the host (the lowest seat number) taps "Start Match" — the host can also remove a seat, which frees it for a new player via matchmaking. The bundle sends those ready/start/ kick messages through the same matchAction relay as gameplay, so the SDK handles all of it (protocol details in README.md, "Lobby wire protocol"). A 1-player match (2048 Versus with playerCount = 1) skips the lobby and starts immediately.

When the match ends the bundle emits the shared GameOver event (with the final standings in board) and the SDK deliberately skips replay submission — the match was already settled server-side.

If the server closes the match socket for good — the player was kicked (4403), the same player connected from a newer device (4000), the match already finished or started without them (4410), or it doesn't exist (4404) — the SDK stops reconnecting and delivers a non-fatal PlayKitEvent.Error(code = "MATCH_CLOSED", message = "<reason> (close code N)", fatal = false) to onEvent. The bundle already shows the player what happened; treat this as your cue to leave the match screen. Other socket drops (network blips) reconnect automatically with backoff.

7. Spectator mode

The Android SDK has no dedicated spectator API. PlayKitClient.kt and MatchSocket.kt only implement the player-match path (/v1/ws/match/{matchId}); there's no PlayKit.spectate(...) equivalent. To watch a match read-only, open the spectate WebSocket yourself per the backend contract in backend/app/realtime/router.py's spectate_socket, using OkHttp directly (mirroring MatchSocket.kt's pattern):

val client = OkHttpClient.Builder().pingInterval(20, TimeUnit.SECONDS).build()
val wsBase = baseUrl.replaceFirst("https", "wss").replaceFirst("http", "ws")
val request = Request.Builder()
    .url("$wsBase/v1/ws/spectate/$matchId")
    .addHeader("Authorization", "Bearer $playerToken")
    .build()
client.newWebSocket(request, object : WebSocketListener() {
    override fun onMessage(webSocket: WebSocket, text: String) { /* live pushes */ }
})
// send only {"type":"ping"} or {"type":"requestState"} — everything else is silently
// ignored, spectators cannot mutate match state.

You'll need a player JWT (obtained exactly as the SDK does, via POST /v1/auth/device) since PlayKitClient's token isn't exposed publicly. Any player token valid for the match's project can spectate — matchId itself is the "invite code."

8. Handling events

PlayKitEvent (android/playkit/src/main/java/com/playkit/sdk/model/PlayKitEvent.kt) is a sealed class:

sealed class PlayKitEvent {
    data class Score(val value: Int, val delta: Int) : PlayKitEvent()
    data class Achievement(val id: String, val value: Int?) : PlayKitEvent()
    data class Error(val code: String, val message: String, val fatal: Boolean) : PlayKitEvent()
    data class GameOver(
        val seed: Long, val finalScore: Int, val board: Any?,
        val movesPlayed: Int, val won: Boolean, val moves: List<Any?>,
    ) : PlayKitEvent()
    data class ReplayResult(
        val valid: Boolean, val serverScore: Int, val rewards: List<Map<String, Any?>>,
    ) : PlayKitEvent()
}

Score, Achievement, Error, and GameOver mirror the web bundle's outbound bridge events verbatim. ReplayResult is synthesized by the SDK itself after it POSTs the GameOver session to v1/games/{slug}/replay for anti-cheat validation and parses the verdict — it always arrives after GameOver, on its own network timing, and only for single-player games. board and moves are left as raw org.json values (JSONObject/JSONArray/primitives/null) since their shape is genuinely per-game (a real-time game's board is its final standings list); your onEvent handler should already know which game is active and cast accordingly. Real-time games reuse the same GameOver event but skip replay submission entirely (PlayKitGame.isRealtimeMultiplayer guards this) — the match was already settled server-side over the WebSocket.

Error codes the SDK itself can emit (all fatal = false): REPLAY_SUBMIT_FAILED (single-player replay POST failed), BRIDGE_PARSE_FAILED (a page → native message wasn't valid JSON), and MATCH_CLOSED (the match socket was closed for good — see section 6). Anything else comes from the game bundle itself.

9. Minimal working sample

Adapted directly from the real demo app (android/playkit-demo/src/main/java/com/playkit/demo/MainActivity.kt, whose PlayerCountDialog builds a picker from playerCountOptions):

const val DEMO_API_KEY = "pk_live_..."
const val DEMO_BASE_URL = "http://10.0.2.2:8000"

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        PlayKit.configure(context = applicationContext, apiKey = DEMO_API_KEY, baseUrl = DEMO_BASE_URL)
        PlayKit.identify(userId = "demo-user")
        setContent {
            MaterialTheme { Surface(modifier = Modifier.fillMaxSize()) { DemoApp() } }
        }
    }
}

@Composable
fun DemoApp() {
    val scope = rememberCoroutineScope()
    var catalog by remember { mutableStateOf<List<GameCatalogEntry>>(emptyList()) }
    var selectedGame by remember { mutableStateOf<PlayKitGame?>(null) }
    var matchId by remember { mutableStateOf<String?>(null) }
    var lastEvent by remember { mutableStateOf<String?>(null) }

    LaunchedEffect(Unit) {
        catalog = try { PlayKit.gameCatalog() } catch (t: Throwable) { emptyList() }
    }

    val game = selectedGame
    if (game != null) {
        PlayKitScreen(
            game = game,
            configJson = """{"seed": 42}""",
            matchId = if (game.isRealtimeMultiplayer) matchId else null,
            onEvent = { event ->
                lastEvent = when (event) {
                    is PlayKitEvent.GameOver -> "gameOver: score=${event.finalScore} won=${event.won}"
                    is PlayKitEvent.ReplayResult -> "server: valid=${event.valid} score=${event.serverScore}"
                    is PlayKitEvent.Score -> "score: ${event.value}"
                    is PlayKitEvent.Error -> {
                        if (event.code == "MATCH_CLOSED") selectedGame = null // kicked / finished — leave the screen
                        "error[${event.code}]: ${event.message}"
                    }
                    else -> lastEvent
                }
            },
        )
    } else {
        Column {
            Text(lastEvent ?: "Waiting for events…")
            catalog.forEach { entry ->
                Button(onClick = {
                    val picked = PlayKitGame.fromSlug(entry.slug) ?: return@Button
                    if (picked.isRealtimeMultiplayer) {
                        // Real app: let the player pick from entry.playerCountOptions first.
                        scope.launch {
                            matchId = PlayKit.joinMatch(picked, playerCount = entry.playerCountOptions?.first()).matchId
                            selectedGame = picked
                        }
                    } else {
                        selectedGame = picked
                    }
                }) {
                    Text(entry.displayName)
                }
            }
        }
    }
}

Content safety & audience

PlayKit's catalog targets a 10–15 year old audience. If your app serves a different age range, review which games from the catalog you surface.

Troubleshooting