Kraty

Unity SDK

Drop-in C# client for Unity 2022 LTS+ game clients — 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 — 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 — those 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

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

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

Or edit Packages/manifest.json directly:

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

The #v0.1.0 ref pins to a specific tagged release. Browse releases at github.com/PedroTrincheiras/kraty-sdk-unity/releases.

Integration in three steps

  1. Add the package via the URL above and let UPM resolve.

  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:

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

    That's 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 — 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 — game code doesn't 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 don't 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 seven resource clients, all sharing one KratyClient:

kraty.Events        // event list / start / progress
kraty.Leaderboards  // read + live SSE 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

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 can't 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();
}

Leaderboards

Snapshot read

// `IncludeSelf` resolves to the active player automatically.
var board = await kraty.Leaderboards.ReadAsync(
    start.LeaderboardId,
    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}");
}

Live SSE stream

var stream = await kraty.Leaderboards.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).

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 — 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 — 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 can't 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 can't 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 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 (Guid.NewGuid() by default), preserved across retries — so a network reset between request-sent and response-received doesn't 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)
kraty.LeaderboardsReadAsync(id, opts, ct), LiveAsync(id, ct)
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)

Testing outside Unity

The Runtime/ sources target .NET Standard 2.1, so the same code compiles + 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 (41 tests today) that drives the client against a fake HttpMessageHandler — useful as a worked example when you're building unit tests for your own integration.