Kraty

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.1

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

CredentialIdentifiesWhere 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 playerGenerated 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:

RuntimeDefault 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 / mergeMetadata

The 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, which events.start(...) returned as attempt.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 lobby
  • isInsufficientEntryCost: 402, paid event the player cannot afford
  • isPlayerSecretInvalid: 401, secret missing or wrong
  • isPlayerAlreadyRegistered: 409, retry with force: true in dev
  • isEntryRequirementFailed: 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).

ClientMethods
kraty.eventslistForPlayer({as}), start(eventKey, {playerContext, as}), progress(eventKey, attemptId, input, {as}), finish(eventKey, attemptId, {as})
kraty.leaderboardsread(key, {options}), join(key, {segment}), standings(key, {options}), submitScore(key, value, {segment, idempotencyKey}), listPeriods(key, {limit}) (dashboard-configured cross-event boards)
kraty.eventLeaderboardsread(id, {options}), join(id), live(id), subscribe(id, {pollInterval}); per-event-window boards (UUID), SSE stream
kraty.grantslistPending({as, limit}), claim(grantId, {as}), open(grantId, {as}), collectAll({as})
kraty.inventorylist({as}), consume(itemKey, input, {as})
kraty.walletlist({as}), debit(economyKey, input, {as})
kraty.lobbiesread(lobbyId)

Identity surface on Kraty:

  • activeExternalPlayerId: getter, null until 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})