PlayKit

Flutter integration

The Flutter SDK is a Dart package at flutter/playkit/ (package name playkit). It wraps a flutter_inappwebview InAppWebView inside a PlayKitView widget, bridges JS events back to your callback via an injected window.PlayKitNative.postMessage shim, and owns the network calls (device auth, catalog, replay submission is triggered from JS but the match WebSocket is owned by native Dart via web_socket_channel) — the same design as the iOS and Android SDKs, just in Dart.

1. Add the package

dependencies:
  playkit:
    path: ../flutter/playkit

The package depends on flutter_inappwebview (chosen over webview_flutter for the fine-grained JS-injection timing control the native-owned-WebSocket bridge design requires), http, web_socket_channel, device_info_plus (for a stable device id), and shared_preferences (catalog offline fallback).

2. Configure the SDK

Call once, early in app startup:

import 'package:playkit/playkit.dart';

PlayKit.configure(
  apiKey: 'pk_live_...',
  baseUrl: 'https://playkit.in', // your own backend's base URL if self-hosting
);
PlayKit.identify('your-user-id'); // optional, safe to call any time

PlayKit (flutter/playkit/lib/src/playkit.dart) is the only public entry point — everything else under lib/src/ (PlayKitClient, MatchSocket, PlayKitView's internals) is an implementation detail not exported from lib/playkit.dart.

3. Fetch the catalog

final entries = await PlayKit.gameCatalog();
// entry.slug, .gameId, .displayName, .latestVersion, .minSdkVersion,
//       .bundleUrl, .bundleChecksum, .iconUrl, .playerCountOptions

Calls GET /v1/catalog under the hood, authenticating with a player JWT fetched/cached automatically via POST /v1/auth/device. On failure it falls back to the last successfully cached catalog response (persisted via shared_preferences), if one exists. Each entry is decoded from a response shaped like this real one:

{
  "slug": "2048",
  "gameId": "1b3b4a85-e1ec-45b1-8545-9cb42ea44624",
  "displayName": "2048",
  "latestVersion": "0.1.0",
  "minSdkVersion": "0.1.0",
  "bundleUrl": "https://playkit.in/static/bundles/2048/0.1.0/",
  "bundleChecksum": "79ee7f6748f6478b7be6862d701178afcad10af3abd09f0a16df49c3dadfc665",
  "iconUrl": "https://playkit.in/v1/game-icons/2048",
  "playerCountOptions": [1, 2, 3, 4]
}

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.

4. Identify which games exist

PlayKitGame (flutter/playkit/lib/src/model/playkit_game.dart) is the fixed enum of games the SDK knows how to host, keyed by catalog slug — 200 entries, generated from and cross-checked against game-core/games.json and the Android/iOS SDKs' own enums:

enum PlayKitGame {
  game2048Versus('2048'),
  ticTacToeVersus('tic-tac-toe'),
  // ... one entry per hosted game ...
  ludo('ludo'),
  rummy('rummy'),
  poker('poker'),
  checkersLive('checkers'),
  dotsAndBoxes('dots-and-boxes'),
  uno('uno'),
  dominoes('dominoes');

  const PlayKitGame(this.slug);
  final String slug;
}

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 27 real-time games — those need joinMatch + a matchId (section 6); everything else is single-player. Unlike the native SDKs' bundledAssetsPath/ bundledResourceName field, this enum has no bundled-fallback field: Android's every entry is null (nothing actually ships a bundled fallback except iOS's 2048), so there's nothing meaningful to mirror — PlayKitView always loads a game's bundle straight from its catalog bundleUrl.

5. Launch a single-player game

PlayKitView is a StatefulWidget (flutter/playkit/lib/src/playkit_view.dart):

PlayKitView({
  required PlayKitGame game,
  required String bundleUrl, // entry.bundleUrl from the catalog
  required int seed,
  Map<String, dynamic>? theme,
  Map<String, dynamic>? debug,
  String? matchId,
  bool? localAiMode,
  required void Function(PlayKitEvent event) onEvent,
})
PlayKitView(
  game: PlayKitGame.game2048Versus,
  bundleUrl: entry.bundleUrl,
  seed: 42,
  onEvent: (event) { /* handle */ },
)

PlayKitView loads the bundle's index.html directly from bundleUrl and calls window.PlayKit.init(...) / .start() once the page finishes loading (onLoadStop). Unlike the native SDKs it does not maintain its own disk cache/checksum-verification layer over the bundle — that stays a host app's job to add if it wants one; the platform WebView's own HTTP cache is what backs repeat loads by default.

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 PlayKitView:

final count = entry.playerCountOptions?.first; // or a value the player chose
final joined = await PlayKit.joinMatch(game: game, playerCount: count);
// joined.matchId, .seat, .requiredPlayers, .status, .roomCode

PlayKitView(
  game: game,
  bundleUrl: entry.bundleUrl,
  seed: 1, // still required by the shared contract but carries no semantics here
  matchId: joined.matchId,
  onEvent: (event) { /* handle */ },
)

joinMatch's response — POST /v1/games/{slug}/matches/join under the hood:

{
  "matchId": "2ccc2030-4c1c-4ac0-9159-aea71f7bc08d",
  "seat": 0,
  "requiredPlayers": 2,
  "status": "waiting",
  "roomCode": null
}

playerCount is optional; omitted asks for the game's largest supported count. Internally, PlayKitView opens its own MatchSocket (a web_socket_channel 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 PlayKitErrorEvent(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 (min(attempt * 1500ms, 10000ms)).

7. Spectator mode

The Flutter SDK has no dedicated spectator API, same as iOS/Android. PlayKitClient and MatchSocket only implement the player-match path (/v1/ws/match/{matchId}). 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 web_socket_channel directly (mirroring match_socket.dart's pattern):

final wsBase = baseUrl.replaceFirst('https', 'wss').replaceFirst('http', 'ws');
final channel = IOWebSocketChannel.connect(
  Uri.parse('$wsBase/v1/ws/spectate/$matchId'),
  headers: {'Authorization': 'Bearer $playerToken'},
);
channel.stream.listen((message) { /* 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 (flutter/playkit/lib/src/playkit_event.dart) is a Dart 3 sealed class:

sealed class PlayKitEvent {}

final class PlayKitScoreEvent extends PlayKitEvent {
  final int value; final int delta;
}
final class PlayKitAchievementEvent extends PlayKitEvent {
  final String id; final int? value;
}
final class PlayKitErrorEvent extends PlayKitEvent {
  final String code; final String message; final bool fatal;
}
final class PlayKitGameOverEvent extends PlayKitEvent {
  final int seed; final int finalScore; final Object? board;
  final int movesPlayed; final bool won; final List<Object?> moves;
}
final class PlayKitReplayResultEvent extends PlayKitEvent {
  final bool valid; final int serverScore; final List<Map<String, Object?>> rewards;
}
final class PlayKitQuitEvent extends PlayKitEvent {}
final class PlayKitRestartEvent extends PlayKitEvent {}

Use Dart 3 pattern matching (switch) on event in your onEvent callback — the sealed hierarchy makes the switch exhaustive. 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 decoded JSON values (Map/List/primitives/null) since their shape is genuinely per-game; 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 and scoreSlug guard this) — the match was already settled server-side over the WebSocket.

The matchAction bridge message is intercepted before generic event decoding in playkit_view.dart's _handleBridgeMessage — the same ordering that was a real regression on iOS (decoding a matchAction as a generic event silently drops it, since the decode switch has no case for it and bails before the socket forward ever runs).

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), BRIDGE_EVAL_FAILED, 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 (flutter/playkit_demo/lib/main.dart):

const demoApiKey = 'pk_live_...';
const demoBaseUrl = 'https://playkit.in';

void main() {
  WidgetsFlutterBinding.ensureInitialized();
  PlayKit.configure(apiKey: demoApiKey, baseUrl: demoBaseUrl);
  PlayKit.identify('demo-user');
  runApp(const PlayKitDemoApp());
}

// CatalogScreen: PlayKit.gameCatalog() -> ListView of entry.displayName.
// Tapping an entry: PlayKitGame.fromSlug(entry.slug), joinMatch if
// isRealtimeMultiplayer, then push a GameScreen.

class GameScreen extends StatelessWidget {
  const GameScreen({required this.entry, required this.game, this.matchId});
  final GameCatalogEntry entry;
  final PlayKitGame game;
  final String? matchId;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text(entry.displayName)),
      body: PlayKitView(
        game: game,
        bundleUrl: entry.bundleUrl,
        seed: 42,
        matchId: matchId,
        onEvent: (event) {
          final text = switch (event) {
            PlayKitGameOverEvent e => 'Game over: score=${e.finalScore} won=${e.won}',
            PlayKitReplayResultEvent e => 'Server verdict: valid=${e.valid}',
            PlayKitErrorEvent e => 'Error[${e.code}]: ${e.message}',
            _ => null,
          };
          if (text != null) {
            ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(text)));
          }
          if (event is PlayKitErrorEvent && event.code == 'MATCH_CLOSED') {
            Navigator.of(context).maybePop();
          }
        },
      ),
    );
  }
}

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