Cette page n’est pas encore traduite : la version anglaise est affichée.
Tournoi quotidien
Un tournoi quotidien avec une période d’inscription (entry window), des salles préparées à l’avance et une remise de prix, construit uniquement avec des déclarations et des hooks sur des modules qui ont déjà leur page : classements, matchmaking, salles, commerce et messagerie.
Déclarer le classement avec une période d’inscription et des tentatives limitées
Un tournoi, c’est une déclaration de classement ordinaire, à laquelle s’ajoutent des contraintes de participation : une période d’inscription, un plafond de participants et un nombre de tentatives par cycle. Le calcul du score ne change pas : la clé de tri, l’agrégation et la réinitialisation restent celles de n’importe quel classement (leaderboard).
daily-tournament — score par ordre décroissant, réinitialisation quotidienne, période d’inscription de 2 heures, 64 participants, trois tentatives[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)La déclaration par attributs en Go n’est pas encore tranchée (O-1). Les exemples arriveront quand la forme d’écriture sera choisie.
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;
}Publiez la déclaration : le panneau affiche un tableau de tournoi vide, avec sa période d’inscription planifiée. Avec joinRequired: true, les participants sont définis par une appartenance (membership) et non par le simple fait de jouer ; la soumission (submit) d’un joueur non inscrit est donc refusée. Le reste des axes, et le comportement de chaque contrainte à sa limite, sont décrits sur Leaderboards.
La période s’ouvre : un groupe s’inscrit, les salles sont préparées
Dès l’ouverture de la période d’inscription, les joueurs font la queue comme pour n’importe quel match : ils créent ou rejoignent un groupe, puis passent un seul appel Find. Le matchmaker place le groupe de joueurs (party) dans un tableau du tournoi, et Rooms prépare le match. C’est le même chemin de placement et d’attribution de place (seat) que pour tout autre match, limité à la file du tournoi.
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)La déclaration par attributs en Go n’est pas encore tranchée (O-1). Les exemples arriveront quand la forme d’écriture sera choisie.
// 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);Les plafonds de l’étape 1 appartiennent au classement, pas au matchmaker. La file ne fait que placer les groupes ; c’est le classement qui arrête un participant au-delà du plafond ou un joueur qui a épuisé ses tentatives. Le 65ᵉ participant pour 64 places est refusé pour conflit, sans que personne soit évincé. Une quatrième soumission dans le même cycle reçoit « attempts exhausted » : c’est aussi un conflit, que lève la réinitialisation quotidienne, et non une demande de permission.
Soumettre les scores depuis le hook de fermeture
Une salle (room) ne transmet pas d’elle-même son vainqueur à un classement : ce lien passe par un hook, soumis au même contrat que partout ailleurs (Extensibility). Le hook on dispose de la salle (Rooms) est le dernier code à s’exécuter avec l’état final du match en main ; c’est de là qu’il soumet le score.
[After] sur la fermeture de la salle soumet le score final du tournoi[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)La déclaration par attributs en Go n’est pas encore tranchée (O-1). Les exemples arriveront quand la forme d’écriture sera choisie.
Un hook est une fonction cloud : il s’exécute sur la plateforme, pas dans le moteur. Écrivez-le en C#, TypeScript ou Python ; votre code Unreal s’abonne à l’événement rank changed qui en résulte. Des fonctions écrites dans le moteur sont prévues : des graphes Blueprint et du C++ (traduit vers une cible de script validée par la plateforme). Les deux seront analysés et publiés comme n’importe quelle déclaration, et l’exécution restera sur la plateforme.
Un hook est une fonction cloud : il s’exécute sur la plateforme, pas dans le moteur. Écrivez-le en C#, TypeScript ou Python ; votre code Unity s’abonne à l’événement rank changed qui en résulte.
Winner et FinalScore sont des champs que le modèle de salle (room template) de ce jeu déclare dans son état ; la plateforme n’ajoute rien au snapshot (Rooms explique comment déclarer l’état d’un modèle). Le hook de fermeture (Rooms.Disposed) reçoit le snapshot final, si bien que le match n’est jamais recalculé. Le hook de pré-soumission de Leaderboards passe toujours en premier : un score de tournoi obéit au même contrat que toute autre soumission — corriger, plafonner ou rejeter.
Le cycle se clôt : récompenses accordées, joueurs notifiés
La réinitialisation quotidienne de l’étape 1 clôt le cycle comme n’importe quelle réinitialisation de classement, et déclenche CycleClosed avec le libellé du cycle clos. Le hook lit le tableau par ce libellé : il obtient ainsi celui qui vient de se fermer, et non le tableau vide qui vient de s’ouvrir.
Un seul hook fait le reste. Il accorde le prix sous forme de droit d’usage (entitlement) via le module Catalog & Commerce et envoie le résultat par Messaging : aucune tâche de distribution des prix à faire tourner à part. Une notification s’adresse à un seul acteur ; le top 8 donne donc une boucle de huit envois, chacun passant son propre argument rank au modèle de message.
[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})La déclaration par attributs en Go n’est pas encore tranchée (O-1). Les exemples arriveront quand la forme d’écriture sera choisie.
Un hook est une fonction cloud : il s’exécute sur la plateforme, pas dans le moteur. Écrivez-le en C#, TypeScript ou Python ; Unreal s’abonne aux événements de clôture de cycle et de notification. Des fonctions écrites dans le moteur sont prévues : des graphes Blueprint et du C++ (traduit vers une cible de script validée par la plateforme). Les deux seront analysés et publiés comme n’importe quelle déclaration, et l’exécution restera sur la plateforme.
Un hook est une fonction cloud : il s’exécute sur la plateforme, pas dans le moteur. Écrivez-le en C#, TypeScript ou Python ; Unity s’abonne aux événements de clôture de cycle et de notification.
Les nombres de l’étape 1 ne sont que des valeurs initiales
L’équipe live-ops ajuste la période d’inscription, le plafond de participants et le nombre de tentatives dans le panneau, et le déploiement suivant n’écrase pas ses réglages. Une nouvelle publication laisse les valeurs seed intactes ; si vous les modifiez plus tard dans le code, le changement n’atteindra pas le classement déployé (Le schéma dans le code).
Pour en faire un tournoi hebdomadaire, une seule modification suffit : Reset.Daily() devient Reset.Weekly(DayOfWeek.Monday), et les étapes 2 à 4 restent telles quelles.
Pour aller plus loin
- Leaderboards — les axes de tournoi (période d’inscription, nombre maximal de participants, tentatives).
- Matchmaking → Rooms — les groupes, le placement et la préparation des salles.
- Extensibility → Catalog & Commerce → Messaging — la chaîne de hooks qui distribue les prix.
- Concepts clés — le vocabulaire que chaque page de module suppose acquis.