Kraty

Leaderboards

Live, ranked, and never empty. Per-event boards, cross-event leaderboards, SSE streams, and segmented sub-boards.

A leaderboard is the ranked view of who is winning an event. Every event with a competitive shape gets one, and Kraty assembles it from the scores your game reports and the bots you configure.

This page covers:

  • Reading a per-event board from your client.
  • Streaming live updates via Server-Sent Events.
  • Configuring leaderboards that outlive any single event window: weeklies, monthlies, all-time boards, segmented league ladders.

Reading a leaderboard

Each leaderboard is identified by an id you receive when a player starts their attempt. Use that id to read the current rankings.

const board = await kraty.eventLeaderboards.read(leaderboardId, {
  limit: 50,
  includeSelf: true,
  externalId: 'player_alice',
});

board.entries; // [{ participantId, kind, name, score, rank, ... }, ...]
board.self;    // { rank, score } for the player you passed

Bots and players are merged into one list, so your UI does not need to treat them differently. Use kind: 'player' | 'bot' to badge them if you want to.

How scores get there

A player calls events.start(...) to begin an attempt, then events.progress(...) to report metric updates. Each progress call recomputes the player's score via the event's score formula and pushes the result into the leaderboard.

Live updates

Kraty exposes two transports for live leaderboard updates:

  • subscribe(...): the recommended high-level helper. Combines the SSE stream below with a background read() poll under one callback API. The poll nudges the server's lazy bot evaluator so bot scores tick even when no player action would otherwise trigger a read; the SSE stream carries the resulting score_update events (and any human player updates) with low latency. Deltas are deduped before they reach your callback, so the same (participantId, score) never surfaces twice.
  • live(...): the raw SSE stream. Lower-level; the server only pushes score_update events when something triggers a backend evaluation. Use this only if you intend to drive the eval yourself (for example, your own polling loop, a custom backend trigger).

For most game UIs, subscribe(...) is what you want.

Quickstart with subscribe(...)

const sub = kraty.eventLeaderboards.subscribe(
  leaderboardId,
  (event) => {
    if (event.kind === 'score_update') repaint(event.data);
  },
  {
    pollIntervalMs: 15_000,             // default; set to 0 for SSE-only
    onError: (err) => console.warn(err), // transient transport errors
  },
);

// Later, on screen close:
await sub.close();
final sub = kraty.eventLeaderboards.subscribe(
  leaderboardId,
  pollInterval: const Duration(seconds: 15), // default
);

sub.events.listen((ev) {
  if (ev.kind == 'score_update') refreshUi(ev.data);
});
sub.errors.listen((err) {
  debugPrint('stream warn: $err');
});

// Later, on screen close:
await sub.cancel();
var sub = kraty.EventLeaderboards.Subscribe(
  leaderboardId,
  onEvent: ev => mainThreadDispatcher.Enqueue(() => {
    if (ev.Kind == "score_update") RefreshUi(ev.Data);
  }),
  opts: new SubscribeOptions {
    PollIntervalMs = 15000,          // default; set to 0 for SSE-only
    OnError = err => Debug.LogWarning(err),
  }
);

// Later, on screen close:
await sub.CancelAsync();

The onEvent callback fires on the HTTP background thread, so marshal to Unity's main thread (e.g. via a MainThreadDispatcher) before touching UnityEngine APIs.

Final placements (finalized)

When a board ends, the stream pushes one finalized event carrying the final standings, so you can show "you placed 3rd" without a follow-up read. It fires in two cases:

  • session_terminated: the player's session (lobby) ended early on a session-end trigger (e.g. first to 5 points). See Events → Sessions.
  • window_closed: the whole event ended at its deadline.

Find the caller in standings by participantId and render their place; stop expecting score_updates after this.

const myId = kraty.playerId; // the internal participant id on the board
const sub = kraty.eventLeaderboards.subscribe(leaderboardId, (event) => {
  if (event.kind === 'score_update') repaint(event.data);
  if (event.kind === 'finalized') {
    const d = event.data as unknown as LeaderboardFinalizedData;
    const me = d.standings.find((s) => s.participantId === myId);
    showResultScreen({ reason: d.reason, rank: me?.rank, score: me?.score });
  }
});

