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
Via Unity Package Manager (recommended)
Window → Package Manager → + → Add package from git URL →
paste:
https://github.com/PedroTrincheiras/kraty-sdk-unity.git#v0.1.0Or 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
-
Add the package via the URL above and let UPM resolve.
-
Get a
client_sdkAPI 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's the whole bootstrap. The SDK auto-registers the player on the first call and persists the identity in
PlayerPrefsso 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 — 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:
| 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 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 / rotateRun 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 lobbyIsInsufficientEntryCost— 402, paid event the player can't 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 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.
| Client | Methods |
|---|---|
kraty.Events | ListForPlayerAsync(@as, ct), StartAsync(eventKey, playerContext, @as, ct), ProgressAsync(eventKey, attemptId, input, @as, ct) |
kraty.Leaderboards | ReadAsync(id, opts, ct), LiveAsync(id, ct) |
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)
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.slnThe 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.