iOS integration
The iOS SDK is a Swift Package at ios/PlayKit/ (product/library name
PlayKit, iOS 16+). It wraps a WKWebView that loads a game bundle,
bridges JS events back to your delegate, and owns the network calls
(device auth, catalog, replay submission is triggered from JS but the
match WebSocket is owned by native).
1. Add the package
ios/PlayKit/Package.swift declares:
let package = Package(
name: "PlayKit",
platforms: [.iOS(.v16)],
products: [
.library(name: "PlayKit", targets: ["PlayKit"])
],
...
)
Add it as a local or git Swift Package dependency in your host app's
Package.swift or via Xcode's "Add Package Dependency":
dependencies: [
.package(path: "../Game/ios/PlayKit") // or a git URL if you vendor it that way
],
targets: [
.target(name: "YourApp", dependencies: ["PlayKit"])
]
Then import PlayKit in your Swift files.
2. Configure the SDK
Call once, early (e.g. app launch):
PlayKit.configure(apiKey: "pk_live_...", baseURL: URL(string: "https://playkit.in"))
baseURL defaults to http://127.0.0.1:8000 if omitted — always pass your
real backend URL. Optionally attach your own logged-in user id at any point
(safe to call after anonymous play has already started):
PlayKit.identify(userId: "your-user-id")
Both are static functions on the PlayKit enum (ios/PlayKit/Sources/PlayKit/PlayKit.swift),
delegating to the internal PlayKitClient.shared singleton.
3. Fetch the catalog
PlayKit.gameCatalog { result in
switch result {
case .success(let entries): // [PlayKitCatalogEntry]
// entries[i].slug, .gameId, .displayName, .latestVersion, .minSdkVersion,
// .bundleUrl, .bundleChecksum, .iconUrl, .playerCountOptions
case .failure(let error):
// PlayKitError.catalogFailed, .notConfigured, ...
}
}
This calls GET /v1/catalog under the hood, authenticating with a player
JWT it fetches/caches automatically via POST /v1/auth/device. You never
call auth or catalog HTTP endpoints directly.
playerCountOptions ([Int]?) is how you tell the two kinds of game apart:
nil 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 — AsyncImage(url:) renders it directly.
4. Identify which games exist
PlayKitGame (ios/PlayKit/Sources/PlayKit/PlayKitConfig.swift) is the
fixed enum of games the SDK knows how to host, keyed by catalog slug:
public enum PlayKitGame: String {
case game2048 = "2048"
case memoryMatch = "memory-match"
case ticTacToe = "tic-tac-toe"
// ... one case per hosted game ...
// Real-time multiplayer (isRealtimeMultiplayer == true):
case ludo = "ludo"
case rummy = "rummy"
case poker = "poker"
case checkersLive = "checkers-live"
case dotsAndBoxes = "dots-and-boxes"
case uno = "uno"
case dominoes = "dominoes"
case game2048Versus = "2048-versus"
case ticTacToeVersus = "tic-tac-toe-versus"
case snakesAndLaddersVersus = "snakes-and-ladders-versus"
case connectFourVersus = "connect-four-versus"
case reversiVersus = "reversi-versus"
case territoryGrabVersus = "territory-grab-versus"
case nimWarsVersus = "nim-wars-versus"
case wordChainVersus = "word-chain-versus"
case auctionHouseVersus = "auction-house-versus"
case backgammonVersus = "backgammon-versus"
}
Map a catalog entry's slug to this enum (PlayKitGame(rawValue: entry.slug))
before presenting it; a nil 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
let config = PlayKitConfig(seed: 42)
let view = PlayKit.present(game: .game2048, config: config, delegate: self)
// `view` is a `PlayKitView: UIViewRepresentable` — drop it into SwiftUI directly.
PlayKitConfig (same file) is:
public struct PlayKitConfig: Codable {
public var seed: UInt32
public var theme: PlayKitTheme? // primaryColor, fontFamily, mode ("light"/"dark"), cornerRadius
public var debug: PlayKitDebugConfig? // POC-only: autoPlay, autoPlayStepMs
public var matchId: String? // required for realtime games, never set otherwise
}
PlayKit.present returns a PlayKitView you place in your view hierarchy
like any other SwiftUI view — it internally loads the resolved bundle (disk
cache → download-and-verify → package-bundled fallback, only "2048" ships
bundled) into a WKWebView, injects the JS bridge, and calls
window.PlayKit.init(...) / .start() once the page finishes loading.
6. Launch a real-time multiplayer game
Pick a player count from the catalog entry's playerCountOptions, reserve a
seat, then present with the returned matchId:
// entry: the PlayKitCatalogEntry the player tapped; game: PlayKitGame(rawValue: entry.slug)
let count = entry.playerCountOptions?.first // or a value the player chose from that list
PlayKit.joinMatch(game: game, playerCount: count) { result in
switch result {
case .success(let joined): // PlayKitMatchJoinResult: matchId, seat, requiredPlayers, status
let config = PlayKitConfig(seed: 1, matchId: joined.matchId) // seed carries no meaning for a realtime match
let view = PlayKit.present(game: game, config: config, delegate: self)
// present `view`
case .failure(let error):
// handle
}
}
playerCount is optional; omitting it asks for the game's largest supported
count. joinMatch calls POST /v1/games/{slug}/matches/join. Internally,
PlayKitView opens its own MatchSocket (a native URLSessionWebSocketTask
to /v1/ws/match/{matchId}, Authorization: Bearer <token> header — never
a WebView-owned socket, 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, not dropped — see MatchSocket.swift's
beginDelivering()).
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
.error(code: "MATCH_CLOSED", message: "<reason> (close code N)", fatal: false)
to your delegate. The bundle already shows the player what happened; treat
this as your cue to dismiss the match view. Other socket drops (network
blips) reconnect automatically with backoff.
7. Spectator mode
The iOS SDK has no dedicated spectator API. PlayKitClient.swift and
MatchSocket.swift only implement the player-match path
(/v1/ws/match/{matchId}); there is no PlayKit.spectate(...) or
equivalent. To let a host app watch a match read-only, open the spectate
WebSocket yourself per the backend contract in
backend/app/realtime/router.py's spectate_socket:
var request = URLRequest(url: baseURL
.appendingPathComponent("v1/ws/spectate/\(matchId)"))
// scheme must become ws/wss, same as MatchSocket.swift does
request.setValue("Bearer \(playerToken)", forHTTPHeaderField: "Authorization")
let task = URLSession.shared.webSocketTask(with: request)
task.resume()
// send {"type":"ping"} or {"type":"requestState"} JSON text frames only —
// everything else you send is silently ignored, spectators cannot mutate state.
You'll need your own player JWT (obtained the same way 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
Conform to PlayKitDelegate (ios/PlayKit/Sources/PlayKit/PlayKitEvent.swift):
public protocol PlayKitDelegate: AnyObject {
func playKit(didReceive event: PlayKitEvent)
}
PlayKitEvent cases:
public enum PlayKitEvent: Error {
case score(value: Int, delta: Int)
case gameOver(seed: UInt32, finalScore: Int, board: Any, movesPlayed: Int, won: Bool, moves: [[String: Any]])
case achievement(id: String, value: Int?)
case error(code: String, message: String, fatal: Bool)
case replayResult(valid: Bool, serverScore: Int, rewards: [[String: Any]])
}
.score, .gameOver, .achievement, and .error mirror the web bundle's
outbound bridge events verbatim. .replayResult is synthesized by the SDK
itself after it submits the gameOver session for anti-cheat validation —
it always arrives after .gameOver, on its own network-round-trip timing,
never synchronously, and only for single-player games. board/moves/each
reward's payload are type-erased (Any/[String: Any]) because their
shape is genuinely per-game (2048's board is [[Int]], Memory Match's is a
flat [Int], a real-time game's is its final standings list) — your delegate
handler should already know which game is active and cast accordingly.
.error codes the SDK itself can emit (all fatal: false):
REPLAY_SUBMIT_FAILED (single-player replay POST failed),
BRIDGE_EVAL_FAILED (a native → page JS call threw), 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
(ios/PlayKitDemo/Sources/DemoViewModel.swift, ContentView.swift, and
MatchConfigSheet.swift, which shows a player-count picker built from
playerCountOptions):
@MainActor
final class DemoViewModel: ObservableObject {
@Published var lastEventText = "Waiting for events…"
@Published var serverValidationText = ""
@Published var catalog: [PlayKitCatalogEntry] = []
@Published var catalogError: String?
@Published var realtimeMatchIds: [PlayKitGame: String] = [:]
func start() {
PlayKit.configure(apiKey: "pk_live_...", baseURL: URL(string: "https://playkit.in"))
PlayKit.identify(userId: "demo-user")
loadCatalog()
}
func loadCatalog() {
PlayKit.gameCatalog { [weak self] result in
switch result {
case .success(let entries): self?.catalog = entries
case .failure(let error): self?.catalogError = "catalog failed: \(error)"
}
}
}
func joinMatch(_ game: PlayKitGame, playerCount: Int?, then present: @escaping () -> Void) {
PlayKit.joinMatch(game: game, playerCount: playerCount) { [weak self] result in
switch result {
case .success(let joined):
self?.realtimeMatchIds[game] = joined.matchId
present()
case .failure(let error):
self?.lastEventText = "join failed: \(error)"
}
}
}
func config(for game: PlayKitGame) -> PlayKitConfig {
game.isRealtimeMultiplayer
? PlayKitConfig(seed: 1, matchId: realtimeMatchIds[game])
: PlayKitConfig(seed: 42)
}
func handle(event: PlayKitEvent) {
switch event {
case let .score(value, delta):
lastEventText = "score: \(value) (Δ\(delta))"
case let .gameOver(_, finalScore, _, movesPlayed, won, _):
lastEventText = "gameOver: score=\(finalScore) moves=\(movesPlayed) won=\(won)"
case let .replayResult(valid, serverScore, rewards):
serverValidationText = "server: valid=\(valid) score=\(serverScore) rewards=\(rewards.count)"
case let .achievement(id, value):
lastEventText = "achievement: \(id)" + (value.map { " (\($0))" } ?? "")
case let .error(code, message, fatal):
lastEventText = "error[\(code)]\(fatal ? " FATAL" : ""): \(message)"
// code == "MATCH_CLOSED": the match view is dead (kicked, finished, ...) — dismiss it.
}
}
}
extension DemoViewModel: PlayKitDelegate {
nonisolated func playKit(didReceive event: PlayKitEvent) {
Task { @MainActor in self.handle(event: event) }
}
}
struct ContentView: View {
@StateObject private var viewModel = DemoViewModel()
@State private var selectedGame: PlayKitGame?
var body: some View {
VStack(spacing: 0) {
Text(viewModel.lastEventText).font(.footnote).padding(8)
if let selectedGame {
PlayKit.present(game: selectedGame, config: viewModel.config(for: selectedGame), delegate: viewModel)
} else {
ForEach(viewModel.catalog, id: \.gameId) { entry in
Button(entry.displayName) {
guard let game = PlayKitGame(rawValue: entry.slug) else { return }
if game.isRealtimeMultiplayer {
// Real app: let the player pick from entry.playerCountOptions first.
viewModel.joinMatch(game, playerCount: entry.playerCountOptions?.first) { selectedGame = game }
} else {
selectedGame = game
}
}
}
}
}
.onAppear { viewModel.start() }
}
}
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 you're self-hosting the
backend behind your own gateway, confirm it runs with
--workers 1(see rootDEPLOY.md) — a second worker process 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 presenting. ThematchIdin yourPlayKitConfigis stale — the match already finished or started without this player (4410), or this player was removed (4403). Join again for a fresh match.- 401 from
/v1/auth/device(surfaces asPlayKitError.authFailed). YourapiKeydoesn't match the project database this backend is actually running against — keys are permanently bound to one project's database. Re-verify the key after any backend redeploy/migration. PlayKitError.gameNotInCatalog/ bundle fails to load. The catalog'sminSdkVersionfor that game is newer thanPlayKit.sdkVersionin your vendored package, or the device has no cached copy and no network — the SDK falls back to a package-bundled copy only for"2048".