Direct SSE access (advanced)

If you need raw SSE for a non-SDK stack, hit the endpoint yourself:

const res = await fetch(`${baseUrl}/sdk/v1/event-leaderboards/${id}/stream`, {
  headers: {
    authorization: `Bearer ${apiKey}`,
    accept: 'text/event-stream',
  },
});

Read the chunked response and split on \n\n to pull each frame. Event kinds today: ready (handshake), score_update (a participant moved), finalized (the board ended, carrying final standings + a reason of session_terminated or window_closed), closed (stream is closing).

A few things to know:

  • Bot scores stream too. A read(...) (or the background poll inside subscribe(...)) triggers the server's lazy bot evaluator, which publishes score_update events for every bot whose score changed. Idle leaderboards with no readers stay silent; that's what the subscribe(...) background poll is for.
  • Heartbeats: the server emits SSE comment lines every 15 seconds so intermediaries do not drop the socket. Ignore them.
  • Finalized leaderboards send a single ready + closed event and close the connection; there is nothing more to publish once a window has ended.
  • No auto-reconnect. Both subscribe(...) and live(...) surface transport errors via the error callback / errors stream; if you want resumption, re-invoke after a backoff.

Why bots feel real

A bot's score is a deterministic function of its seed, the time since the event started, and its configured behavior. Two reads at the same instant produce the same number, and the score curves smoothly. Your players see motion, not magic.

Read Bots for how to configure them.

Leaderboards

Some boards belong to an event window; they are born when the event opens and finalize when it closes. Others outlive any single window: a weekly global board that resets every Monday, an all-time top 100, a per-level board players climb across many sessions.

That is what leaderboards are. They are first-class objects configured under the Leaderboards tab of your game in the portal, and events publish into them via a contributesTo binding instead of allocating their own per-window board.

Each leaderboard owns:

  • Key: stable identifier you reference from event bindings and reward policies (weekly_global, season_3_kills).
  • Reset cadence: never (all-time), weekly, or monthly. Resets fire at the start of the new period in the leaderboard's configured timezone so boards line up with your audience's clock. For weekly you pick the reset day (any weekday) and the time of day; for monthly you pick the time of day (the boundary is always the 1st). Both default to Monday at 00:00, matching the prior behaviour, and existing boards are unchanged.
  • Score aggregation: how a player's many submitted scores collapse into the one number that ranks them: best keeps the high-water mark, latest keeps the most recent, sum adds them up.
  • Segmentation (optional): split the board by any field your SDK passes in playerContext. You name the field (e.g. league); your SDK supplies the value (e.g. diamond) on attempt start. Each distinct value becomes its own ranked sub-board. See Segmentation below for the full shape.

When an event reports progress, the engine writes the player's score into every leaderboard the event contributes to, honoring each board's aggregation rule.

Deploy a global all-time leaderboard

For an unsegmented board that never resets (the classic "all-time champions" wall):

In the portal, open Leaderboards under your game.

Click + New leaderboard. Pick a stable key (e.g. all_time_global). Your client will use this forever.

Set Reset cadence to Never (all-time).

Set Score aggregation to Best score wins (or Sum if you are tracking lifetime totals).

Leave Segmentation key blank.

Skip Period-end rewards; there are no periods.

Save.

Open any event that should contribute. In the Leaderboards card, check all_time_global and Save bindings.

From your game client:

const board = await kraty.leaderboards.read('all_time_global', {
  limit: 50,
  includeSelf: true,
});

The active player's rank comes back under board.self.

Every events.progress call from a contributing event now also writes to all_time_global, applying its best aggregation.

Deploy a weekly board segmented by region with prizes

For a weekly board that ranks players independently by region (NA, EU, APAC) and pays out the top 3 in each region:

Open Leaderboards+ New leaderboard.

Set key to weekly_region, Reset cadence Weekly, and Reset timezone to your audience's clock (e.g. UTC or America/New_York).

Set Score aggregation to Best score wins.

