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
- 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.
PlayKitErrorEvent(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.StateErroron/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.
gameCatalog()falls back to ashared_preferences-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. - Blank WebView / game never loads. Confirm
flutter_inappwebview's platform setup is complete (iOS: no extra step beyond the pod install; Android: minSdkVersion 19+) — see the package's own install docs if the view stays blank with no bridge events at all.