Flutter SDK
Pure Dart client for Flutter apps and Dart CLIs, covering 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, which 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, so install directly from the public
GitHub repo 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.18.1Run 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_sdk API 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 is the whole bootstrap. The SDK auto-registers the player
on the first call and persists the identity in
shared_preferences so 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, and 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
does not 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 eight resource clients, all sharing one
KratyClient:
kraty.events // event list / start / progress
kraty.leaderboards // dashboard-configured cross-event boards (by key)
kraty.eventLeaderboards // per-event-window boards (by UUID) + SSE live stream
kraty.grants // pending / claim / open / collectAll
kraty.lobbies // read (with botSlots projection)
kraty.inventory // list / consume
kraty.wallet // list / debit
kraty.players // register / rotate / setIdentity / getIdentity / getAnonymizedIdentity / setMetadata / mergeMetadataThe 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, so you do not 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 cannot 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();
}End the run (score-attack)
An untimed score-attack event has no completion target; the
player decides when the run is over. events.finish finalizes the
in-progress attempt at its current score and returns a
FinishAttemptResponse whose outcome tells you which terminal
state it landed in. Such events require it; otherwise the
attempt just sits until the window closes.
final res = await kraty.events.finish('score_attack', start.attempt.id);
// res.attempt: the finalized attempt
// res.outcome: 'completed' | 'expired'
if (res.outcome == 'completed') {
// No target (or target already met) → completion rewards rolled.
await kraty.grants.collectAll();
}
// 'expired' → event had a target the player did not meet;
// participation rewards only, same as a timeout.finish throws KratyApiError on a session / lobby event (those
end via their session rules) and on an already-finished attempt.
Leaderboards
Two resource clients sit on the facade:
kraty.leaderboards: the dashboard-configured cross-event boards your studio defines. Addressed by stable key. Use this for most game UI.kraty.eventLeaderboards: the per-event-window board tied to an attempt. Addressed by UUID, whichevents.start(...)returned asattempt.leaderboardId. Includes the SSE live stream.
Snapshot read (by key)
final board = await kraty.leaderboards.read(
'weekly_global',
options: const 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}');
}For segmented boards (e.g. one bucket per region) pass segment
with the same value your game sent as playerContext['region'] on
attempt start. For historical periods, listPeriods(key) returns a
newest-first list and read(key, options: ...period: ...) reads one.
Snapshot read (by event-window UUID)
final eventLeaderboard = await kraty.eventLeaderboards.read(
start.attempt.leaderboardId,
options: const EventLeaderboardReadOptions(limit: 50, includeSelf: true),
);Submit a score directly
Push a score straight to a configurable board for the active player,
with no event attempt in the loop. Only valid for score-ranked boards
(a progression-ranked board returns 400 score_not_supported).
// Unsegmented / progression-segmented board: no segment.
final result = await kraty.leaderboards.submitScore('daily_steps', 8421);
print('${result.leaderboardId} ${result.score} #${result.rank}');
// context-segmented board: pass the bucket the client owns.
await kraty.leaderboards.submitScore(
'weekly_region',
8421,
segment: 'EU',
idempotencyKey: 'steps_2026_06_29', // optional; dedupes retries
);rank is null when the board can't place the player yet (e.g. a
best-aggregation board the score didn't beat). If the board has
Accept client scores off, this throws KratyApiError with
code == 'client_scoring_disabled' (403); score it from your
backend instead. See
Client vs server segmentation
for when segment is required.
Join without a score
// Appear on the board at 0 without scoring; returns current standings.
final board = await kraty.leaderboards.join('weekly_region', segment: 'EU');
final evBoard = await kraty.eventLeaderboards.join(start.leaderboardId);Flexible standings
read returns one segment; standings returns one block per segment
(scope: self_segment | mine | segment | all), each flagging
the caller, live or for a past period.
final mine = await kraty.leaderboards.standings(
'season_league',
options: const StandingsReadOptions(scope: 'self_segment', externalId: 'player_alice'),
);
print(mine.segments.first.selfRank); // caller's rank in their division
final periods = await kraty.leaderboards.listPeriods('season_league');
final past = await kraty.leaderboards.standings(
'season_league',
options: StandingsReadOptions(scope: 'all', period: periods.periods.first.periodStartedAt),
);Live SSE stream
final stream = await kraty.eventLeaderboards.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).
Finalization catch-up
Boards end. A session (lobby) inside an event can terminate early (first to N points, roster full, idle timeout), and the whole event window closes at its end time. When a board the player is on ends you usually want a result screen: "your session ended, you placed 2nd."
The finalized SSE event tells you this live, but only while connected.
A player who backgrounded the app misses it and returns to a fresh
board. So the SDK keeps a small persisted registry of the boards the player
is in (every events.start auto-tracks its board) and gives you one
callback that fires exactly once per board across both paths: the
live SSE event, and checkFinalizations() for boards that ended while away.
// Register once (e.g. at app boot). Returns an unsubscribe function.
final off = kraty.onFinalized((result) {
final placed = result.self != null ? '#${result.self!.rank}' : 'unplaced';
showResultScreen(result.ref.leaderboardId, result.reason, placed);
// Acknowledge so it never resurfaces and leaves storage.
kraty.dismiss(result.ref);
});
// On app foreground / reconnect: cheap when nothing ended.
final ended = await kraty.checkFinalizations();
debugPrint('${ended.length} board(s) finalized while away');result.reason distinguishes why the board ended, even on the catch-up
path, because the board persists it. Use the FinalizationReason constants
rather than magic strings:
switch (result.reason) {
case FinalizationReason.sessionTerminated: // your lobby ended early
case FinalizationReason.windowClosed: // the whole event ended
case FinalizationReason.finalized: // ended, exact cause unknown
}Delivery is at-most-once even across a crash: the registry entry is marked
reported before your callback fires. dismiss(ref) drops one handled
entry; clearReported() bulk-drops every delivered entry (returns the count).
Rewards are separate and durable: any prize from a finalized session
or window lands as a pending grant regardless of connectivity. Pull them
with grants.collectAll(). onFinalized is for the result screen;
grants are for what the player earned. Persistence uses
shared_preferences in Flutter (in-memory in pure-Dart); override via
membershipStore in options.
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
cannot 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 cannot 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 cannot 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 does not 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}), finish(eventKey, attemptId, {as}) |
kraty.leaderboards | read(key, {options}), join(key, {segment}), standings(key, {options}), submitScore(key, value, {segment, idempotencyKey}), listPeriods(key, {limit}) (dashboard-configured cross-event boards) |
kraty.eventLeaderboards | read(id, {options}), join(id), live(id), subscribe(id, {pollInterval}); per-event-window boards (UUID), SSE stream |
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})