Flutter SDK
Pure Dart client for Flutter apps and Dart CLIs — covers events, leaderboards, lobbies, grants, inventory, wallet, and per-player auth.
The Dart/Flutter SDK targets Flutter apps (iOS, Android, web,
desktop) and pure-Dart tooling. Auto-stamped idempotency keys on
every write (preserved across retries), exponential backoff with
jitter, sealed error codes you can switch on, and adaptive
polling helpers for grants and lobbies — so the common patterns are
one line of code.
This SDK is for game CLIENTS only. It deliberately does NOT
expose the /server/v1 (server_integration) or /admin/v1
surfaces — those can mint currency, grant items, and rotate player
secrets. Embedding a server_integration key in a shipped game
client is a security incident: an attacker who dumps the APK
extracts the key and immediately owns every player's economy. Call
those endpoints from your own backend with @kraty/server-sdk
(Node), never from a Flutter client.
Install
The package isn't on pub.dev yet — install directly from GitHub
against a tagged release. Each release is verified by flutter test before the tag goes out.
# pubspec.yaml
dependencies:
kraty:
git:
url: https://github.com/PedroTrincheiras/kraty-sdk-flutter.git
ref: v0.1.0Run flutter pub get to fetch. Browse releases at
github.com/PedroTrincheiras/kraty-sdk-flutter/releases.
Migrate to dart pub add kraty once we publish to pub.dev
(planned for v1.0).
Integration in three steps
-
Add the dependency above and run
flutter pub get. -
Get a
client_sdkAPI key from the Kraty portal under your game → Settings → API Keys. -
Drop the SDK into your game code:
import 'package:kraty/kraty.dart'; final kraty = Kraty(KratyClientOptions(apiKey: 'YOUR_CLIENT_SDK_KEY')); final events = await kraty.events.listForPlayer();That's the whole bootstrap. The SDK auto-registers the player on the first call and persists the identity in
shared_preferencesso it survives app restarts. See Authentication for the bring-your-own-id and device-link flows.
Authentication
Kraty uses two credentials in lock-step:
| Credential | Identifies | Where it lives |
|---|---|---|
SDK key (Authorization: Bearer …) | The game (studio + game + permission set) | Embedded in the game client, one per game/environment |
Player secret (X-Player-Secret: …) | The player | Generated server-side on the first player-scoped call, persisted via SecretStore, attached to every player-scoped request |
Player-scoped routes (events.start, events.progress, inventory.*,
wallet.*, grants.*) require both — the SDK key alone gets you
public catalog data (events list, leaderboards, lobby state). See
the Authentication guide for the full security model.
One-line setup
import 'package:kraty/kraty.dart';
final kraty = Kraty(KratyClientOptions(apiKey: '<your-client-sdk-key>'));
// First player-scoped call below: the SDK generates a kp_<uuid>
// id, calls POST /sdk/v1/players/:id/register, persists the
// secret + id, and attaches X-Player-Secret on every call.
final events = await kraty.events.listForPlayer();
await kraty.events.start(events.first.eventKey);Subsequent calls reuse the cached identity — within the session
and across launches, since the default SharedPreferencesSecretStore
survives restarts.
Bring your own player id
If your auth system already minted a stable id (Apple Sign-In, Google ID, your own account-service uuid), pin it in the constructor — the SDK still handles register + secret persistence:
final kraty = Kraty(KratyClientOptions(
apiKey: '<your-client-sdk-key>',
activeExternalPlayerId: 'player_42',
));
// First call registers `player_42` and persists the secret;
// later launches restore the same identity transparently.
await kraty.events.listForPlayer();Sign in with a server-issued secret
Device-link flow: your backend mints a fresh secret server-side
(via /server/v1/players/:p/secret/rotate) and hands the pair
back to the device. Install + persist with one call:
final secret = await myBackend.linkDevice(playerId);
await kraty.signIn(externalPlayerId: playerId, secret: secret);The next request uses the new identity.
Log out / switch player
await kraty.logout();
// Next player-scoped call lazily registers a fresh player.
await kraty.signIn(
externalPlayerId: 'player_99',
secret: '<from your auth backend>',
);logout() wipes the persisted active id + secret. The next call
falls back through the three-tier resolver (constructor id →
persisted id → fresh register).
Inspect the active identity
// null until the first player-scoped call resolves.
final id = kraty.activeExternalPlayerId;
// Force an early resolve (rare — usually unnecessary):
final (externalPlayerId: pid, secret: _) = await kraty.ensureIdentity();Persistence backends
The SDK picks a durable default based on the runtime — game code
doesn't construct or pass a SecretStore:
| Runtime | Default backend |
|---|---|
| Flutter apps (iOS, Android, web, desktop) | SharedPreferencesSecretStore (wraps shared_preferences) |
| Pure-Dart CLIs / headless tests (no Flutter binding) | InMemorySecretStore |
shared_preferences is unencrypted on disk. For high-value
economies, wrap flutter_secure_storage
(Keychain on iOS, EncryptedSharedPreferences on Android) behind a
custom SecretStore and pass it via KratyClientOptions.secretStore.
The SecretStore interface lives in lib/src/secret_store.dart.
Configure
final kraty = Kraty(KratyClientOptions(
apiKey: '<your-client-sdk-key>',
timeout: Duration(seconds: 10),
retry: KratyRetryConfig(
attempts: 5,
initialDelay: Duration(milliseconds: 200),
maxDelay: Duration(seconds: 10),
jitter: 0.25,
),
onRequest: (info) { // optional telemetry
print('${info.method} ${info.url} → ${info.status}');
},
));Always call kraty.close() on shutdown to release the underlying
HTTP connection pool.
Resource clients
Kraty exposes seven resource clients, all sharing one KratyClient:
kraty.events // event list / start / progress
kraty.leaderboards // read + live SSE stream
kraty.grants // pending / claim / open / collectAll
kraty.lobbies // read (with botSlots projection)
kraty.inventory // list / consume
kraty.wallet // list / debit
kraty.players // register / rotateThe active player
After the first player-scoped call (or an explicit
await kraty.ensureIdentity()), the SDK holds the player's
externalPlayerId and secret internally. Every player-scoped
method then resolves to that id on its own — you don't pass it on
each call.
final kraty = Kraty(KratyClientOptions(apiKey: '<your-client-sdk-key>'));
// All of these target the active player implicitly:
await kraty.events.listForPlayer();
await kraty.grants.listPending();
await kraty.inventory.list();
await kraty.wallet.list();If you need to address a different player from the same client
(server-side admin tooling, not a game client), pass as::
await kraty.grants.listPending(as: 'other_player');as: skips active-player resolution entirely — no identity gets
registered or persisted for the override id.
Events
// 1) What can this player play right now?
final available = await kraty.events.listForPlayer();
for (final e in available) {
print('${e.eventKey} (${e.type})');
print(' cost: ${e.entryCost?.currencies ?? []}');
print(' metrics: ${e.metrics.map((m) => m['key']).toList()}');
}
// 2) Start an attempt. Pays entryCost atomically; throws on
// `insufficient_entry_cost` if the player can't afford it.
final start = await kraty.events.start(
available.first.eventKey,
playerContext: {'country': 'PT', 'level': 7},
);
// 3) Push progress. `set` writes; `increment` adds.
// The response carries any milestones whose threshold crossed.
final update = await kraty.events.progress(
available.first.eventKey,
start.attempt.id,
const ProgressInput(mode: 'increment', metricValue: 1),
);
for (final fired in update.milestonesFired) {
showToast('Milestone ${fired.key} → ${fired.grants.length} grants');
}
// 4) Attempt completed?
if (update.attempt.status == 'completed') {
await kraty.grants.collectAll();
}Leaderboards
Snapshot read
// includeSelf resolves to the active player automatically.
final board = await kraty.leaderboards.read(
start.leaderboardId,
options: LeaderboardReadOptions(
limit: 50,
includeSelf: true,
),
);
for (final e in board.entries) {
print('#${e.rank} ${e.name} ${e.score} (${e.kind})'); // kind: player | bot
}
if (board.self != null) {
print('You: #${board.self!.rank} score ${board.self!.score}');
}Live SSE stream
final stream = await kraty.leaderboards.live(leaderboardId);
stream.events.listen((event) {
switch (event.kind) {
case 'ready': // initial handshake
break;
case 'score_update': // someone climbed
refreshUi(event.data);
break;
case 'closed': // server finalized
break;
}
});
stream.errors.listen((err) {
// Transport drop — re-call .live() after a backoff
});
// Stop streaming:
await stream.cancel();The SDK does not auto-reconnect — that policy belongs to your app (game-paused vs background-tab handling differs by use case).
Grants and crates
// Manual loop.
final pending = await kraty.grants.listPending();
for (final g in pending) {
if (g.kind == 'crate') {
await kraty.grants.open(g.id);
} else {
await kraty.grants.claim(g.id);
}
}
// Or in one call:
final result = await kraty.grants.collectAll();
print('Opened ${result.opened.length} crates, claimed ${result.claimed.length}');
if (result.hasFailures) {
for (final f in result.failures) {
print('${f.grant.id} failed: ${f.error}');
}
}collectAll opens crates first; the rolled-contents grants the
crates produce land in the next listPending — recall it after
a moment if you want to drain those too.
Inventory and wallet
Only meaningful when the game has
settings.inventoryManagement = 'platform' (the "platform-managed
inventory" mode). For studio-managed games these endpoints return
empty lists.
final items = await kraty.inventory.list();
final wallet = await kraty.wallet.list();
// Spend.
await kraty.inventory.consume('health_potion',
ConsumeItemInput(quantity: 1));
await kraty.wallet.debit('gold',
DebitWalletInput(amount: 100));Credit / grant flows are server-API-only by design — clients can't
mint resources. Server-side IAP fulfilment goes through
@kraty/server-sdk.
Lobbies (matchmaking)
When you call events.start on a lobby-matched event, it may throw
KratyApiError with code == 'lobby_forming'. The SDK exposes a
ready-made polling helper:
try {
final start = await kraty.events.start('quick_brawl');
// Got an attempt — lobby was already full or a slot opened.
} on KratyApiError catch (err) {
if (err.isLobbyForming) {
final lobbyId = (err.details as Map)['lobbyId'] as String;
final lobby = await pollLobbyUntilActive(kraty.lobbies, lobbyId);
// Now safe to retry events.start:
final start = await kraty.events.start('quick_brawl');
}
}Lobby reads carry a botSlots projection — the number of bot
slots the server will materialise on promote, derived from lobby
age. Use it to render a smooth "filling up" UI instead of waiting
for a sudden all-bots-at-once promote:
final lobby = await kraty.lobbies.read(lobbyId);
print('${lobby.participantCount} humans + ${lobby.botSlots} bots'
' / ${lobby.capacity}');
print('filled (clamped to capacity): ${lobby.filledSlots}');Polling helpers
// Adaptive grants polling — grows interval while empty, snaps back
// to the floor when grants land.
final stop = Completer<void>();
unawaited(pollPendingGrants(
kraty.grants,
options: PollPendingGrantsOptions(
start: Duration(seconds: 2),
grow: 1.5,
max: Duration(seconds: 30),
onBatch: (batch) => print('${batch.length} pending'),
),
signal: stop.future,
));
// later: stop.complete();
// Fixed-interval lobby poll with a TimeoutException after `timeout`.
final lobby = await pollLobbyUntilActive(
kraty.lobbies,
lobbyId,
options: PollLobbyOptions(
interval: Duration(seconds: 1),
timeout: Duration(seconds: 60),
),
);Errors
Every non-2xx response throws KratyApiError with a code, message,
and HTTP status. Network failures (DNS, socket reset, timeout) throw
KratyNetworkError.
try {
await kraty.events.start('bounty_hunt');
} on KratyApiError catch (err) {
if (err.isLobbyForming) {
// matchmaking
} else if (err.isInsufficientEntryCost) {
// player can't afford — err.message has the resource detail
} else if (err.isPlayerSecretInvalid) {
// re-register or surface to the user
} else {
switch (err.code) {
case KratyErrorCode.noActiveWindow:
// event is between windows
break;
case KratyErrorCode.maxAttemptsReached:
// player burned all attempts for this window
break;
default:
rethrow;
}
}
} on KratyNetworkError {
// backend unreachable
}Typed getters on KratyApiError:
isLobbyForming— 202 lobby_forming, poll the lobbyisInsufficientEntryCost— 402, paid event the player can't affordisPlayerSecretInvalid— 401, secret missing or wrongisPlayerAlreadyRegistered— 409, retry withforce: truein devisEntryRequirementFailed— 403, ownership gate failed
Full code reference: Error codes.
Retries and idempotency
Every POST / PUT / PATCH is auto-stamped with an
idempotencyKey (16-byte URL-safe random by default), preserved
across retries — so a network reset between request-sent and
response-received doesn't double-charge or double-grant.
Default retry policy: 408 / 425 / 429 / 5xx and network failures,
exponential backoff with jitter, honours Retry-After. Configure
via KratyRetryConfig (see Configure).
Telemetry
final kraty = Kraty(KratyClientOptions(
apiKey: '...',
onRequest: (info) {
metrics.timing('kraty.${info.url}', info.duration.inMilliseconds);
if (!info.ok) metrics.increment('kraty.error.${info.status}');
},
));Fires once per HTTP attempt, including retries. Use the attempt
field to dedupe.
Resource reference
Every player-scoped method defaults to the active player and
accepts as: to address a different one (server-side admin
tooling only).
| Client | Methods |
|---|---|
kraty.events | listForPlayer({as}), start(eventKey, {playerContext, as}), progress(eventKey, attemptId, input, {as}) |
kraty.leaderboards | read(id, {options}), live(id) |
kraty.grants | listPending({as, limit}), claim(grantId, {as}), open(grantId, {as}), collectAll({as}) |
kraty.inventory | list({as}), consume(itemKey, input, {as}) |
kraty.wallet | list({as}), debit(economyKey, input, {as}) |
kraty.lobbies | read(lobbyId) |
Identity surface on Kraty:
activeExternalPlayerId— getter,nulluntil first resolve.ensureIdentity()— resolve up-front (rare).signIn(externalPlayerId:, secret:)— install + persist a server-issued identity.logout()— wipe the persisted identity.
Free functions:
pollPendingGrants(grantsClient, {options, signal})pollLobbyUntilActive(lobbiesClient, lobbyId, {options})
Sample app
The apps/flutter_test
project is a small but complete game-shaped sample that drives
every SDK surface: events list with cost badges and lock state, a
tap-to-score quest screen with lobby visualisation and milestone
toasts, a leaderboard browser, a player snapshot tab, and a debug
panel that exercises every endpoint individually.
Unity SDK
Drop-in C# client for Unity 2022 LTS+ game clients — events, leaderboards, lobbies, grants, inventory, wallet, and per-player auth.
Server SDKs
Per-game server-side SDKs — IAP fulfilment, grant currency / items, push pre-matched lobbies, verify incoming webhooks, look up player state. Available in Node and Python.