Rewards
Bundles, tables, crates, and policies, and how players get paid in Kraty.
Kraty's reward engine has four layers:
- Bundles: deterministic payloads.
- Tables: weighted random rolls.
- Crates: openable containers the player decides when to pop.
- Policies: who gets what when an event ends.
Each layer composes with the next. You almost never write all four by hand; most events use a single policy that references a bundle or a table.
Bundles
A reward bundle is a deterministic payload. Same input, same output. Use bundles for daily-login rewards, first-completion bonuses, fixed tier rewards, anything where the payload is fixed.
{
"entries": [
{ "type": "currency", "currencyKey": "gold", "amount": 100 },
{ "type": "item", "itemKey": "legendary_sword", "quantity": 1 }
]
}Amounts and quantities
Anywhere a reward entry takes a number (amount on a currency
entry, quantity on an item or crate entry, rolls on a table
entry) the value accepts either a fixed integer or a randomized
range:
{ "type": "currency", "currencyKey": "gold", "amount": 100 }
{ "type": "currency", "currencyKey": "gold", "amount": { "min": 100, "max": 1000 } }
{ "type": "currency", "currencyKey": "gold", "amount": { "min": 100, "max": 1000, "step": 10 } }- Fixed: always grants exactly that number.
- Range
{min, max}: uniform random integer in[min, max], inclusive on both ends. - Range with
step: snaps tomin + k*step, so{100, 1000, step: 10}only ever produces100, 110, 120, …, 1000. When(max - min)is not a clean multiple ofstep, the top bucket clamps tomaxso the upper bound stays reachable.
Rolls are deterministic per grant: the same logical grant re-rolled by an idempotent retry produces the same result, so the player never gets a "second chance" out of a flaky network. This behavior is inherited by every place a reward entry shows up: direct entries on a reward policy, entries inside bundles, weighted entries in tables, and crate contents (which roll at open time).
Tables
A reward table rolls random outcomes from a weighted list.
{
"weightedEntries": [
{ "weight": 60, "entry": { "type": "currency", "currencyKey": "gems", "amount": 10 } },
{ "weight": 30, "entry": { "type": "currency", "currencyKey": "gems", "amount": 50 } },
{ "weight": 10, "entry": { "type": "item", "itemKey": "legendary_sword", "quantity": 1 } }
]
}Tables roll deterministically per attempt: replays return the same result, so a network retry does not grant a second item.
Crates
A crate is a grant the player chooses when to open. Crates roll their reward table at open time, not at grant time, so a player can hoard them and decide when to pop one.
const pending = await kraty.grants.listPending();
// Find a crate grant:
const crate = pending.find((g) => g.kind === 'crate');
const opened = await kraty.grants.open(crate.id);
opened.contents; // the rolled reward grantReplays of open return the same rolled contents; the open
is idempotent.
Policies
A reward policy decides who gets what when an event ends or a player completes. Built-in policies:
| Policy | What it does |
|---|---|
none | No reward; the event is for the leaderboard only. |
fixed_bundle | Grants a single bundle to everyone who qualifies. |
loot_table | Rolls a single reward table per qualifying player. |
rank_scaled | Different payouts per rank bracket (top 1, top 10, etc.). |
participation_plus_tier | Base reward for everyone, scaled extras per tier. |
composite | Combine multiple of the above (useful for "fixed bundle + loot roll"). |
shared_pool | Splits a single prize pool evenly among every attempt that hit a winner predicate. Pays out at window close (not at attempt completion) so the per-winner share knows the final count of winners. |
Policies emit grants. Grants are immutable, signed, and pushed to your backend via webhooks. The SDK can list pending grants, claim them, and open crates.
Milestone rewards
A milestone reward pays out mid-attempt, the first time a watched metric crosses its threshold. They are configured on the event (not the reward policy) because the trigger is metric-driven, not outcome-driven.
{
"milestoneRewards": [
{
"key": "kills_15",
"metricKey": "kills",
"threshold": 15,
"entries": [
{ "type": "currency", "currencyKey": "gold", "amount": 200 }
]
}
]
}Each milestone fires at most once per attempt: Kraty records the milestone key on the attempt the first time it pays out, so subsequent progress updates skip it. Milestones do not replace the terminal reward policy; both fire independently.
Shared pool
shared_pool is the "everyone who reached it splits the prize"
pattern. The split happens at window close because the
per-winner share is pool / numberOfWinners, and we do not know
the winner count until the window is final.
{
"type": "shared_pool",
"parameters": {
"pool": 10000,
"currencyKey": "cash",
"winnerPredicate": {
"type": "metric_at_least",
"metricKey": "streak",
"threshold": 5
}
}
}winnerPredicate is either { type: 'is_complete' } (any
completed attempt) or
{ type: 'metric_at_least', metricKey, threshold }. The share is
floored to an integer by default (floor: false opts out for
fractional currencies). Grants land with sourceRefId set to the
window id, not an attempt id.
Grant lifecycle
pending → claimed
↘ expired (if past expiresAt)Claim a grant from the game client via
kraty.grants.claim(grantId), or server-side via
POST /server/v1/players/{externalId}/grants/{grantId}/ack if
your backend applied the reward before the client could.
Inventory management
Who actually holds the items and currency a grant contains is a per-game setting (Settings → Engine defaults → Inventory management):
| Mode | Who owns inventory | Can the game client create grants? |
|---|---|---|
studio (default) | Your backend. Grants stay claimable; you listen on grant.created and apply them to your own inventory. | No |
platform | Kraty. Grants auto-deposit into player holdings; the SDK reads inventory / wallet and does atomic consume. | No |
permissive | Kraty (like platform), plus the game client may grant to its own player. | Yes — self only |
studio is the default so nothing changes for an existing game
until you opt in.
A grant issued from the dashboard always lands in Kraty's tables.
In studio mode that's all it does — your backend still has to react
to grant.created / inventory.changed to apply it to your own
inventory. If a grant looks like it "did nothing" in-game, this is
almost always why. The player's Economy tab shows which mode the game
is in.
Keeping a running game in sync
Changes don't only come from the client: an operator grants from the dashboard, your backend credits a currency, the engine pays out an event reward or a level-up. In platform-managed modes the SDK can subscribe to all of it instead of polling:
const stop = await kraty.inventory.watch(
(ev) => {
if (ev.kind === 'inventory_changed') refreshBackpack();
if (ev.kind === 'wallet_changed') setBalance(ev.economyKey, ev.balance);
if (ev.kind === 'grant_created') showRewardPopup(ev.contents);
},
{ ignoreOwnWrites: true },
);Each event carries an origin (client / server / admin /
engine), so ignoreOwnWrites skips the echo of the client's own
writes. See the TypeScript SDK guide
for reconnect handling.
What new players start with
Every currency, progression item, and item has a starting amount on
its page — 0 unless you change it. Set it to anything above zero and
every player who registers from then on is handed that much the moment
we first see them: 100 gems, 3 revive tokens, the bronze-league trophy
floor, a starter sword.
It arrives as an ordinary grant (sourceKind: starting_balance), so it
behaves exactly like a reward you'd hand out yourself:
- Studio-managed inventory: you get a
grant.createdwebhook with every starting entry in it, and seed your own tables from it. - Platform-managed / permissive: it's deposited straight into the
player's wallet and inventory, readable from
/inventoryand/walleton their very first call. If one of the starting resources drives a derived level, the level is computed too — a startingxpof 500 puts the player at whatever level your curve says, and any per-level rewards that implies pay out.
Two things worth knowing:
- It only applies going forward. Raising a starting amount later doesn't top up players who already exist — that would be a silent payout across your whole economy. Grant it explicitly from your backend if you want existing players to get it too.
- Derived items can't have one. A level computed from XP is
recomputed on every XP write, so set the starting amount on the
source item (
xp) and the level follows.
Progression items
Economy resources come in two flavours, picked when you create one:
- a currency is spendable (gold, gems);
- a progression item accumulates (XP, trophies, ranks, levels).
Progression items are what leaderboards segment by, what promotion and relegation ladders move, and what the two features below are about.
Where are players on it? The Progression page ends with a
Progression distribution panel: pick an item — including the built-in sessions
and login_streak — and an install cohort to see how many real players sit
at each value, with the headline numbers above the chart (players counted,
median, share still at the start value, highest reached). When the item has
tiers, a band under the axis names them. Hover a column for exact numbers,
click it to open the players there, or switch to the table view; wide-ranging
items like XP are binned into equal ranges so the chart stays readable.
Levels that follow XP automatically
Rather than have your game compute XP and level and keep both in sync,
tell Kraty that level is derived from xp and it owns the number
from then on. On the progression item's page, tick "Derive this value
from another progression item" and pick:
- the source item (
xp); - the thresholds — the same editor as division tiers, so you can list ranges by hand or generate them from a linear ("one level per 100 XP") or exponential ("each level costs 1.5× the last") curve;
- the first level (1 by default; use 0 if your game counts from zero).
The preview table shows exactly which XP range maps to which level, and a test box converts any amount so you can sanity-check the curve before saving.
From then on, every write to xp recomputes level in the same
transaction — whether the XP came from the client, your server, an event
reward, or an operator adjustment. It also moves down: refund some XP
and the player is demoted, which is the whole point of deriving rather
than pushing. Writing level directly is refused (the next XP write would
overwrite it anyway); write the source instead.
Rewards for each level gained
Add reward entries under "Reward per level gained" and every level crossed pays out — "100 coins per level" pays 300 when a single award jumps three levels, not 100. Use per-level overrides for milestone levels (a trophy at level 10) and an explicit empty list for levels that shouldn't pay.
Payouts are ordinary grants (sourceKind: progression_level), so they
deposit or stay claimable exactly like event rewards depending on your
inventory mode. Dropping back down never claws them back — the player
earned them at the time.
Letting the client push progress
Set "Let the game client push progress into this resource" on a progression item and the SDK can add to it directly:
const res = await kraty.wallet.progress('xp', { amount: 250 });
res.derived; // [{ economyKey: 'level', previous: 1, current: 3, ... }]That saves a backend round-trip for values the client is genuinely the source of truth for (XP from a finished run, distance travelled). It's off by default and per resource, and it can never be enabled on a spendable currency — a client that could mint gems could mint IAPs. Spending was always allowed; this opens the gaining side, so turn it on deliberately. Anything server-authoritative stays on the server API.
Client-side grants (permissive mode)
In permissive mode the game client can grant items / currency / crates directly to its own player — handy for prototypes, jams, and trusted single-player games where a server round-trip for every reward is overkill:
// Only works when the game is in "permissive" inventory mode.
await kraty.grants.grant({
entries: [
{ type: 'item', itemKey: 'gold_sword', quantity: 1 },
{ type: 'currency', currencyKey: 'coins', amount: 250 },
],
});
// It auto-deposits, so you can read it straight back:
const inv = await kraty.inventory.list(); // gold_sword ×1
const wallet = await kraty.wallet.list(); // coins: 250Two guard rails, always enforced by the server:
- Self only. The player secret authenticates the caller, so a client can only ever grant to itself — never to another player.
- Opt-in only. In any other mode the call fails with 403
inventory_not_permissive, so a client can't mint items in a game that never enabled it. Create grants from your backend with aserver_integrationkey instead (POST /server/v1/players/{externalId}/grants).