Segmentation key: region. Allowed buckets: NA, EU, APAC. (Leave buckets blank to accept any value, useful for country codes.)

Check Enable period-end rewards and add tiers:

  • Up to rank 1 (key gold): currency entries totalling your prize for first place.
  • Up to rank 3 (key silver_bronze): runner-up prize.

Tiers apply independently per region, so each segment has its own podium.

Save.

Bind one or more events to it via their Leaderboards card.

Your game client sets the player's region on attempt start:

await kraty.events.start('weekly_run', { region: 'EU' });

Subsequent events.progress calls publish to weekly_region:EU (or :NA / :APAC depending on the player's region).

Read the player's region-local ranks:

const eu = await kraty.leaderboards.read('weekly_region', {
  segment: 'EU',
  limit: 10,
  includeSelf: true,
});

At the configured reset boundary the worker snapshots final ranks per region, fires the per-tier rewards (one set per region), and clears the live zsets. The first-place EU player gets the gold reward; the first-place NA player also gets gold; etc.

Subscribe to the leaderboard.period_finalized webhook to react in your backend.

When to use one

  • Per-event window only: leave the event on its default per-window leaderboard. Nothing to set up.
  • Weekly / monthly / seasonal climbs that survive event resets: create a leaderboard with the right cadence and bind your recurring events to it.
  • All-time records: resetCadence: never, usually scoreAggregation: best.
  • Segmented ranks: combine any cadence with a segmentation key. Use league for tiered ladders, country for regional cohorts, or any custom field your game tracks.

Segmenting by country needs zero client work: Kraty resolves each player's country server-side (from your CDN's geo header, falling back to an offline GeoIP lookup) and fills it in for you. Just set the segmentation key to country and leave the buckets blank. If your client does send playerContext.country, that value wins.

Archived boards stop accepting new scores; events bound to them silently drop those writes until you point the binding elsewhere.

Reading a leaderboard from your game

Leaderboards are addressed by their key (game-scoped), not a UUID. Pick a stable string at design time and your client can hardcode it.

// Top 50 of the all-time global board.
const board = await kraty.leaderboards.read('weekly_global', {
  limit: 50,
  includeSelf: true,
  externalId: 'player_alice',
});
board.entries; // [{ rank, name, score, kind, participantId, avatar }, …]
board.self;    // { rank, score } | null
board.period;  // ISO timestamp of the period this read covers

For context-segmented boards you MUST pass the segment value, the same field your client supplies in playerContext[segmentation.key] on attempt start, usually held in your client config. For progression-segmented boards, omit segment to read the caller's own division (or pass one to peek at another). See Client vs server segmentation for the full rule:

const board = await kraty.leaderboards.read('season_league', {
  segment: 'diamond',
  limit: 20,
  includeSelf: true,
  externalId: 'player_alice',
});

When the board has a closed bucket list, passing a value outside it returns 400 validation_failed. Render that as a "this board is not visible to you yet" empty state, not a crash.

To show "last week's top 10" alongside this week's board, list the snapshotted periods and re-read the one you want:

const { periods } = await kraty.leaderboards.listPeriods('weekly_global');
const lastWeek = periods[0]; // newest snapshot
const snap = await kraty.leaderboards.read('weekly_global', {
  period: lastWeek.periodStartedAt,
  limit: 10,
});

Snapshots are durable in Postgres so historical ranks survive indefinitely, and your "all-time hall of fame" UI can fold over them.

Scoring a leaderboard directly

Until now a leaderboard only filled up indirectly: events bound to it via contributesTo dual-wrote each events.progress call into the board. That still works and is the right model for "this board tracks event results".

But some boards have no event behind them: a daily "steps walked" ladder, a "fastest build time" board your tooling reports, a trophy count your match server computes. For those, push a score straight to the board with no attempt in the loop.

This is only valid for score-ranked boards (rankBy: { kind: 'score' }, the default). A progression-ranked board ranks by an item balance Kraty reads server-side, so a raw score is meaningless there; submitting one returns 400 score_not_supported. Adjust the progression item instead (via a server-side grant or an event payout).

From the game client

The client submits the score for the active player. The board's segmentation decides whether you pass segment; see Client vs server segmentation below.

