Kraty

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

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

  1. Add the dependency above and run flutter pub get.

  2. Get a client_sdk API key from the Kraty portal under your game → Settings → API Keys.

  3. 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_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 — 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:

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 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 / rotate

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 — 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 lobby
  • isInsufficientEntryCost — 402, paid event the player can't 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 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).

ClientMethods
kraty.eventslistForPlayer({as}), start(eventKey, {playerContext, as}), progress(eventKey, attemptId, input, {as})
kraty.leaderboardsread(id, {options}), live(id)
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})

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.