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
- Matches never start / hang forever. If self-hosting the backend
behind your own gateway, confirm it runs with
--workers 1(see rootDEPLOY.md) — a second worker never sees matches created on the first, and a non-sticky load balancer has the same effect. - The lobby never becomes startable. Every seat must be connected and ready, and only the host (lowest seat) sees an enabled "Start Match". A kicked seat leaves the roster incomplete until matchmaking fills it again.
Error(code = "MATCH_CLOSED", …)right after the screen opens. ThematchIdyou passed is stale — the match already finished or started without this player (4410), or this player was removed (4403). Join again for a fresh match.IllegalStateExceptionon/v1/auth/devicewith HTTP 401. YourapiKeydoesn't match the project database this backend actually runs against — keys are permanently bound to one project's database. Re-verify after any backend redeploy/migration.- Picker or a cached game fails to load fully offline.
fetchCatalog()falls back to aSharedPreferences-persisted copy of the last successful catalog response when the network call fails, but only if one exists — the very first launch on a device with no network and no prior catalog fetch has nothing to fall back to.