Kraty

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 passed

Bots 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 + closed event 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 cadencenever (all-time), weekly, or monthly. 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: 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. 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:

  1. In the portal, open Leaderboards under your game.

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

  3. Set Reset cadence to Never (all-time).

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

  5. Leave Segmentation key blank.

  6. Skip Period-end rewards — there are no periods.

  7. Save.

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

  9. 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:

  1. Open Leaderboards+ New leaderboard.

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

  3. Score aggregation Best score wins.

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

  5. 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.

  6. Save.

  7. Bind one or more events to it via their Shared leaderboards card.

  8. 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).

  9. Read the player's region-local ranks:

    const eu = await kraty.leaderboards.readShared('weekly_region', {
      segment: 'EU',
      limit: 10,
      includeSelf: true,
    });
  10. 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 shared_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 shared leaderboard with the right cadence and bind your recurring events to it.
  • All-time recordsresetCadence: 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.

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 covers

For 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 in playerContext when calling events.start(...). Lowercase alphanumerics, underscore, or dash; up to 64 chars.
  • buckets (optional) — a closed list of accepted values. Scores whose playerContext[key] isn't in the list are dropped silently with a warning logged. Useful for leagues (bronzediamond) where the universe is fixed.
  • buckets omitted — 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:

  1. The current period's final ranks are snapshotted into Postgres (core.shared_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 shared_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's no "replay the reset" footgun. Historical rows survive forever and back the "last week's top 10" widget and analytics queries.