Not translated yet — showing English.
Leaderboards
One board model whose axes compose into every kind of leaderboard: daily standings, best-lap boards, guild totals, seasons, tournaments.
When to use it
- Scores must rank players (daily standings, best-lap boards, guild totals) as one declared model, not a system per board.
- You need the standard reads (top-N, around-me, a named list of owners) without extra data modelling.
- Cycles must close on schedule, archive (never delete) and fire a reward hook with the final table.
- Suspicious scores must never enter the table: a pre-submit hook validates, caps or rejects with a typed reason.
- A tournament is the same board with an entry window, max entrants and attempts per cycle.
- Skip it when the number is never compared between players. A personal counter or a career total is ordinary data. The module orders results; it never computes them, and it runs no elimination bracket.
Who does what
| Actor | On this page |
|---|---|
player | reads top-N/around-me/own rank, subscribes to rank changes |
backend-service | submits results; corrects or rejects them in the pre-submit hook; grants rewards when a cycle closes |
operator | declares boards; closes a cycle early, corrects records (audited), watches submission rates |
At a glance
weekly-score: owner, aggregation, a Monday reset, server submits, the order key[Leaderboard("weekly-score")]
public static class WeeklyScore
{
public static Owner Owner = Owner.Player;
public static Aggregation Agg = Aggregation.Best; // set · best · increment · decrement
public static Reset Reset = Reset.Weekly(DayOfWeek.Monday); // Monday 00:00 UTC
public static Submit Submit = Submit.ServerOnly; // the default — clients are refused
[Rank(1, Sort.Descending)] public static int Score; // ranks first, high to low
[Rank(2, Sort.Ascending)] public static int ElapsedMs; // equal scores: the faster run wins
[Display] public static string Map; // travels with the row, never ranks it
}@Leaderboard('weekly-score')
export class WeeklyScore {
static owner = Owner.Player;
static agg = Aggregation.Best; // set · best · increment · decrement
static reset = Reset.weekly(DayOfWeek.Monday); // Monday 00:00 UTC
static submit = Submit.ServerOnly; // the default — clients are refused
@rank(1, Sort.Descending) static score: number; // ranks first, high to low
@rank(2, Sort.Ascending) static elapsedMs: number; // equal scores: the faster run wins
@display() static map: string; // travels with the row, never ranks it
}@leaderboard("weekly-score")
class WeeklyScore:
owner = Owner.PLAYER
agg = Aggregation.BEST # set · best · increment · decrement
reset = Reset.weekly(DayOfWeek.MONDAY) # Monday 00:00 UTC
submit = Submit.SERVER_ONLY # the default — clients are refused
score: int = rank(1, Sort.DESCENDING) # ranks first, high to low
elapsed_ms: int = rank(2, Sort.ASCENDING) # equal scores: the faster run wins
map: str = display() # travels with the row, never ranks itAttribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
USTRUCT(PSLeaderboard = (Name = "weekly-score", Owner = "Player", Aggregation = "Best",
Reset = "Weekly:Monday", Submit = "ServerOnly"))
struct FWeeklyScore
{
GENERATED_BODY()
UPROPERTY(PSRank = (Order = 1, Sort = "Descending")) int32 Score; // ranks first, high to low
UPROPERTY(PSRank = (Order = 2, Sort = "Ascending")) int32 ElapsedMs; // equal scores: the faster run wins
UPROPERTY(PSDisplay) FString Map; // travels with the row, never ranks it
};
// declarations compile into the same pushed model — playserv push from the UE project or CI[Leaderboard("weekly-score")]
public static class WeeklyScore
{
public static Owner Owner = Owner.Player;
public static Aggregation Agg = Aggregation.Best; // set · best · increment · decrement
public static Reset Reset = Reset.Weekly(DayOfWeek.Monday); // Monday 00:00 UTC
public static Submit Submit = Submit.ServerOnly; // the default — clients are refused
[Rank(1, Sort.Descending)] public static int Score; // ranks first, high to low
[Rank(2, Sort.Ascending)] public static int ElapsedMs; // equal scores: the faster run wins
[Display] public static string Map; // travels with the row, never ranks it
}The declaration lives beside the rest of your schema — in the server project, or in the UE or Unity project — and playserv push compiles it and sends it up: the board appears in the panel, empty, with its next reset already scheduled. Schedules are UTC, so this board closes Monday 00:00 UTC; Reset.Weekly(DayOfWeek.Monday, at: "03:00") moves the hour. Per-player local time is not a reset option — one table cannot close at twenty-four different moments.
The order key is a list, not a score plus a tie-break. Fields rank in the order you number them, each with its own direction, and the last tier is the platform's: on equal keys the earlier submission ranks higher, so two identical runs never swap places between two reads. A field outside the key — Map here — is carried for display and never moves a row.
Agg says what a second submit does to the one record an owner has in the current cycle:
Agg | A second submit | Idempotent |
|---|---|---|
Set | replaces the record with the submitted values | yes |
Best | replaces it only when the new values rank higher under the order key | yes |
Increment | adds the submitted values to the record — kills, laps, guild contribution | no — carry an idempotency key |
Decrement | subtracts them | no — carry an idempotency key |
A submit that does not beat a Best record is not an error: it comes back accepted, order unchanged. Increment and Decrement are the two a retried call would apply twice, so they take the same idempotency key as every other retryable write.
Submitting is one call, and on this board it comes from server code because the declaration said so:
Submit: the two ranked fields and the display field, from the function that owns the resultawait PlayServ.Leaderboards.Submit("weekly-score", playerId,
score: 4200, elapsedMs: 61230, map: "caves");await PlayServ.leaderboards.submit('weekly-score', playerId,
{ score: 4200, elapsedMs: 61230, map: 'caves' });await playserv.leaderboards.submit("weekly-score", player_id,
score=4200, elapsed_ms=61230, map="caves")Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
The call exists in Unreal. This board keeps the default Submit.ServerOnly, so the platform accepts a submit only from a cloud function or a room host under its host key. Declare Submit.Players and the same call works from the client. See Access & Roles.
The call exists in Unity. This board keeps the default Submit.ServerOnly, so the platform accepts a submit only from a cloud function or a room host under its host key. Declare Submit.Players and the same call works from the client. See Access & Roles.
Three things in that call are worth reading off separately:
| In the call | What it is |
|---|---|
PlayServ · playserv | the cloud-function handle and the client instance the SDK hands you at startup. Same API, two callers — in Go they are ps and psv, and each snippet uses the one its caller has |
| the submitter | the function that owns the match result. In Tanks that is the room's on dispose hook (Rooms), which runs with the final state in hand |
playerId | the platform player id from Auth, never a name you chose: a hook reads it off its payload (e.By.PlayerId in the lesson), and a room host submits the id of the seat it owns |
The values are the fields the declaration named — an undeclared field is refused, not stored.
The reads every game needs, and the subscription that keeps them current:
var top = await playserv.Leaderboards.Top("weekly-score", 100);
var around = await playserv.Leaderboards.AroundMe("weekly-score", 5);
var members = await playserv.Group("guild-42").GetMembers();
var guild = await playserv.Leaderboards.ForOwners("weekly-score", members);
var live = playserv.Leaderboards.OnRankChanged("weekly-score", r => UpdateHud(r.Rank, r.Score));
live.Cancel(); // later, when the HUD closesconst top = await playserv.leaderboards.top('weekly-score', 100);
const around = await playserv.leaderboards.aroundMe('weekly-score', 5);
const members = await playserv.group('guild-42').getMembers();
const guild = await playserv.leaderboards.forOwners('weekly-score', members);
const live = playserv.leaderboards.onRankChanged('weekly-score', (r) => updateHud(r.rank, r.score));
live.cancel(); // later, when the HUD closestop = await playserv.leaderboards.top("weekly-score", 100)
around = await playserv.leaderboards.around_me("weekly-score", 5)
members = await playserv.group("guild-42").get_members()
guild = await playserv.leaderboards.for_owners("weekly-score", members)
live = playserv.leaderboards.on_rank_changed("weekly-score", lambda r: update_hud(r.rank, r.score))
live.cancel() # later, when the HUD closesAttribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
Client->Leaderboards->Of<FWeeklyScore>()->Get(
TPSOnResult<FPSBoard*>::CreateWeakLambda(this, [this](const TPSResult<FPSBoard*>& Result)
{
if (!Result.HasValue()) { return; }
OnBoard(Result.Value());
}));
// in OnBoard(FPSBoard* Board): the page, the window, and the guild rows
Board->Entries->Select().Page(100).Then(
TPSOnResult<TPSPage<FPSLeaderboardEntry>>::CreateWeakLambda(this, [this](const TPSResult<TPSPage<FPSLeaderboardEntry>>& Top)
{
if (!Top.HasValue()) { return; }
Hud->ShowTop(Top.Value().Rows);
}));
Board->Entries->SelectAround(MyPlayerId, /*Radius*/ 5,
TPSOnResult<TArray<FPSLeaderboardEntry>>::CreateWeakLambda(this, [this](const TPSResult<TArray<FPSLeaderboardEntry>>& Around)
{
if (!Around.HasValue()) { return; }
Hud->ShowWindow(Around.Value());
}));
// guild rows: the member list first, then the entries for exactly those owners
Guild->Members->Select().Then(
TPSOnResult<TArray<FPSMember>>::CreateWeakLambda(this, [this](const TPSResult<TArray<FPSMember>>& Members)
{
if (!Members.HasValue()) { return; }
TArray<FPSPlayerId> Owners;
for (const FPSMember& Member : Members.Value()) { Owners.Add(Member.PlayerId); }
Board->Entries->Select().ForOwners(Owners).Then(OnGuildRows);
}));
TPSSubscription MyRank = Board->Subscribe->Mine(
[this](const FPSLeaderboardEntry& Mine) { UpdateHud(Mine.Rank, Mine.Score); });
MyRank.Unsubscribe(); // later, when the HUD closes
// co_await support ships later: a built-in SDK coroutine wrapper that respects Unreal's GC and threading.var top = await playserv.Leaderboards.Top("weekly-score", 100);
var around = await playserv.Leaderboards.AroundMe("weekly-score", 5);
var members = await playserv.Group("guild-42").GetMembers();
var guild = await playserv.Leaderboards.ForOwners("weekly-score", members);
var live = playserv.Leaderboards.OnRankChanged("weekly-score", r => UpdateHud(r.Rank, r.Score));
live.Cancel(); // later, when the HUD closesAroundMe("weekly-score", 5) is a window by rank, not a page: five rows above you, five below, plus your own — eleven rows, trimmed symmetrically where the table ends, so rank 2 gets a shorter window on both sides rather than a shifted one. Top is paged: it returns the first N rows and a cursor, and after: walks the rest.
ForOwners is how a friends board works. The platform holds no friend graph; you pass the owners your game already has — a group's members, or a list of ids from your own data — and each row comes back with its rank in the full table, not a rank within the list.
OnRankChanged delivers the local player's own rank and nothing else: a board with fifty thousand entrants does not push every reshuffle to every client. The callback receives the changed row — rank, the ranked fields, the display fields — and Cancel() ends the subscription. Rank itself is a snapshot: two reads a second apart can differ while submits land, though your own submit is always visible to your own next read.
The model
What a board declares.
| Axis | Values | How you set it |
|---|---|---|
| Owner | player · group | Owner = Owner.Player — a guild board is the same board with Owner.Group |
| Order key | one or more declared fields, each ascending or descending | [Rank(1, Sort.Descending)] int Score |
| Aggregation | set · best · increment · decrement | Agg = Aggregation.Best |
| Reset | a schedule in UTC; a cycle expires, never deletes | Reset = Reset.Weekly(DayOfWeek.Monday) |
| Who may submit | server only (the default) · players | Submit = Submit.ServerOnly |
| Display fields | declared and typed; never part of the order | [Display] string Map |
| Owner list | chosen at read time, not declared | ForOwners("weekly-score", ids) — friends, a guild, a lobby |
| Tournament rules | entry window · max entrants · attempts per cycle · join-required | Rules = Tournament.Define(…), in the table under Tournaments |
There is no scope axis: a board per region, per room or per season is a board per key, and the key is what your code references.
What is true of every board.
| Always | What it is |
|---|---|
direction and operator | are immutable after the first write: changing them would silently re-rank history; the way to change a mechanic is a new generation, not an edit |
exactly one entry per owner per generation | a second is not a second row |
an entry | is not an entity: no lifecycle of its own, no machine: it is created by the first submission and changed by the operator the board declared |
fields outside the order key never affect the order | they are display, and that is why they are declared separately |
a generation | expires, it does not delete: open → expired → evicted from retention, and expired generations stay readable for the declared retention period |
a board | is authored content: declared in code, addressed by a key, reaching the admin console, under the seed ownership mode so a designer's schedule edits survive the next push |
What a cycle is, and what closing one does.
| What it is | |
|---|---|
a reset | closes a cycle rather than deleting it |
a closed cycle | stops taking submits and stays readable under its label — Top("weekly-score", 100, cycle: label), a read parameter rather than an export job |
the close event | carries that label, so a handler reads exactly the table that closed and not the empty one that just opened |
The two hooks on a board, and their kinds differ.
| Hook | What it may do |
|---|---|
pre-submit | a gatekeeper, called before the write, and the platform waits for it. It returns one of three verdicts: accept, accept a corrected submission, or reject. It may correct the values against your own entities or cap them; it may not change the record's owner or its board, which are already claimed. It fails closed, and a rejection reaches the caller as a typed Problem (Core) |
cycle-closed | an observer: fired after the fact, it cannot veto, and a failure there leaves the cycle closed |
weekly-score: pre-submit rejects an impossible score, cycle-closed grants the top 10[Before(Leaderboards.Submit, board: "weekly-score")]
public static Verdict Validate(Submission s) =>
s.Score > 10_000 ? s.Reject("score above the map maximum") : s.Accept();
[After(Leaderboards.CycleClosed, board: "weekly-score")]
public static async Task Reward(CycleClosed closed)
{
var final = await PlayServ.Leaderboards.Top("weekly-score", 10, cycle: closed.Cycle);
foreach (var row in final)
await PlayServ.Commerce.Grant(row.PlayerId, entitlement: "chest.gold", origin: Grant.Reward);
}export const validate = before(Leaderboards.submit, { board: 'weekly-score' },
(s: Submission) => s.score > 10_000 ? s.reject('score above the map maximum') : s.accept());
export const reward = after(Leaderboards.cycleClosed, { board: 'weekly-score' },
async (closed: CycleClosed) => {
const final = await PlayServ.leaderboards.top('weekly-score', 10, { cycle: closed.cycle });
for (const row of final)
await PlayServ.commerce.grant(row.playerId, { entitlement: 'chest.gold', origin: Grant.Reward });
});@before(leaderboards.submit, board="weekly-score")
def validate(s: Submission) -> Verdict:
return s.reject("score above the map maximum") if s.score > 10_000 else s.accept()
@after(leaderboards.cycle_closed, board="weekly-score")
async def reward(closed: CycleClosed):
final = await playserv.leaderboards.top("weekly-score", 10, cycle=closed.cycle)
for row in final:
await playserv.commerce.grant(row.player_id, entitlement="chest.gold", origin=Grant.REWARD)Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unreal subscribes to the cycle-closed event. Engine-authored functions are planned: Blueprint graphs, and C++ (translated to a platform-validated script target). Both are analyzed and pushed like any declaration; execution stays on the platform.
A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unity subscribes to the cycle-closed event.
The reward is a Commerce grant rather than a mechanic of this module: chest.gold is a catalogue id, and the reward origin is what separates the grant from a purchase — refunds, revocation and the entitlement-changed event work on it exactly as they do on a bought item.
Tournaments. A tournament is this board plus participation constraints — there is no second mechanism and no separate entity. The four constraints, with their units and their behaviour at the edge:
| Constraint | Declared as | At the boundary |
|---|---|---|
| entry window | entryWindow: TimeSpan — how long joining stays open after the cycle opens | a join after it closes is refused; the cycle still runs to its reset |
| max entrants | maxEntrants: int — records in one cycle | entrant 65 of 64 is refused as a conflict, and nothing is evicted — a board that dropped its worst rows would rank whoever arrived first |
| attempts per cycle | attemptsPerCycle: int — submits per owner | the next submit answers "attempts exhausted" — a conflict, not a permission error, and the counter resets with the cycle |
| join-required | joinRequired: true — entrants are a membership, not everyone who plays | a submit from a non-entrant is refused |
A daily tournament declares all four end to end.
Errors
A player session calling an operation this board reserves for fn — a submit to a server-only board, an early cycle close — is refused as a permission error before anything is written; the same call from a cloud function goes through. A submit into a cycle that has already closed is a conflict instead; the retry is a submit into the current one.
Limits
Every limit with what happens at its boundary.
| Limit | At the boundary | Number |
|---|---|---|
| rows per read | the page is trimmed, "there is more" stays true, after: continues | page cap set per project |
| window around an owner | trimmed symmetrically | window cap set per project |
| records in one cycle | the submit is refused as a conflict; no eviction | maxEntrants per board; unbounded when unset |
| attempts per owner per cycle | conflict "attempts exhausted", cleared by the reset | attemptsPerCycle per board; unbounded when unset |
| submit rate per owner | rate-limit refusal carrying the moment a retry is allowed | rate set per project |
| boards per project | a new declaration is refused at deploy | limit set per project |
| retention of closed cycles | the cycle leaves storage with an event; reads then answer not-found | retention window set per project |