Leaderboards
Live, ranked, and never empty.
A leaderboard is the ranked view of who's winning an event. Every event that has a competitive shape gets one — Kraty assembles it from the scores your game reports and the bots you configure.
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.leaderboards.read(leaderboardId, {
limit: 50,
includeSelf: true,
externalId: 'player_alice',
});
board.entries; // [{ participantId, kind, name, score, rank, ... }, ...]
board.self; // { rank, score } for the player you passedBots and players are merged into one list — your UI doesn't need to treat them differently.
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 (SSE)
GET /sdk/v1/leaderboards/{leaderboardId}/stream opens a Server-Sent
Events connection. Every time a player submits progress, the server
pushes a score_update event so your UI can repaint without polling.
const res = await fetch(`${baseUrl}/sdk/v1/leaderboards/${id}/stream`, {
headers: { authorization: `Bearer ${apiKey}`, accept: 'text/event-stream' },
});The official SDKs wrap this so you don't have to parse the stream
yourself: kraty.leaderboards.live(id) in
TypeScript and
Flutter, LeaderboardsClient.LiveAsync(id)
in Unity. For other stacks, read the chunked
response and split on \n\n to pull each frame.
A few things to know:
- Only player progress is broadcast. Bot scores are part of every regular GET; they don't generate stream events.
- Heartbeats: the server emits comment lines every 15 seconds so intermediaries don't drop the socket. Ignore them.
- Finalized leaderboards send a single
ready+closedevent and close the connection — there's nothing more to publish once a window has ended.
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.
Shared leaderboards
Some boards belong to an event window — they're 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's what shared leaderboards are. They're 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 shared leaderboard owns:
- Key — stable identifier you reference from event bindings and
reward policies (e.g.
weekly_global,season_3_kills). - Reset cadence —
never(all-time),weekly, ormonthly. Resets fire at the start of the new period in the leaderboard's configured timezone so weekly boards line up with your audience's "Monday." - Score aggregation — how a player's many submitted scores
collapse into the one number that ranks them:
bestkeeps the high-water mark,latestkeeps the most recent,sumadds 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. Optionally pin a closed bucket list — values outside the list are dropped silently. See Segmentation below for the full shape.
When an event reports progress, the engine writes the player's score into every shared leaderboard the event contributes to, honoring each board's aggregation rule.
Walkthrough: 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're tracking lifetime totals).
-
Leave Segmentation key blank.
-
Skip Period-end rewards — there are no periods.
-
Save.
-
Open any event that should contribute. In the Shared leaderboards card, check
all_time_globaland Save bindings. -
From your game client:
const board = await kraty.leaderboards.readShared('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.
Walkthrough: 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
keytoweekly_region, Reset cadence Weekly, Reset timezone to your audience's clock (e.g.UTCorAmerica/New_York). -
Score aggregation 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.
- Up to rank 1 (key
-
Save.
-
Bind one or more events to it via their Shared leaderboards card.
-
Your game client sets the player's region on attempt start:
await kraty.events.start('weekly_run', { region: 'EU' });Subsequent
events.progresscalls publish toweekly_region:EU(or:NA/:APACdepending on the player's region). -
Read the player's region-local ranks:
const eu = await kraty.leaderboards.readShared('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
goldreward; the first-place NA player also getsgold; etc. Subscribe to theshared_leaderboard.period_finalizedwebhook 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 shared leaderboard with the right cadence and bind your recurring events to it.
- All-time records —
resetCadence: never, usuallyscoreAggregation: best. - Segmented ranks — combine any cadence with a
segmentationkey. Useleaguefor tiered ladders,countryfor regional cohorts, or any custom field your game tracks.
Archived boards stop accepting new scores; events bound to them silently drop those writes until you point the binding elsewhere.
Reading a shared leaderboard from your game
Shared 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.readShared('weekly_global', {
limit: 50,
includeSelf: true,
externalId: 'player_alice',
});
board.entries; // [{ rank, name, score, kind, participantId, avatarUrl }, …]
board.self; // { rank, score } | null
board.period; // ISO timestamp of the period this read coversFor 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:
const board = await kraty.leaderboards.readShared('season_league', {
segment: 'diamond', // the player's bucket
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 isn't 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.listSharedPeriods('weekly_global');
const lastWeek = periods[0]; // newest snapshot
const snap = await kraty.leaderboards.readShared('weekly_global', {
period: lastWeek.periodStartedAt,
limit: 10,
});Snapshots are durable in Postgres so historical ranks survive indefinitely — your "all-time hall of fame" UI can fold over them.
Period-end rewards
A shared 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 }] }
]
}
}
}Tiers are matched first-whose-to-≥-rank-wins, so the example
above pays 500 gems to rank 1, 200 gems to ranks 2-3, and one
loot_crate to ranks 4-10. Outside-tier ranks get nothing.
For segmented boards, tiers apply independently per segment — top-1 in every league pays out, not just the global top-1. That matches how leagues / regions / cohorts are usually designed.
Grants land with sourceKind: 'shared_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. Same grant.created webhook fires as for
event-completion grants; receivers can route by sourceKind.
Segmentation
Segmentation lets one shared leaderboard split into independent ranked sub-boards per cohort. The studio names the field that defines a cohort; the SDK supplies the value at attempt start.
{ "segmentation": { "key": "league" } }
{ "segmentation": { "key": "league", "buckets": ["bronze", "silver", "gold", "diamond"] } }key— the field name your SDK passes inplayerContextwhen callingevents.start(...). Lowercase alphanumerics, underscore, or dash; up to 64 chars.buckets(optional) — a closed list of accepted values. Scores whoseplayerContext[key]isn't in the list are dropped silently with a warning logged. Useful for leagues (bronze→diamond) where the universe is fixed.bucketsomitted — open-ended segmentation. Any non-empty string value gets its own sub-board. Good for country codes, opaque cohort ids, or anything where the universe grows over time.
Each cohort has its own zset and its own ranks — a diamond-league
player isn't 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 isn't a non-empty string) is dropped with a warning — the SDK call still succeeds so a single bad client write doesn't 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:
- The current period's final ranks are snapshotted into Postgres
(
core.shared_leaderboard_periods, one row per participant). currentPeriodStartedAtadvances to the period boundary.- The live Redis zset is cleared so the next period starts empty.
- A
shared_leaderboard.period_finalizedwebhook 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's no
"replay the reset" footgun. Historical rows survive forever and
back the "last week's top 10" widget and analytics queries.