Skip to main content
Services around the game

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

ActorOn this page
playerreads top-N/around-me/own rank, subscribes to rank changes
backend-servicesubmits results; corrects or rejects them in the pre-submit hook; grants rewards when a cycle closes
operatordeclares boards; closes a cycle early, corrects records (audited), watches submission rates

At a glance

Declaring 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 it
Coming soon — Go

Attribute-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
Unity C# is the same C# API here — the same template class compiles in Unity, on the 2021.3 baseline — no C# 12 syntax here
[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:

AggA second submitIdempotent
Setreplaces the record with the submitted valuesyes
Bestreplaces it only when the new values rank higher under the order keyyes
Incrementadds the submitted values to the record — kills, laps, guild contributionno — carry an idempotency key
Decrementsubtracts themno — 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:

One Submit: the two ranked fields and the display field, from the function that owns the result
await 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")
Coming soon — Go

Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.

A different actor calls this

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.

A different actor calls this

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 callWhat it is
PlayServ · playservthe 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 submitterthe 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
playerIdthe 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:

Top 100, the window around me, a guild's rows by owner list, and a live rank subscription
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 closes
const 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 closes
top     = 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 closes
Coming soon — Go

Attribute-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.
Unity C# is the same C# API here — runs as-is in Unity against the generated types
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 closes

AroundMe("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.

AxisValuesHow you set it
Ownerplayer · groupOwner = Owner.Player — a guild board is the same board with Owner.Group
Order keyone or more declared fields, each ascending or descending[Rank(1, Sort.Descending)] int Score
Aggregationset · best · increment · decrementAgg = Aggregation.Best
Reseta schedule in UTC; a cycle expires, never deletesReset = Reset.Weekly(DayOfWeek.Monday)
Who may submitserver only (the default) · playersSubmit = Submit.ServerOnly
Display fieldsdeclared and typed; never part of the order[Display] string Map
Owner listchosen at read time, not declaredForOwners("weekly-score", ids) — friends, a guild, a lobby
Tournament rulesentry window · max entrants · attempts per cycle · join-requiredRules = 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.

AlwaysWhat it is
direction and operatorare 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 generationa second is not a second row
an entryis 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 orderthey are display, and that is why they are declared separately
a generationexpires, it does not delete: open → expired → evicted from retention, and expired generations stay readable for the declared retention period
a boardis 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
a submit, from server codeyour pre-submit hook: accept, correct or rejectthe open cycle, ranked by the order keythe reset (Monday 00:00 UTC) closes itthe closed cycle, readable under its labelthe next cycle opens, emptyyour cycle-closed hook: reads by the label, grants rewardsevicted when retention ends, with an event acceptedthe close event carries the label
One cycle: submits pass your gatekeeper, the reset closes the table, and the cycle-closed hook reads it by its label while the next cycle starts empty

What a cycle is, and what closing one does.

What it is
a resetcloses a cycle rather than deleting it
a closed cyclestops taking submits and stays readable under its label — Top("weekly-score", 100, cycle: label), a read parameter rather than an export job
the close eventcarries 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.

HookWhat it may do
pre-submita 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-closedan observer: fired after the fact, it cannot veto, and a failure there leaves the cycle closed
Both hooks on 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)
Coming soon — Go

Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.

Runs off the engine

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.

Runs off the engine

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:

ConstraintDeclared asAt the boundary
entry windowentryWindow: TimeSpan — how long joining stays open after the cycle opensa join after it closes is refused; the cycle still runs to its reset
max entrantsmaxEntrants: int — records in one cycleentrant 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 cycleattemptsPerCycle: int — submits per ownerthe next submit answers "attempts exhausted" — a conflict, not a permission error, and the counter resets with the cycle
join-requiredjoinRequired: true — entrants are a membership, not everyone who playsa 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.

LimitAt the boundaryNumber
rows per readthe page is trimmed, "there is more" stays true, after: continuespage cap set per project
window around an ownertrimmed symmetricallywindow cap set per project
records in one cyclethe submit is refused as a conflict; no evictionmaxEntrants per board; unbounded when unset
attempts per owner per cycleconflict "attempts exhausted", cleared by the resetattemptsPerCycle per board; unbounded when unset
submit rate per ownerrate-limit refusal carrying the moment a retry is allowedrate set per project
boards per projecta new declaration is refused at deploylimit set per project
retention of closed cyclesthe cycle leaves storage with an event; reads then answer not-foundretention window set per project