PlayKit

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