Not translated yet — showing English.
A leaderboard in Tanks
Tanks, the sample arena from Getting Started, has no leaderboard. This lesson adds a weekly kill board in three steps: declare the board, submit from the kill hook, read it in the client. Each step links to the module page that owns what it uses.
Declare the board
A board is a declaration: which field ranks it, how repeat submits combine, when it resets, and who may submit. Aggregation.Increment adds each submit to the running total, so one kill is one point. Submit.ServerOnly is the default and it closes the board to clients, which is what makes step 2 the only way in.
tanks-weekly-kills — kills descending, incrementing, resets Monday, server submits only[Leaderboard("tanks-weekly-kills")]
public static class WeeklyKills
{
public static Owner Owner = Owner.Player;
public static Aggregation Agg = Aggregation.Increment;
public static Reset Reset = Reset.Weekly(DayOfWeek.Monday);
public static Submit Submit = Submit.ServerOnly;
[Rank(1, Sort.Descending)] public static int Kills;
}@Leaderboard('tanks-weekly-kills')
export class WeeklyKills {
static owner = Owner.Player;
static agg = Aggregation.Increment;
static reset = Reset.weekly(DayOfWeek.Monday);
static submit = Submit.ServerOnly;
@rank(1, Sort.Descending) static kills: number;
}@leaderboard("tanks-weekly-kills")
class WeeklyKills:
owner = Owner.PLAYER
agg = Aggregation.INCREMENT
reset = Reset.weekly(DayOfWeek.MONDAY)
submit = Submit.SERVER_ONLY
kills: int = rank(1, Sort.DESCENDING)Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
USTRUCT(PSLeaderboard = (Name = "tanks-weekly-kills", Owner = "Player", Aggregation = "Increment",
Reset = "Weekly:Monday", Submit = "ServerOnly"))
struct FWeeklyKills
{
GENERATED_BODY()
UPROPERTY(PSRank = (Order = 1, Sort = "Descending")) int32 Kills;
};
// declarations compile into the same pushed model — playserv push from the UE project or CI[Leaderboard("tanks-weekly-kills")]
public static class WeeklyKills
{
public static Owner Owner = Owner.Player;
public static Aggregation Agg = Aggregation.Increment;
public static Reset Reset = Reset.Weekly(DayOfWeek.Monday);
public static Submit Submit = Submit.ServerOnly;
[Rank(1, Sort.Descending)] public static int Kills;
}Push it with playserv push and the board appears in the panel, empty, with its Monday cycle already scheduled. The axes you did not set keep their defaults. See Leaderboards for the full axis list: owner, order key, display fields, tournament rules.
Schedules are UTC
The weekly reset runs at Monday 00:00 UTC, not in the players' time zone.
Submit from the kill hook
Tanks already ends a life through the HP threshold declared on the tank: at zero HP the death transition fires and the platform calls the hook after it. The hook is a cloud function, typed in and typed out, so submitting a kill is one line inside it.
[After] hook on hp depletion submits one kill for the killer[After(Stats.Depleted, stat: "hp")]
public static Task SubmitKill(StatEvent e) =>
PlayServ.Leaderboards.Submit("tanks-weekly-kills", e.By.PlayerId,
kills: 1, idempotencyKey: e.Id);export const submitKill = after(Stats.depleted, { stat: 'hp' }, (e: StatEvent) =>
PlayServ.leaderboards.submit('tanks-weekly-kills', e.by.playerId,
{ kills: 1, idempotencyKey: e.id }));@after(stats.depleted, stat="hp")
async def submit_kill(e: StatEvent):
await playserv.leaderboards.submit("tanks-weekly-kills", e.by.player_id,
kills=1, idempotency_key=e.id)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 — your Unreal code subscribes to the resulting rank changed 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 — your Unity code subscribes to the resulting rank changed event.
e.By is the attacker the damage carried, so no bookkeeping tracks who shot whom. e.Id is the event's own id, and passing it as the idempotency key is what an Increment board needs: a re-delivered kill event counts once, not twice. The hook point, the ordering guarantees and the veto contract are Extensibility; the threshold that fires it is a stat preset on the tank, and the score row it writes is ordinary data you can query.
Read the board in the client
Two reads cover the whole UI: the top of the board and the window around the local player — five rows above, five below, plus your own. Both come back as ranked entries with kills and display name, ready to bind to a list. A subscription keeps the panel current while the match runs, and it delivers the local player's rank only.
var top = await playserv.Leaderboards.Top("tanks-weekly-kills", 20);
var around = await playserv.Leaderboards.AroundMe("tanks-weekly-kills", 5);
playserv.Leaderboards.OnRankChanged("tanks-weekly-kills", r => UpdateHud(r));const top = await playserv.leaderboards.top('tanks-weekly-kills', 20);
const around = await playserv.leaderboards.aroundMe('tanks-weekly-kills', 5);
playserv.leaderboards.onRankChanged('tanks-weekly-kills', (r) => updateHud(r));top = await playserv.leaderboards.top("tanks-weekly-kills", 20)
around = await playserv.leaderboards.around_me("tanks-weekly-kills", 5)
playserv.leaderboards.on_rank_changed("tanks-weekly-kills", lambda r: update_hud(r))Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
Client->Leaderboards->Of<FWeeklyKills>()->Get(
TPSOnResult<FPSBoard*>::CreateWeakLambda(this, [this](const TPSResult<FPSBoard*>& Result)
{
if (!Result.HasValue()) { return; }
OnBoard(Result.Value());
}));
// in OnBoard(FPSBoard* Board):
Board->Entries->Select().Page(20).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());
}));
TPSSubscription MyRank = Board->Subscribe->Mine(
[this](const FPSLeaderboardEntry& Mine) { UpdateHud(Mine); });
// co_await support ships later: a built-in SDK coroutine wrapper that respects Unreal's GC and threading.var top = await playserv.Leaderboards.Top("tanks-weekly-kills", 20);
var around = await playserv.Leaderboards.AroundMe("tanks-weekly-kills", 5);
playserv.Leaderboards.OnRankChanged("tanks-weekly-kills", r => UpdateHud(r));Monday's reset closes the cycle instead of deleting it, so last week's table stays readable by its label — the same Top call with a cycle: argument. A reward hook on the cycle close is the natural fourth step, described on Leaderboards.
Where to go next
- Leaderboards — the axes, cycles, tournaments, and the pre-submit hook that caps suspicious scores.
- Extensibility — every hook point, in order, with the veto contract.
- Entity presets — the stat threshold that fired the kill in step 2.
- Health crates in Tanks — the other Tanks example: two declarations and one hook.
- Getting Started — the Tanks arena this lesson extends.
- Core Concepts — the vocabulary every module page assumes.