// Unsegmented or progression-segmented board: no segment needed.
const result = await kraty.leaderboards.submitScore('daily_steps', 8421);
console.log(result.leaderboardId, result.score, result.rank);

// context-segmented board: pass the bucket the client owns.
await kraty.leaderboards.submitScore('weekly_region', 8421, {
  segment: 'EU',
  idempotencyKey: 'steps_2026_06_29', // optional; dedupes retries
});
final result = await kraty.leaderboards.submitScore('daily_steps', 8421);
print('${result.leaderboardId} ${result.score} #${result.rank}');

// context-segmented board:
await kraty.leaderboards.submitScore(
  'weekly_region',
  8421,
  segment: 'EU',
  idempotencyKey: 'steps_2026_06_29',
);
var result = await kraty.Leaderboards.SubmitScoreAsync("daily_steps", 8421);
Debug.Log($"{result.LeaderboardId} {result.Score} #{result.Rank}");

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

The result is { leaderboardId, score, rank }, where rank is null when the board can't place the player yet (e.g. a best-aggregation board the new score didn't beat).

From your server

Your backend can score on a player's behalf with the server SDK, the trusted, server-authoritative path. This is the only way to score when the board has acceptClientScores turned off (see The acceptClientScores gate).

import { KratyServer } from '@kraty/server-sdk';

const kraty = new KratyServer({ apiKey: process.env.KRATY_SERVER_KEY! });

const result = await kraty.leaderboards.submitScore(
  'player_42',        // external player id
  'daily_steps',      // board key
  8421,               // value
  { segment: 'EU', idempotencyKey: 'steps_2026_06_29' },
);
result = kraty.leaderboards.submit_score(
    "player_42",        # external player id
    "daily_steps",      # board key
    8421,               # value
    segment="EU",
    idempotency_key="steps_2026_06_29",
)

The aggregation rule (best / latest / sum) is applied exactly as it is for event contributions: a direct submitScore and an event's contributesTo write are indistinguishable once they land on the board.

Joining a board without a score

Sometimes you want a player to appear on a board before they've earned anything: a lobby roster, a "you're in this week's league" screen, a placeholder row at rank last. join registers the player at score 0 without submitting a score, and returns the current standings for their segment in one round-trip. It's idempotent: re-joining never resets an existing score, and it's allowed even on server-authoritative boards (joining places no score).

// Configurable board: pass segment only for context-segmented boards.
const board = await kraty.leaderboards.join('weekly_region', { segment: 'EU' });
board.entries; // current standings, with the player now present at 0
board.self;    // { rank, score } for the joiner

// Per-event-window board: addressed by the attempt's leaderboardId.
const evBoard = await kraty.eventLeaderboards.join(leaderboardId);
final board = await kraty.leaderboards.join('weekly_region', segment: 'EU');
final evBoard = await kraty.eventLeaderboards.join(leaderboardId);
var board = await kraty.Leaderboards.JoinAsync("weekly_region",
    new LeaderboardJoinOptions { Segment = "EU" });
var evBoard = await kraty.EventLeaderboards.JoinAsync(leaderboardId);

Joining a per-event board that has already finalized returns 409 conflict; you can't join a closed window.

Flexible standings: my division, every division, past periods

read(...) returns one segment. When you want more than that (the player's own division, every division they're in, or the whole ladder), use standings(...). It returns one block per segment, each flagging the caller (isSelf on entries, selfRank, and participated), for the current period or any past one.

Pick the segments with scope:

scopeReturns
self_segmentjust the caller's home division (derived for progression boards)
mineevery segment the caller appears in
segmentthe one named in segment
all (default)every segment for the period
// The player's own division, live.
const mine = await kraty.leaderboards.standings('season_league', {
  scope: 'self_segment',
  externalId: 'player_alice',
});
mine.segments[0].entries;      // ranked rows, caller flagged isSelf
mine.segments[0].selfRank;     // caller's rank in that division
mine.segments[0].participated; // true; they're in it

