Leaderboards and tournaments
This page describes leaderboards, where players compete on score, and elimination-style tournaments. Read it if you want to write scores from a trusted server and show the ranking in your game.
What it does
The competition module (competition) provides two things:
- Leaderboard: A ranking that keeps each player’s best score and can be split into seasons.
- Tournament: A single-elimination, double-elimination or round-robin bracket with player or clan participants, with a final prize.
Writing scores and managing boards is done with a server key. The game client reads the board with a player session and does not need a server key.
If the module is disabled, the endpoints return 404 with {"code":"module_disabled","module":"competition"}. Enable the module on the Game settings › Modules page.
Where it lives in the panel
| What you want to do | Panel page |
|---|---|
| Define a board; set season, unit and visibility | Leaderboard |
| See a board’s top entries and season history | Leaderboard detail |
| The prize currency for a tournament | Currencies |
There is no panel screen for tournaments; they are managed only with REST.
Note
Panel help pages are not translated yet; the panel links above point to the Turkish source.
Data model (in plain words)
- Board definition:
key,name,metric_expr,order(asc: a smaller score is better,desc: a larger score is better),season,tie_policy,player_visible,unitandunit_suffix. A definition is versioned. - Season (
season):none(never reset),daily,weekly,monthly(aligned to the UTC calendar: a day turns over at 00:00 UTC, a week on Monday at 00:00 UTC, a month on the first of the month) orcustom(a period ofcustom_period_mslength). When a season closes, its ranking is stored as an immutable record. - Score: An integer (
i64). A player’s best score in the season counts (the highest fordesc, the lowest forasc). Sending a worse score does not change the ranking. - Tie-break (
tie_policy): For now the only option isscore_achieved_player_id: score first, then whoever reached that score first, then the player ID. - Rank (
rank): Starts at 1. - Unit (
unit):points,money(smallest currency unit) orduration_ms;unit_suffixis a 1–8 character suffix ($,ms…). The unit is for display only. player_visible: Iftrue, a player session can read the board; the default isfalse.metric_expr: A CEL expression compiled at registration (required, cannot be empty). Boards that plugins create on their own useevent.score; write the same expression when you create one with REST.
Calls your game makes
| Method and path | Credential | What it does |
|---|---|---|
POST /v1/leaderboards | server key | Creates a board definition |
GET /v1/leaderboards, GET /v1/leaderboards/{key} | server key | Lists / reads definitions |
POST /v1/leaderboards/{key}/scores | server key | Writes a player score (Idempotency-Key required, 202) |
GET /v1/leaderboards/{key}/entries?limit=&cursor= | server key | Top ranks (paginated) |
GET /v1/leaderboards/{key}/around-me?player_id=&radius= | server key | Ranks around a player |
GET /v1/player/leaderboards/{key}?limit= | player session | The first page of the board and the player’s own row |
POST /v1/tournaments | server key | Creates a tournament |
GET /v1/tournaments/{id} | server key | Reads the tournament state |
POST /v1/tournaments/{id}/participants | server key | Registers a participant (Idempotency-Key) |
POST /v1/tournaments/{id}/start | server key | Generates the bracket and starts |
POST /v1/tournaments/{id}/matches/{match_id}/result | server key | Reports a match result or resolves a dispute (Idempotency-Key) |
Server endpoints require Authorization: Bearer crx_live_<prefix>_<secret>; a client key and a player session are rejected. The player endpoint works with a cookie or session token and x-crx-env: <environment uuid>; a developer key is not accepted.
Note
The TypeScript SDK has
player.leaderboard(key, { limit })for player reads; there is no typed client for the server endpoints (writing scores, definitions, tournaments), which are called with raw REST. See TypeScript SDK.
Board definition
POST /v1/leaderboards
Authorization: Bearer crx_live_<prefix>_<secret>
Content-Type: application/json
{
"key": "daily-earnings", "name": "Daily earnings",
"metric_expr": "event.score", "order": "desc",
"season": "daily", "tie_policy": "score_achieved_player_id",
"player_visible": true, "unit": "money", "unit_suffix": "$"
}
Returns 201 with { id, key, name, metric_expr, order, season, custom_period_ms, tie_policy, season_key }. For season: "custom", custom_period_ms must be positive.
Writing a score
POST /v1/leaderboards/daily-earnings/scores
Authorization: Bearer crx_live_<prefix>_<secret>
Idempotency-Key: score-player-1234-2026-11-03
Content-Type: application/json
{ "player_id": "0192f0f2-5b1a-7c3e-9d11-4a5b6c7d8e9f", "score": 4200 }
{ "entry": { "rank": 7, "player_id": "0192f0f2-…", "score": 4200, "achieved_at_ms": 1790000000000 }, "replayed": false }
The response is 202: the score has been durably accepted; the ranking projection is updated after a short delay, so a board read immediately afterwards can lag by a few seconds. Idempotency-Key is required: the same key and the same body return the same result with replayed: true.
Reading the board with a player session
GET /v1/player/leaderboards/daily-earnings?limit=10
x-crx-env: 0192f000-0000-7000-8000-00000000e001
{
"leaderboard": { "key": "daily-earnings", "name": "Daily earnings", "order": "desc", "season": "daily", "seasonKey": "2026-11-03", "seasonEndsAtMs": 1790121600000, "unit": "money", "unitSuffix": "$" },
"entries": [ { "rank": "1", "playerId": "…", "displayName": "Ayşe", "score": "9800", "achievedAtMs": 1790000000000 } ],
"me": { "rank": "7", "playerId": "…", "displayName": "You", "score": "4200", "achievedAtMs": 1790000000000 }
}
- Only
player_visibleboards can be read; otherwise404.404also if the module is disabled. limitis 1–50 (default 20).rankandscoreare decimal strings in JSON (losslessi64); the SDK turns them intobigint. Field names on this endpoint are camelCase.- Banned and deleted players are filtered out and ranks are numbered densely within the page;
meis your rank among visible players. If you have no score this season,meisnull. - The first page is cached for a short time (at most a few seconds).
Tournaments
POST /v1/tournaments
Authorization: Bearer crx_live_<prefix>_<secret>
Content-Type: application/json
{
"name": "Winter Cup", "format": "single_elimination", "participant_type": "player",
"capacity": 16,
"registration_opens_at_ms": 1790000000000, "registration_closes_at_ms": 1790086400000,
"seeding_source": "registration_order",
"prize_table": { "currency_key": "gold", "amount": 5000 }
}
format:single_elimination,double_elimination,round_robin.participant_type:playerorclan.capacityis 2–1024; the registration close must be after the open;nameis 1–200 characters.seeding_source:registration_order(registration order;leaderboard_idis not given) orleaderboard(player tournaments only;leaderboard_idis required).prize_table: an object withcurrency_key(no whitespace, at most 200 characters) and a positiveamount.- Flow: create → register with
participants({ "participant_id" }) → generate the bracket withstart→ reportresultfor each match (reporter_id,reported_side:homeawayadmin,outcome:home_winaway_windraw,score). Theadminside is for resolving a dispute. - Responses carry the tournament’s current view (
{ "tournament": { … } }).
Versioning and replay safety
- Writing a score, registering a participant and a match result require
Idempotency-Key(the same key + the same body: does not run again; a different body:422). - A board definition is versioned; list and ranking reads do not need a version header.
- General rules: Idempotency and concurrency.
Events
Accepting a score produces the score_submitted event, and a season rollover produces season_closed/season_opened; these can be used in quest objectives and rule automation. Among the types you can select as a webhook filter there is no leaderboard or tournament event; endpoints registered without a filter receive all events, and leaderboard events are included, but the envelope format is not a documented contract. For the final prize, use the reward granting flow on the Rewards page.
Limits
limitis 1–50 on player reads; pagination on the serverentriesendpoints is cursor-based.- Tournament
capacityis 2–1024. season: customneeds a positivecustom_period_ms.
Common errors
| Symptom | Cause | Fix |
|---|---|---|
404 (player read) | The board is not player_visible, does not exist, or the module is disabled | Check the definition |
403 | A server endpoint called with a client key or a player session | Use the server key from your server |
400 (Idempotency-Key) | The header is missing when writing a score | Send a key |
| The score was written but the ranking did not change | The player’s best score this season is better | Only a better score changes the ranking |
| The score was written but the board did not update right away | Projection delay | Wait a few seconds and read again |
400 (tournament) | capacity, the registration window, prize_table or the seeding rule is wrong | Fix the body |
Working example: write a score, read the board
/** Skor tablosu: sunucu puan yazar (ham REST), oyun istemcisi tabloyu oyuncu oturumuyla okur. */
import type { PlayerClient } from "@ferdiunal/open-play-ts";
/** Sunucu: `POST /v1/leaderboards/{key}/scores`. Sunucu anahtarı gerekir; Idempotency-Key zorunludur. */
export async function submitScore(baseUrl: string, serverApiKey: string, board: string, playerId: string, score: number, requestKey: string): Promise<number> {
const response = await fetch(`${baseUrl}/v1/leaderboards/${encodeURIComponent(board)}/scores`, {
method: "POST",
headers: {
Authorization: `Bearer ${serverApiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": requestKey,
},
body: JSON.stringify({ player_id: playerId, score }),
});
if (response.status !== 202) throw new Error(`Puan yazılamadı: HTTP ${response.status} ${await response.text()}`);
const body = (await response.json()) as { entry: { rank: number }; replayed: boolean };
return body.entry.rank;
}
/** Oyun istemcisi: tablonun ilk sayfası ve oyuncunun kendi satırı (`player_visible` tablolar). */
export async function showBoard(player: PlayerClient, board: string): Promise<void> {
const view = await player.leaderboard(board, { limit: 10 });
for (const row of view.entries) console.log(`${row.rank}. ${row.displayName} — ${row.score}`);
if (view.me !== null) console.log(`Siz: ${view.me.rank}. sıra, ${view.me.score} puan`);
}