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.
Via Unity Package Manager (recommended)
Window → Package Manager → + → Add package from git URL →
paste:
https://github.com/PedroTrincheiras/kraty-sdk-unity.git#v0.18.1Or 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:
| Credential | Identifies | Where 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 player | Generated 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:
| Runtime | Default 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 / MergeMetadataAsyncRun 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 whatEvents.StartAsync(...)returned asattempt.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 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 (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.
| Client | Methods |
|---|---|
kraty.Events | ListForPlayerAsync(@as, ct), StartAsync(eventKey, playerContext, @as, ct), ProgressAsync(eventKey, attemptId, input, @as, ct), FinishAsync(eventKey, attemptId, @as, ct) |
kraty.Leaderboards | ReadAsync(key, opts, ct), SubmitScoreAsync(key, value, opts, ct), ListPeriodsAsync(key, limit, ct) (dashboard-configured cross-event boards) |
kraty.EventLeaderboards | ReadAsync(id, opts, ct), LiveAsync(id, ct), Subscribe(id, …) (per-event-window boards (UUID), SSE stream) |
kraty.Grants | ListPendingAsync(limit, @as, ct), ClaimAsync(grantId, @as, ct), OpenAsync(grantId, @as, ct), CollectAllAsync(@as, ct) |
kraty.Inventory | ListAsync(@as, ct), ConsumeAsync(itemKey, input, @as, ct) |
kraty.Wallet | ListAsync(@as, ct), DebitAsync(economyKey, input, @as, ct) |
kraty.Lobbies | ReadAsync(lobbyId, ct) |
Identity surface on Kraty:
ActiveExternalPlayerId: property,nulluntil 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.slnThe 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.