Kraty

Unity SDK

Drop-in C# client for Unity 2022 LTS+ game clients, covering events, leaderboards, lobbies, grants, inventory, wallet, and per-player auth.

The Unity SDK is a thin wrapper over the /sdk/v1 surface. Auto-stamped idempotency keys on every write (preserved across retries), exponential retry with jitter, sealed error codes you can switch on, Server-Sent-Events leaderboard streaming, and adaptive polling helpers for grants and lobbies, so the common patterns are one line of code.

Built on .NET Standard 2.1, so the same package runs in plain .NET tools and tests, so you can validate your integration outside the Unity editor.

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 Unity build is a security incident: an attacker who dumps the binary 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 Unity client.

Install

The SDK isn't on the Unity Asset Store yet, so install via UPM straight from the public GitHub repo against a tagged release.

Window → Package Manager → +Add package from git URL → paste:

https://github.com/PedroTrincheiras/kraty-sdk-unity.git#v0.18.1

Or edit Packages/manifest.json directly:

{
  "dependencies": {
    "app.kraty.sdk": "https://github.com/PedroTrincheiras/kraty-sdk-unity.git#v0.18.1"
  }
}

Browse releases at github.com/PedroTrincheiras/kraty-sdk-unity/releases.

Integration in three steps

Add the package via UPM and let it resolve.

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

Drop the SDK into your game code:

using Kraty;

using var kraty = new Kraty(new KratyClientOptions
{
    ApiKey = "YOUR_CLIENT_SDK_KEY",
});
var events = await kraty.Events.ListForPlayerAsync();

That is the whole bootstrap. The SDK auto-registers the player on the first call and persists the identity in PlayerPrefs 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 Unity build, one per game / environment
Player secret (X-Player-Secret: …)The playerGenerated server-side on the first player-scoped call, persisted via ISecretStore, attached to every player-scoped request

Player-scoped routes (Events.StartAsync, Events.ProgressAsync, 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

using Kraty;

using var kraty = new Kraty(new KratyClientOptions
{
    ApiKey = "<your-client-sdk-key>",
});

// First player-scoped call: the SDK generates a kp_<guid> id,
// hits POST /sdk/v1/players/:id/register, persists the secret +
// id (PlayerPrefs by default on Unity), and attaches
// X-Player-Secret on every call.
var events = await kraty.Events.ListForPlayerAsync();
await kraty.Events.StartAsync(events[0].EventKey);

Subsequent calls reuse the cached identity, within the session and across launches, since the default PlayerPrefsSecretStore 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 guid), pin it in the constructor, and the SDK still handles register + secret persistence:

using var kraty = new Kraty(new 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.ListForPlayerAsync();

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 client. Install + persist with one call:

var secret = await myBackend.LinkDeviceAsync(playerId);
await kraty.SignInAsync(playerId, secret);

The next request uses the new identity.

Log out / switch player

await kraty.LogoutAsync();
// Next player-scoped call lazily registers a fresh player.

await kraty.SignInAsync("player_99", "<from your auth backend>");

LogoutAsync() 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.
string? id = kraty.ActiveExternalPlayerId;

// Force an early resolve (rare, usually unnecessary):
var (externalPlayerId, _) = await kraty.EnsureIdentityAsync();

Persistence backends

The SDK picks a default ISecretStore based on the runtime, so game code does not construct or pass one:

RuntimeDefault backend
Unity (UNITY_5_3_OR_NEWER)PlayerPrefsSecretStore
Plain .NET (CLI tools, server bots, xUnit suite)InMemorySecretStore

PlayerPrefs is unencrypted on disk. Shipped games handling real economies should wrap a platform Keychain (iOS) / EncryptedSharedPreferences (Android) plugin behind a custom ISecretStore and pass it via KratyClientOptions.SecretStore. The interface lives in Runtime/Auth/SecretStore.cs.

Configure

Build a Kraty facade once at boot (usually inside a singleton or your DI container) and keep it for the lifetime of the app.

using Kraty;

var kraty = new Kraty(new KratyClientOptions
{
    ApiKey = Environment.GetEnvironmentVariable("KRATY_API_KEY"),
    Timeout = TimeSpan.FromSeconds(10),
    Retry = new RetryConfig
    {
        Attempts = 5,
        InitialDelay = TimeSpan.FromMilliseconds(200),
        MaxDelay = TimeSpan.FromSeconds(10),
        Jitter = 0.25,
    },
    OnRequest = info =>               // optional telemetry
    {
        Debug.Log($"{info.Method} {info.Url}{info.Status}");
    },
});

The API key alone identifies your studio + game; you do not pass those separately. Always call kraty.Dispose() (or wrap in a using block) on shutdown to release the underlying HTTP 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 / CollectAllAsync
kraty.Lobbies            // read (with BotSlots projection)
kraty.Inventory          // list / consume
kraty.Wallet             // list / debit
kraty.Players            // register / rotate / SetIdentityAsync / GetIdentityAsync / GetAnonymizedIdentityAsync / SetMetadataAsync / MergeMetadataAsync

Run an event end to end

The first player-scoped call resolves the active player (lazy auto-register if needed). Every subsequent player-scoped method reuses that id automatically; pass @as: "other_id" only when server-side tooling needs to address a different player.

// 1) What events can the active player start right now?
var events = await kraty.Events.ListForPlayerAsync();

foreach (var e in events)
{
    Debug.Log($"{e.EventKey} ({e.Type})");
    if (e.EntryCost != null && !e.EntryCost.IsEmpty)
    {
        foreach (var c in e.EntryCost.Currencies)
            Debug.Log($"  cost: {c.Amount} {c.Key}");
    }
}

// 2) Start an attempt. Pays entryCost atomically; throws on
//    insufficient_entry_cost if the player cannot afford it.
var start = await kraty.Events.StartAsync(
    eventKey: events[0].EventKey,
    playerContext: new Dictionary<string, object?>
    {
        ["country"] = "PT",
        ["level"] = 7,
    }
);

// 3) Push progress. `set` writes; `increment` adds.
//    The response carries any milestones whose threshold this
//    update crossed: perfect for "you unlocked a chest!" toasts.
var update = await kraty.Events.ProgressAsync(
    events[0].EventKey,
    start.Attempt.Id,
    new ProgressInput { Mode = "increment", MetricValue = 1 }
);
foreach (var fired in update.MilestonesFired)
{
    Debug.Log($"milestone {fired.Key} fired with {fired.Grants.Count} grants");
}

// 4) Attempt completed?
if (update.Attempt.Status == "completed")
{
    await kraty.Grants.CollectAllAsync();
}

End the run (score-attack)

An untimed score-attack event has no completion target; the player decides when the run is over. Events.FinishAsync finalizes the in-progress attempt at its current score and returns a FinishAttemptResult whose Outcome tells you which terminal state it landed in. Such events require it; otherwise the attempt just sits until the window closes.

var res = await kraty.Events.FinishAsync("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.CollectAllAsync();
}
// "expired" → event had a target the player did not meet;
// participation rewards only, same as a timeout.

FinishAsync 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 (weekly / monthly / all-time, optionally segmented). Addressed by stable key. Use this for most game UI.
  • kraty.EventLeaderboards: the auto-generated per-event-window leaderboard tied to an attempt. Addressed by UUID, which is what Events.StartAsync(...) returned as attempt.LeaderboardId. Includes the SSE live stream.

Snapshot read (by key)

// Live ranks for the current period. `IncludeSelf` resolves to the
// active player automatically.
var board = await kraty.Leaderboards.ReadAsync(
    "weekly_global",
    new LeaderboardReadOptions { Limit = 50, IncludeSelf = true }
);

foreach (var e in board.Entries)
{
    Debug.Log($"#{e.Rank} {e.Name} {e.Score} ({e.Kind})"); // kind: player | bot
}
if (board.Self != null)
{
    Debug.Log($"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 supplied as playerContext["region"] when starting the contributing attempt:

var board = await kraty.Leaderboards.ReadAsync(
    "weekly_region",
    new LeaderboardReadOptions { Segment = "eu", IncludeSelf = true }
);

To render historical snapshots (e.g. "last week's top 10"), list periods then read one:

var periods = await kraty.Leaderboards.ListPeriodsAsync("weekly_global");
var lastWeek = periods.Periods[0];
var board = await kraty.Leaderboards.ReadAsync(
    "weekly_global",
    new LeaderboardReadOptions { Period = lastWeek.PeriodStartedAt }
);

Snapshot read (by event-window UUID)

var eventLeaderboard = await kraty.EventLeaderboards.ReadAsync(
    start.Attempt.LeaderboardId,
    new EventLeaderboardReadOptions { Limit = 50, IncludeSelf = true }
);

Use this only when you want the attempt's leaderboard view. For "the leaderboard players see in the lobby UI", kraty.Leaderboards.ReadAsync(key) is what you want.

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.
var result = await kraty.Leaderboards.SubmitScoreAsync("daily_steps", 8421);
Debug.Log($"{result.LeaderboardId} {result.Score} #{result.Rank}");

// context-segmented board: pass the bucket the client owns.
await kraty.Leaderboards.SubmitScoreAsync("weekly_region", 8421,
    new SubmitScoreOptions { Segment = "EU", IdempotencyKey = "steps_2026_06_29" });

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 == KratyErrorCode.ClientScoringDisabled (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.
var board = await kraty.Leaderboards.JoinAsync("weekly_region",
    new LeaderboardJoinOptions { Segment = "EU" });
var evBoard = await kraty.EventLeaderboards.JoinAsync(start.LeaderboardId);

Flexible standings

ReadAsync returns one segment; StandingsAsync returns one block per segment (Scope: self_segment | mine | segment | all), each flagging the caller, live or for a past period.

var mine = await kraty.Leaderboards.StandingsAsync("season_league",
    new StandingsReadOptions { Scope = "self_segment", ExternalId = "player_alice" });
Debug.Log(mine.Segments[0].SelfRank);      // caller's rank in their division

var periods = await kraty.Leaderboards.ListPeriodsAsync("season_league");
var past = await kraty.Leaderboards.StandingsAsync("season_league",
    new StandingsReadOptions { Scope = "all", Period = periods.Periods[0].PeriodStartedAt });

Live SSE stream

var stream = await kraty.EventLeaderboards.LiveAsync(leaderboardId);

// Callbacks fire on the HTTP background thread; marshal to the
// main thread before touching UnityEngine APIs.
stream.OnEvent = ev =>
{
    mainThreadDispatcher.Enqueue(() =>
    {
        switch (ev.Kind)
        {
            case "ready":        break; // initial handshake
            case "score_update": RefreshUi(ev.Data); break;
            case "closed":       break; // server finalized
        }
    });
};
stream.OnError = err =>
{
    // Transport drop; call LiveAsync again after a backoff.
};

// Stop streaming:
await stream.CancelAsync();

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 game 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.StartAsync auto-tracks its board) and gives you one callback that fires exactly once per board across both paths: the live SSE event, and CheckFinalizationsAsync() for boards that ended while away.

// Register once (e.g. on boot). Returns an unsubscribe Action.
var off = kraty.OnFinalized(result =>
{
    var placed = result.Self != null ? $"#{result.Self.Rank}" : "—";
    ShowResultScreen(result.Ref.LeaderboardId, result.Reason, placed);
    // Acknowledge so it never resurfaces and leaves storage.
    _ = kraty.DismissAsync(result.Ref);
});

// On app foreground / reconnect; cheap when nothing ended.
var ended = await kraty.CheckFinalizationsAsync();
Debug.Log($"{ended.Count} 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
        break;
}

Delivery is at-most-once even across a crash: the registry entry is marked reported before your callback fires. DismissAsync(ref) drops one handled entry; ClearReportedAsync() bulk-drops every delivered entry.

OnFinalized callbacks fire on a background thread; marshal to Unity's main thread before touching UnityEngine APIs, same as the SSE stream.

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.CollectAllAsync(). OnFinalized is for the result screen; grants are for what the player earned. Persistence uses PlayerPrefs in Unity (in-memory elsewhere); override via MembershipStore in options.

Grants and crates

// Manual loop; active player resolved automatically.
var pending = await kraty.Grants.ListPendingAsync();
foreach (var g in pending)
{
    if (g.Kind == "crate")
    {
        await kraty.Grants.OpenAsync(g.Id);
    }
    else
    {
        await kraty.Grants.ClaimAsync(g.Id);
    }
}

// Or in one call:
var result = await kraty.Grants.CollectAllAsync();
Debug.Log($"Opened {result.Opened.Count} crates, claimed {result.Claimed.Count}");
if (result.HasFailures)
{
    foreach (var f in result.Failures)
    {
        Debug.LogWarning($"{f.Grant.Id} failed: {f.Error}");
    }
}

Both ClaimAsync and OpenAsync are idempotent, so they are safe to retry on a flaky network without double-granting. CollectAllAsync opens crates first; the rolled-contents grants the crates produce land in the next ListPendingAsync, so 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.

var items  = await kraty.Inventory.ListAsync();
var wallet = await kraty.Wallet.ListAsync();

// Spend.
await kraty.Inventory.ConsumeAsync("health_potion",
    new ConsumeItemInput { Quantity = 1 });
await kraty.Wallet.DebitAsync("gold",
    new 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.StartAsync on a lobby-matched event, it may throw KratyApiError with IsLobbyForming == true. The SDK exposes a ready-made polling helper:

try
{
    var start = await kraty.Events.StartAsync("quick_brawl");
}
catch (KratyApiError err) when (err.IsLobbyForming)
{
    var lobbyId = err.Details!["lobbyId"]?.ToString();
    var lobby = await LobbyPolling.UntilActiveAsync(kraty.Lobbies, lobbyId!);
    // Now safe to retry start:
    var start = await kraty.Events.StartAsync("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:

var lobby = await kraty.Lobbies.ReadAsync(lobbyId);
Debug.Log($"{lobby.ParticipantCount} humans + {lobby.BotSlots} bots"
        + $" / {lobby.Capacity}");
Debug.Log($"filled (clamped to capacity): {lobby.FilledSlots}");

Adaptive polling

Two helpers wrap the common patterns:

// Grow interval while empty; snap back when grants land.
// Abort via the CancellationToken.
var cts = new CancellationTokenSource();
_ = GrantPolling.PollPendingAsync(
    kraty.Grants,
    new GrantPolling.Options
    {
        Start = TimeSpan.FromSeconds(2),
        Grow = 1.5,
        Max = TimeSpan.FromSeconds(30),
        OnBatch = batch => { /* claim / open / queue for UI */ },
    },
    cancellationToken: cts.Token
);

// Fixed-interval lobby poll with a timeout.
var lobby = await LobbyPolling.UntilActiveAsync(kraty.Lobbies, lobbyId);

Errors

Non-2xx responses throw KratyApiError with a typed Code (one of KratyErrorCode.*). Network failures throw KratyNetworkError.

try
{
    await kraty.Events.StartAsync("bounty_hunt");
}
catch (KratyApiError err) when (err.IsLobbyForming)
{
    // matchmaking lobby still filling; poll and retry
}
catch (KratyApiError err) when (err.IsInsufficientEntryCost)
{
    // player cannot afford; err.Message has the resource detail
}
catch (KratyApiError err) when (err.IsPlayerSecretInvalid)
{
    // re-register or surface to the user
}
catch (KratyApiError err)
{
    switch (err.Code)
    {
        case KratyErrorCode.NoActiveWindow:
            // event is between windows
            break;
        case KratyErrorCode.MaxAttemptsReached:
            // player burned all attempts for this window
            break;
        default:
            throw;
    }
}
catch (KratyNetworkError err)
{
    // backend unreachable; err.OriginalCause has the underlying exception
}

Typed properties 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 (Guid.NewGuid() by default), preserved across retries so that a network reset between request-sent and response-received does not double-charge or double-grant. Tune via RetryConfig on KratyClientOptions:

new KratyClientOptions
{
    ApiKey = "...",
    Retry = new RetryConfig
    {
        Attempts = 5,
        InitialDelay = TimeSpan.FromMilliseconds(200),
        MaxDelay = TimeSpan.FromSeconds(10),
        Jitter = 0.25,
    },
}

Retries fire on 408 / 425 / 429 / 5xx and on HttpRequestException / network failures. Retry-After headers (used by the platform's 429 responses) are honored.

Telemetry

new KratyClientOptions
{
    ApiKey = "...",
    OnRequest = info =>
    {
        metrics.Timing($"kraty.{info.Url}", (long)info.Duration.TotalMilliseconds);
        if (!info.Ok) metrics.Increment($"kraty.error.{info.Status}");
    },
};

Fires once per HTTP attempt, including retries. Use the Attempt field to dedupe.

Resource reference

All player-scoped methods accept an optional @as: string to override the active player (server tooling only). Omitting it resolves to the lazily-registered active player.

ClientMethods
kraty.EventsListForPlayerAsync(@as, ct), StartAsync(eventKey, playerContext, @as, ct), ProgressAsync(eventKey, attemptId, input, @as, ct), FinishAsync(eventKey, attemptId, @as, ct)
kraty.LeaderboardsReadAsync(key, opts, ct), SubmitScoreAsync(key, value, opts, ct), ListPeriodsAsync(key, limit, ct) (dashboard-configured cross-event boards)
kraty.EventLeaderboardsReadAsync(id, opts, ct), LiveAsync(id, ct), Subscribe(id, …) (per-event-window boards (UUID), SSE stream)
kraty.GrantsListPendingAsync(limit, @as, ct), ClaimAsync(grantId, @as, ct), OpenAsync(grantId, @as, ct), CollectAllAsync(@as, ct)
kraty.InventoryListAsync(@as, ct), ConsumeAsync(itemKey, input, @as, ct)
kraty.WalletListAsync(@as, ct), DebitAsync(economyKey, input, @as, ct)
kraty.LobbiesReadAsync(lobbyId, ct)

Identity surface on Kraty:

  • ActiveExternalPlayerId: property, null until first resolve.
  • EnsureIdentityAsync(ct): resolve up-front (rare).
  • SignInAsync(externalPlayerId, secret, ct): install + persist a server-issued identity.
  • LogoutAsync(ct): wipe the persisted identity.

Static helpers:

  • GrantPolling.PollPendingAsync(grants, opts, ct)
  • LobbyPolling.UntilActiveAsync(lobbies, lobbyId, opts, ct)

Smoke-testing a build

The package ships a SDK Smoke Rig sample: a one-file MonoBehaviour panel that exercises every public SDK surface against your backend. Import via Package Manager → Kraty SDK → Samples → "SDK Smoke Rig" → Import, drop the KratySdkSmokeRig component on any GameObject in any scene, fill the inspector fields (Api Key, Base Url, Leaderboard Key), and hit Play.

Use it to verify a shipped Player build before publishing. Editor unit tests don't run the IL2CPP / Mono code-paths that historically caused this SDK's prod regressions (System.Text.Json stripping, missing .meta files breaking asmdef refs, PlayerPrefs accessed off the main thread). A button you click in a real Player build does.

The panel has individual buttons for each surface plus a Run ALL button that runs the standard sequence: identity → list events → start → progress → read leaderboard → read shared leaderboard → list periods → grants → inventory → wallet. The imported sample folder includes a README.md covering the pre-ship checklist.

Testing outside Unity

The Runtime/ sources target .NET Standard 2.1, so the same code compiles and tests via plain dotnet:

dotnet build packages/sdk-unity/Kraty.SDK.csproj
dotnet test  packages/sdk-unity/Kraty.SDK.sln

The package ships an xUnit test suite that drives the client against a fake HttpMessageHandler, useful as a worked example when you are building unit tests for your own integration.