Not translated yet — showing English.
A daily tournament
A daily tournament with an entry window, seeded rooms and a prize payout, built from declarations and hooks on modules that already have pages: leaderboards, matchmaking, rooms, commerce and messaging.
Declare the board with an entry window and attempt limits
A tournament is an ordinary leaderboard declaration plus participation constraints: an entry window, a cap on entrants and attempts per cycle. Nothing about scoring changes: the order key, the aggregation and the reset stay as on any board.
daily-tournament — score descending, daily reset, a 2-hour entry window, 64 entrants, three attempts[Leaderboard("daily-tournament")]
public static class DailyTournament
{
public static Owner Owner = Owner.Player;
public static Aggregation Agg = Aggregation.Best;
public static Reset Reset = Reset.Daily(); // 00:00 UTC
public static Submit Submit = Submit.ServerOnly;
public static Tournament Rules = Tournament.Define(
entryWindow: TimeSpan.FromHours(2), maxEntrants: 64,
attemptsPerCycle: 3, joinRequired: true);
[Rank(1, Sort.Descending)] public static int Score;
}@Leaderboard('daily-tournament')
export class DailyTournament {
static owner = Owner.Player;
static agg = Aggregation.Best;
static reset = Reset.daily(); // 00:00 UTC
static submit = Submit.ServerOnly;
static rules = Tournament.define({ entryWindow: hours(2), maxEntrants: 64,
attemptsPerCycle: 3, joinRequired: true });
@rank(1, Sort.Descending) static score: number;
}@leaderboard("daily-tournament")
class DailyTournament:
owner = Owner.PLAYER
agg = Aggregation.BEST
reset = Reset.daily() # 00:00 UTC
submit = Submit.SERVER_ONLY
rules = Tournament.define(entry_window=hours(2), max_entrants=64,
attempts_per_cycle=3, join_required=True)
score: 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 = "daily-tournament", Owner = "Player", Aggregation = "Best",
Reset = "Daily", Submit = "ServerOnly"),
PSTournament = (EntryWindow = "2h", MaxEntrants = 64,
AttemptsPerCycle = 3, JoinRequired = "true"))
struct FDailyTournament
{
GENERATED_BODY()
UPROPERTY(PSRank = (Order = 1, Sort = "Descending")) int32 Score;
};
// declarations compile into the same pushed model — playserv push from the UE project or CI[Leaderboard("daily-tournament")]
public static class DailyTournament
{
public static Owner Owner = Owner.Player;
public static Aggregation Agg = Aggregation.Best;
public static Reset Reset = Reset.Daily(); // 00:00 UTC
public static Submit Submit = Submit.ServerOnly;
public static Tournament Rules = Tournament.Define(
entryWindow: TimeSpan.FromHours(2), maxEntrants: 64,
attemptsPerCycle: 3, joinRequired: true);
[Rank(1, Sort.Descending)] public static int Score;
}Push it and the panel shows an empty bracket with its window scheduled. joinRequired: true makes the entrants a membership rather than everyone who plays, so a submit from a non-entrant is refused. See Leaderboards for the rest of the axis list and for what each constraint does at its boundary.
The window opens: a party joins, rooms get seeded
Once the entry window opens, players queue exactly as for any match: create or join a party, then one Find call. The matchmaker places the party into a tournament bracket and Rooms seeds the match: the same placement-and-seat path every match uses, scoped to the tournament's queue.
var party = await playserv.Matchmaking.Party.Create();
await party.Invite(friendId);
var seat = await playserv.Matchmaking.Find("daily-tournament");
var room = await playserv.Rooms.Join(seat);const party = await playserv.matchmaking.party.create();
await party.invite(friendId);
const seat = await playserv.matchmaking.find('daily-tournament');
const room = await playserv.rooms.join(seat);party = await playserv.matchmaking.party.create()
await party.invite(friend_id)
seat = await playserv.matchmaking.find("daily-tournament")
room = await playserv.rooms.join(seat)Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
// a party first; the ticket then carries the party
Client->Matchmaking->Parties->Create(FPSIdempotencyKey(PartyId),
TPSOnResult<FPSParty*>::CreateWeakLambda(this, [this](const TPSResult<FPSParty*>& PartyResult)
{
if (!PartyResult.HasValue()) { return; }
FPSParty* Party = PartyResult.Value();
Party->Invitations->Create(FriendId);
Client->Matchmaking->Of<FDailyTournament>()->Tickets->Create(FPSTicketClaim{ .Party = Party },
TPSOnResult<FPSTicket*>::CreateWeakLambda(this, [this](const TPSResult<FPSTicket*>& TicketResult)
{
if (!TicketResult.HasValue()) { return; }
TPSSubscription Placement = TicketResult.Value()->Subscribe([this](const FPSSeat& Seat)
{
Client->Rooms->Join(Seat,
TPSOnResult<FPSRoom*>::CreateWeakLambda(this, [this](const TPSResult<FPSRoom*>& JoinResult)
{
if (!JoinResult.HasValue()) { return; }
EnterTournament(JoinResult.Value());
}));
});
}));
}));
// co_await support ships later: a built-in SDK coroutine wrapper that respects Unreal's GC and threading.var party = await playserv.Matchmaking.Party.Create();
await party.Invite(friendId);
var seat = await playserv.Matchmaking.Find("daily-tournament");
var room = await playserv.Rooms.Join(seat);The caps from step 1 belong to the board, not to the matchmaker: the queue places parties, and it is the board that meets an entrant over its cap or a player past their attempts. Entrant 65 of 64 is refused as a conflict with nothing evicted, and a fourth submit in one cycle answers "attempts exhausted" — also a conflict, cleared by the daily reset rather than by asking for a permission.
Scores submit via the on-dispose hook
Rooms don't self-report a winner into a leaderboard; that link is a hook, with the same Extensibility contract as everywhere else. The room's on dispose hook (Rooms) is the last thing that runs with the match's final state in hand, and it submits from there.
[After] hook on room dispose submits the bracket's final score[After(Rooms.Disposed, room: "daily-tournament")]
public static Task SubmitScore(RoomDisposed e) =>
PlayServ.Leaderboards.Submit("daily-tournament", e.State.Winner, score: e.State.FinalScore);export const submitScore = after(Rooms.disposed, { room: 'daily-tournament' }, (e: RoomDisposed) =>
PlayServ.leaderboards.submit('daily-tournament', e.state.winner, { score: e.state.finalScore }));@after(rooms.disposed, room="daily-tournament")
async def submit_score(e: RoomDisposed):
await playserv.leaderboards.submit("daily-tournament", e.state.winner, score=e.state.final_score)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.
Winner and FinalScore are fields this game's own room template declares in its state — the platform adds nothing to the snapshot (Rooms is where template state is declared). The dispose hook (Rooms.Disposed) hands the final snapshot over, so the match is never recomputed. The pre-submit hook from Leaderboards still runs first — a bracket score is subject to the same correct-cap-or-reject contract as any other submit.
The cycle closes: rewards granted, player notified
The daily reset from step 1 closes the cycle exactly as any leaderboard reset does, and fires CycleClosed carrying the closed cycle's label. The hook reads the table by that label, so it gets the table that just closed rather than the empty one that just opened.
One hook does the rest: it grants the prize through Commerce's entitlement path and pushes the result through Messaging, so there is no separate payout job to run. A notification is addressed to one actor, so the top 8 is a loop of eight, each carrying its own rank argument into the template.
[After(Leaderboards.CycleClosed, board: "daily-tournament")]
public static async Task RewardAndNotify(CycleClosed closed)
{
var final = await PlayServ.Leaderboards.Top("daily-tournament", 8, cycle: closed.Cycle);
foreach (var row in final)
{
await PlayServ.Commerce.Grant(row.PlayerId, entitlement: "trophy.daily", origin: Grant.Reward);
await PlayServ.Messaging.Notify(row.PlayerId, Template.Named("daily-tournament-won"),
args: new { rank = row.Rank });
}
}export const rewardAndNotify = after(Leaderboards.cycleClosed, { board: 'daily-tournament' },
async (closed: CycleClosed) => {
const final = await PlayServ.leaderboards.top('daily-tournament', 8, { cycle: closed.cycle });
for (const row of final) {
await PlayServ.commerce.grant(row.playerId, { entitlement: 'trophy.daily', origin: Grant.Reward });
await PlayServ.messaging.notify(row.playerId, Template.named('daily-tournament-won'),
{ args: { rank: row.rank } });
}
});@after(leaderboards.cycle_closed, board="daily-tournament")
async def reward_and_notify(closed: CycleClosed):
final = await playserv.leaderboards.top("daily-tournament", 8, cycle=closed.cycle)
for row in final:
await playserv.commerce.grant(row.player_id, entitlement="trophy.daily", origin=Grant.REWARD)
await playserv.messaging.notify(row.player_id, Template.named("daily-tournament-won"),
args={"rank": row.rank})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 and notification events. 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 and notification events.
The numbers in step 1 are seeds
LiveOps retunes the window, the entrant cap and the attempt count in the panel, and the next deploy doesn't overwrite their change: a repeat push leaves seed values alone, so a later change to them in code doesn't reach the deployed board (Schema as Code).
Making this a weekly tournament is one edit — Reset.Daily() becomes Reset.Weekly(DayOfWeek.Monday), and steps 2 to 4 stay as they are.
Where to go next
- Leaderboards — the tournament axes (entry window, max entrants, attempts).
- Matchmaking → Rooms — parties, placement and seeding.
- Extensibility → Commerce → Messaging — the hook chain that pays out.
- Core Concepts — the vocabulary every module page assumes.