// The whole ladder for LAST period (browse every division).
const { periods } = await kraty.leaderboards.listPeriods('season_league');
const all = await kraty.leaderboards.standings('season_league', {
  scope: 'all',
  period: periods[0].periodStartedAt,
  maxSegments: 20,
});
all.segments; // [{ segment, participated, selfRank, entries }, …]

self_segment and mine resolve the caller from externalId (or the SDK's active player identity). all is capped by maxSegments (default 20); segmentsTruncated tells you when there were more.

In the portal

The board's Standings tab mirrors these reads: pick a period to inspect the live board or any finalized past one, and a tier / segment to switch divisions (the picker lists only divisions that actually have entries for the chosen period + environment).

The acceptClientScores gate

Every event and every leaderboard carries an acceptClientScores flag (default on). It controls whether a client_sdk key (the key embedded in your shipped game) is allowed to write scores / progress.

acceptClientScoresClient SDK (client_sdk key)Server SDK / API (server_integration key)
on (default)Can scoreCan score
off403 client_scoring_disabledCan score

Turn it off to make a board (or event) server-authoritative: the game client physically cannot write a score, so a tampered client can't inflate the ladder. Your trusted backend validates the result (anti-cheat, replay verification, simulation) and submits via the server SDK, which is never gated.

  • On a leaderboard, the gate covers leaderboards.submitScore: a client call returns 403 client_scoring_disabled.
  • On an event, the gate covers events.progress (and start's scoring side effects); see the event gate.

The two are independent: a board can accept client scores while a sensitive tournament event next to it does not, and vice versa. Set the flag in the portal on the board's / event's editor (it is a single toggle labelled Accept client scores).

This is the recommended anti-cheat posture for any board whose ranking has real value (prizes, matchmaking input, public prestige). Keep it on for casual, low-stakes boards where the one-line client integration is worth more than tamper resistance.

Client vs server segmentation

This is the single most important thing to get right when a board is segmented, because it decides who supplies the bucket and therefore what your client code must send.

A board's segmentation.source is one of two modes:

context (client-side)progression (server-side)
Bucket comes fromthe game clientthe server, from a progression item balance
Config{ source: 'context', key: 'region' }{ source: 'progression', itemKey: 'leaderboardlevel' }
Set at attempt startclient sends playerContext[key] (e.g. region: 'EU')client sends nothing
segment when scoring directlyrequired (pass the bucket value)omit (server resolves it)
segment when readingrequired (pass the bucket value)optional (omit for your own division, or pass one to view another)
When the division can changewhenever the client sends a new valuesnapshotted at first contribution each period (see snapshot-at-join)

The rule of thumb:

  • context = the client is the source of truth for the cohort (region, platform, opaque squad id). The client must always tell Kraty which bucket, so segment is required when reading and when scoring directly. A missing/blank value on a write is dropped with a warning; a value outside a closed bucket list on a read returns 400 validation_failed.
  • progression = Kraty derives the cohort from the player's progression item balance (their league / division), so the client never sends it. When reading, omit segment to get the caller's own division, or pass a specific division to peek at another (e.g. show the player the diamond board above them). When scoring directly, omit segment; the server resolves the bucket from the player's progression state.
  • Combined (a segments array) = the caller's bucket spans several axes, which one segment value can't name, so segment is ignored on read — the SDK always returns the caller's own combination (derived from their context + balances). Writes route on the same derived combination. Make sure the client sends every context axis in playerContext at attempt start; a combination with any axis missing is dropped, just like a single context board.
// context board: client owns the bucket, required everywhere.
await kraty.events.start('weekly_run', { region: 'EU' });          // at start
await kraty.leaderboards.read('weekly_region', { segment: 'EU' }); // reading
await kraty.leaderboards.submitScore('weekly_region', 8421, { segment: 'EU' });

// progression board: server owns the bucket, client sends nothing.
await kraty.events.start('league_run');                            // no context
await kraty.leaderboards.read('season_league');                    // own division
await kraty.leaderboards.read('season_league', { segment: 'diamond' }); // peek
await kraty.leaderboards.submitScore('season_league', 8421);       // bucket resolved server-side
// context board: client owns the bucket, required everywhere.
await kraty.events.start('weekly_run', playerContext: {'region': 'EU'});
await kraty.leaderboards.read('weekly_region',
    options: const LeaderboardReadOptions(segment: 'EU'));
await kraty.leaderboards.submitScore('weekly_region', 8421, segment: 'EU');

// progression board: server owns the bucket, client sends nothing.
await kraty.events.start('league_run');
await kraty.leaderboards.read('season_league');                 // own division
await kraty.leaderboards.read('season_league',
    options: const LeaderboardReadOptions(segment: 'diamond')); // peek
await kraty.leaderboards.submitScore('season_league', 8421);    // resolved server-side
// context board: client owns the bucket, required everywhere.
await kraty.Events.StartAsync("weekly_run",
    playerContext: new Dictionary<string, object?> { ["region"] = "EU" });
await kraty.Leaderboards.ReadAsync("weekly_region",
    new LeaderboardReadOptions { Segment = "EU" });
await kraty.Leaderboards.SubmitScoreAsync("weekly_region", 8421,
    new SubmitScoreOptions { Segment = "EU" });

// progression board: server owns the bucket, client sends nothing.
await kraty.Events.StartAsync("league_run");
await kraty.Leaderboards.ReadAsync("season_league");            // own division
await kraty.Leaderboards.ReadAsync("season_league",
    new LeaderboardReadOptions { Segment = "diamond" });        // peek
await kraty.Leaderboards.SubmitScoreAsync("season_league", 8421); // resolved server-side

The mechanics of each mode (passthrough buckets, progression tiers, snapshot-at-join) are detailed under Segmentation below; this section is only about who supplies the value.

Period-end rewards

A leaderboard can pay out prizes at every reset boundary by configuring a rewardPolicy. The reset worker dispatches it AFTER snapshotting the period's final ranks, inside the same transaction that advances currentPeriodStartedAt, so a board that pays out can never reset without grants firing, and vice versa.

Only one policy type is built in today: top_n, rank-tiered payouts.

{
  "rewardPolicy": {
    "type": "top_n",
    "parameters": {
      "tiers": [
        { "to": 1,  "key": "gold_medal",   "entries": [{ "type": "currency", "currencyKey": "gems", "amount": 500 }] },
        { "to": 3,  "key": "silver_medal", "entries": [{ "type": "currency", "currencyKey": "gems", "amount": 200 }] },
        { "to": 10, "key": "top_ten",      "entries": [{ "type": "item",     "itemKey": "loot_crate", "quantity": 1 }] }
      ]
    }
  }
}

Brackets are matched first-whose-to-≥-place-wins, so the example above pays 500 gems to place 1, 200 gems to places 2–3, and one loot_crate to places 4–10. Outside-bracket places get nothing. (to is the place the bracket extends to; "tier" is reserved for progression tiers below.)

For segmented boards, brackets apply independently per segment: place 1 in every league pays out, not just the global place 1. That matches how leagues, regions, and cohorts are usually designed.

Different prizes per progression tier

By default every progression tier uses the same place brackets. To pay a tier its own prizes (Silver's 1st place winning something different from Bronze's), add perSegmentTiers, keyed by segment value. A tier listed there uses its own ladder; any tier not listed falls back to the default tiers. An empty ladder ([]) is a deliberate "no prizes in this tier".

{
  "rewardPolicy": {
    "type": "top_n",
    "parameters": {
      "tiers": [
        { "to": 1, "entries": [{ "type": "currency", "currencyKey": "gems", "amount": 100 }] }
      ],
      "perSegmentTiers": {
        "silver": [{ "to": 1, "entries": [{ "type": "currency", "currencyKey": "gems", "amount": 250 }] }],
        "bronze": [{ "to": 1, "entries": [{ "type": "item",     "itemKey": "starter_crate", "quantity": 1 }] }]
      }
    }
  }
}

In the portal, set this on a segmented board's Rewards tab under Per progression-tier rewards. For a board segmented by a progression item with named tiers, the picker lists those tier labels directly.

Grants land with sourceKind: 'leaderboard_period' and an idempotency key of shared_lb:<boardId>:<periodStartIso>:<segment>:<participantId>:<tierKey>, so re-runs of the reset worker on the same boundary produce zero new grants. The same grant.created webhook fires as for event-completion grants; receivers can route by sourceKind.

Promotion & relegation ladders

Beyond paying prizes, a period reset can adjust a progression value by finishing place, the building block for league ladders where players climb and drop progression tiers. Add progressionAdjustments to the reward policy: each rule nudges a progression item for players who finish in a place range counted from an end of the ranking (top: place 1 = best; bottom: place 1 = worst). amount is the signed change: positive promotes (climb), negative relegates (drop). A promotion stops at its ceiling (omit for no cap); a relegation never falls below its floor (default 0). Because each rule is a range, you can reward places differently: 1st place a lot, places 2–5 a little:

{
  "rewardPolicy": {
    "type": "top_n",
    "parameters": {
      "tiers": [],
      "progressionAdjustments": [
        { "end": "top",    "fromPlace": 1, "toPlace": 1, "itemKey": "leaderboardlevel", "amount":  100, "ceiling": 10 },
        { "end": "top",    "fromPlace": 2, "toPlace": 5, "itemKey": "leaderboardlevel", "amount":  10,  "ceiling": 10 },
        { "end": "bottom", "fromPlace": 1, "toPlace": 5, "itemKey": "leaderboardlevel", "amount": -1,   "floor": 0 }
      ]
    }
  }
}

floor is only read for relegations (negative amount) and ceiling only for promotions (positive amount); the off-direction bound is ignored. (The legacy { "from", "count" } shape is still accepted; count maps to places 1..count.)

Now segment the board by that same progression item (segmentation.itemKey: "leaderboardlevel"). The result is a self-driving ladder:

  • Each leaderboardlevel tier is its own sub-board (its own places).
  • Finish in the top places of your tier → leaderboardlevel climbs (up to the ceiling, e.g. the top league) → next period you're matched into the harder tier above.
  • Finish in the bottom placesleaderboardlevel drops (never below the floor) → you fall a tier.

Ranges apply independently per progression tier (each has its own places), bots are skipped, and the adjustment shares the reset's idempotency guard, so a re-run on the same boundary never double-promotes.

Events use the same ladder. The identical progressionAdjustments shape works on an event's rewardPolicy.parameters, applied at window close over the event's final standings (and, for session events, the converged main board). Configure it in the event editor's Reward policy section, or see Events → Promotion & relegation.

Segmentation

Segmentation lets one leaderboard split into independent ranked sub-boards per cohort. You name the field that defines a cohort; the SDK supplies the value at attempt start.

{ "segmentation": { "source": "context", "key": "league" } }
  • key: the field name your SDK passes in playerContext when calling events.start(...). Lowercase alphanumerics, underscore, or dash; up to 64 chars.
  • Pure passthrough: each distinct value the client sends gets its own sub-board. Good for country codes, league names, opaque cohort ids, or anything where the universe grows over time. (There is no closed bucket list on the segmentation; the value is the sub-board.)

Segment by a progression item (server-side, snapshot-at-join)

Instead of trusting a playerContext field, a board can segment by a progression item that Kraty reads server-side:

{ "segmentation": { "source": "progression", "itemKey": "leaderboardlevel" } }
  • The divisions come from the progression item's own Tiers config (set under Economy → Progression), reused by every board/event that segments by it. With no tiers, each integer balance is its own division (the ladder case, leaderboardlevel 3 → division "3"). With tiers, the balance falls into the matching named range (bronze, silver, …); see Progression tiers.
  • The division is snapshotted at the player's first contribution each period: a mid-period balance change can't move them; they shift next period. (Pair with promotion/relegation above to drive that.)
  • source: "context" (or omitting source) is the playerContext behavior described above.

Combine multiple axes

A board can segment by a combination of axes — say country and rank. Pass a segments array instead of the single segmentation; each entry is one axis, in priority order:

{
  "segments": [
    { "source": "progression", "itemKey": "leaderboardlevel" },
    { "source": "context", "key": "country" }
  ]
}
  • Players are ranked within each unique combination of all axes, so the example gives one sub-board per (rank, country) pair — gold players in PT compete only against other gold players in PT.
  • The first axis is the reward axis: period-end rewards and promotion/relegation (below) apply per division of that axis only. The remaining axes just split each division into parallel boards that share the same ladder. Order your segments so the axis you pay out on comes first.
  • Each axis resolves the same way it would on its own — country and progression are server-derived (nothing from the client), a plain context field comes from playerContext. If any axis can't resolve for a player (missing context value, balance outside the item's tiers), that score is dropped, exactly as for a single-axis board.
  • Reading a combined board is automatic: the SDK returns the caller's own combination (derived from their context + balances). A single segment query value can't name every axis, so it's ignored on combined boards — see Client vs server segmentation.
  • segmentation (single object) and segments (array) are interchangeable on the API; a one-axis board can use either. Up to four axes.

Progression tiers

Named divisions live on the progression item, not on each board. Open the item under Economy → Progression → Tiers and choose:

  • None: no divisions; each balance value is its own sub-board.
  • Manual: an explicit list of named ranges (bronze 0–999, silver 1000–4999, gold 10000+…).
  • Generated: a curve produces the ranges automatically: linear (fixed step → 0–10, 11–20…) or exponential (×ratio → 0–10, 11–100, 101–1000…), labelled by a naming scheme (Level {n}, numbers, letters, or custom labels), with a live simulation preview.

Every leaderboard or event that segments by that item reuses these tiers, so define them once.

Ranked by

By default a board ranks by the score your game reports. Set rankBy to rank by a progression item's balance instead; Kraty reads it server-side and updates the player's board value on each contribution:

{ "rankBy": { "kind": "progression", "itemKey": "trophies" } }
{ "rankBy": { "kind": "score" } }   // default

Rank-by and segment-by are independent: segment by leaderboardlevel (division) while ranking by kills (score) within it, or rank a board directly by trophies.

Bots

Leaderboards can be populated with bots from your catalog bot definitions, configured per segment bucket, with a default for unlisted buckets and unsegmented boards:

{
  "botConfig": {
    "default":  { "count": 5,  "bindings": [{ "botId": "...", "weight": 1 }] },
    "perBucket": {
      "diamond": { "count": 20, "bindings": [{ "botId": "...", "weight": 2 }] }
    }
  }
}
  • count bots fill the bucket; their types are a weighted draw over bindings (same model as event lobby fill). Put tougher bots in higher divisions via perBucket.
  • Bots are ephemeral and lazy: drawn deterministically (seeded by board + bucket + period) and evaluated over the board's current period on read, then merged into the ranking. Nothing is persisted, so they reset naturally each period and cost nothing at write time.
  • They reuse your existing bot definitions wholesale: behaviour, player-tracking, pacing all work as on event boards. Want a different cadence for a board? Copy a bot and tune it.

Each cohort has its own zset and its own ranks, so a diamond-league player is not crushed by another league's rankings. When a reset fires, every cohort gets snapshotted side-by-side under the same period start; the snapshot table carries the segment_value so historical queries can filter by league, region, or whatever field you picked.

A score with a missing or malformed segment value (the playerContext field is not a non-empty string) is dropped with a warning; the SDK call still succeeds so a single bad client write does not break the player's session.

Resets and history

When resetCadence is weekly or monthly, the scheduler walks the boundary on the configured timezone:

  • Weekly rolls over at 00:00 local on the next Monday.
  • Monthly rolls over at 00:00 local on the 1st of the next month.
  • Never never rolls; the board accumulates forever.

A rollover is one atomic step:

  1. The current period's final ranks are snapshotted into Postgres (core.leaderboard_periods, one row per participant).
  2. currentPeriodStartedAt advances to the period boundary.
  3. The live Redis zset is cleared so the next period starts empty.
  4. A leaderboard.period_finalized webhook fires with the period bounds, entry count, and top-10 ranks inline.

Snapshots are unique on (board, period, participant) so a re-run of the worker on the same boundary is a no-op; there is no "replay the reset" footgun. Historical rows survive forever and back the "last week's top 10" widget and analytics queries.