Cette page n’est pas encore traduite : la version anglaise est affichée.
Leaderboards
Un seul modèle de classement, dont les axes se combinent pour produire tous les types de classement : classement du jour, meilleur tour, totaux de guilde, saisons, tournois.
Quand l’utiliser
- Les scores doivent classer les joueurs (classement du jour, meilleur tour, totaux de guilde) selon un seul modèle déclaré, et non un système par classement (leaderboard).
- Vous avez besoin des lectures standard (top N, autour de moi, une liste nommée de propriétaires) sans modélisation de données supplémentaire.
- Les cycles doivent se clore à l’heure prévue, être archivés (jamais supprimés) et déclencher un hook de récompense avec le tableau final.
- Un score suspect ne doit jamais entrer dans le tableau : un hook avant soumission le valide, le plafonne ou le rejette avec un motif typé.
- Un tournoi, c’est le même classement avec une période d’inscription, un nombre maximal de participants et un nombre de tentatives par cycle.
- Inutile si le nombre n’est jamais comparé d’un joueur à l’autre. Un compteur personnel ou un total de carrière relève des données ordinaires. Le module ordonne les résultats, il ne les calcule jamais, et il ne gère pas de tableau à élimination.
Qui fait quoi
| Acteur | Ce qu’il fait sur cette page |
|---|---|
player | lit le top N, la fenêtre autour de lui et son propre rang ; s’abonne aux changements de rang |
backend-service | soumet les résultats ; les corrige ou les rejette dans le hook avant soumission ; attribue les récompenses à la clôture d’un cycle |
operator | déclare les classements ; clôt un cycle en avance, corrige des enregistrements (avec audit), surveille la fréquence des soumissions |
En bref
weekly-score : propriétaire, agrégation, réinitialisation le lundi, soumission par le serveur, clé de tri[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 itLa 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 = "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
}La déclaration vit à côté du reste de votre schéma, dans le projet serveur ou dans le projet UE ou Unity ; playserv push la compile et l’envoie : le classement apparaît dans le panneau, vide, avec sa prochaine réinitialisation déjà programmée. Les calendriers sont en UTC : ce classement se clôt donc le lundi à 00:00 UTC, et Reset.Weekly(DayOfWeek.Monday, at: "03:00") décale l’heure. L’heure locale de chaque joueur n’est pas une option de réinitialisation : un même tableau ne peut pas se clore à vingt-quatre moments différents.
La clé de tri est une liste, pas un score accompagné d’un critère de départage. Les champs classent dans l’ordre où vous les numérotez, chacun dans son propre sens, et le dernier niveau appartient à la plateforme : à clés égales, la soumission la plus ancienne passe devant, si bien que deux parties identiques n’échangent jamais leur place entre deux lectures. Un champ hors de la clé (ici Map) sert à l’affichage et ne fait jamais bouger une ligne.
Agg indique l’effet d’une deuxième soumission sur l’unique enregistrement d’un propriétaire dans le cycle en cours :
Agg | Une deuxième soumission | Idempotent |
|---|---|---|
Set | remplace l’enregistrement par les valeurs soumises | oui |
Best | ne le remplace que si les nouvelles valeurs se classent plus haut selon la clé de tri | oui |
Increment | ajoute les valeurs soumises à l’enregistrement : éliminations, tours, contribution à la guilde | non : passez une clé d’idempotence |
Decrement | les soustrait | non : passez une clé d’idempotence |
Une soumission qui ne bat pas un enregistrement Best n’est pas une erreur : elle revient acceptée, sans changement de classement. Increment et Decrement sont les deux qu’un appel retenté appliquerait deux fois : ils prennent donc la même clé d’idempotence que toute autre écriture qu’on peut retenter.
Soumettre tient en un appel, et sur ce classement il part du code serveur, parce que la déclaration l’impose :
Submit : les deux champs classés et le champ d’affichage, depuis la fonction qui détient le résultatawait 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")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.
L’appel existe dans Unreal. Ce classement garde la valeur par défaut Submit.ServerOnly : la plateforme n’accepte donc une soumission que d’une fonction cloud ou de l’hôte d’une salle muni de sa clé hôte. Déclarez Submit.Players, et le même appel fonctionne depuis le client. Voir Access & Roles.
L’appel existe dans Unity. Ce classement garde la valeur par défaut Submit.ServerOnly : la plateforme n’accepte donc une soumission que d’une fonction cloud ou de l’hôte d’une salle muni de sa clé hôte. Déclarez Submit.Players, et le même appel fonctionne depuis le client. Voir Access & Roles.
Trois éléments de cet appel méritent d’être lus séparément :
| Dans l’appel | Ce que c’est |
|---|---|
PlayServ · playserv | le handle de la fonction cloud et l’instance cliente que le SDK vous remet au démarrage. Même API, deux appelants : en Go, ce sont ps et psv, et chaque extrait utilise celui dont dispose son appelant |
| l’émetteur de la soumission | la fonction qui détient le résultat du match. Dans Tanks, c’est le hook on dispose de la salle (room, voir Rooms), qui s’exécute avec l’état final en main |
playerId | l’id de joueur de la plateforme, fourni par Auth & Players, jamais un nom que vous auriez choisi : un hook le lit dans sa charge utile (e.By.PlayerId dans la leçon), et l’hôte d’une salle soumet l’id de la place (seat) qui lui appartient |
Les valeurs sont les champs nommés par la déclaration : un champ non déclaré est refusé, pas stocké.
Les lectures dont tout jeu a besoin, et l’abonnement qui les tient à jour :
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 closesLa déclaration par attributs en Go n’est pas encore tranchée (O-1). Les exemples arriveront quand la forme d’écriture sera choisie.
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) est une fenêtre par rang, pas une page : cinq lignes au-dessus de vous, cinq en dessous, plus la vôtre, soit onze lignes, rognées symétriquement au bord du tableau. Le rang 2 obtient donc une fenêtre plus courte des deux côtés, et non une fenêtre décalée. Top est paginé : il renvoie les N premières lignes et un curseur, et after: parcourt la suite.
ForOwners est la façon de faire un classement entre amis. La plateforme ne tient aucun graphe d’amitié : vous passez les propriétaires que votre jeu connaît déjà (les membres d’un groupe, ou une liste d’id tirée de vos propres données), et chaque ligne revient avec son rang dans le tableau complet, pas avec un rang au sein de la liste.
OnRankChanged ne livre que le rang du joueur local : un classement de cinquante mille participants n’envoie pas chaque remaniement à chaque client. Le callback reçoit la ligne modifiée (le rang, les champs classés, les champs d’affichage), et Cancel() met fin à l’abonnement. Le rang lui-même est un snapshot : deux lectures à une seconde d’intervalle peuvent différer pendant que des soumissions arrivent, mais votre propre soumission est toujours visible à votre lecture suivante.
Le modèle
Ce que déclare un classement.
| Axe | Valeurs | Comment le définir |
|---|---|---|
| Propriétaire | joueur · groupe | Owner = Owner.Player : un classement de guildes est le même classement avec Owner.Group |
| Clé de tri | un ou plusieurs champs déclarés, chacun croissant ou décroissant | [Rank(1, Sort.Descending)] int Score |
| Agrégation | set · best · increment · decrement | Agg = Aggregation.Best |
| Réinitialisation | un calendrier en UTC ; un cycle expire, il n’est jamais supprimé | Reset = Reset.Weekly(DayOfWeek.Monday) |
| Qui peut soumettre | le serveur seul (par défaut) · les joueurs | Submit = Submit.ServerOnly |
| Champs d’affichage | déclarés et typés ; jamais pris en compte dans le tri | [Display] string Map |
| Liste de propriétaires | choisie à la lecture, pas déclarée | ForOwners("weekly-score", ids) : des amis, une guilde, un lobby |
| Règles de tournoi | période d’inscription · nombre maximal de participants · tentatives par cycle · inscription obligatoire | Rules = Tournament.Define(…), dans le tableau de la partie Tournois |
Il n’y a pas d’axe de portée : un classement par région, par salle ou par saison, c’est un classement par clé, et la clé est ce que votre code référence.
Ce qui vaut pour tout classement.
| Toujours | Ce que c’est |
|---|---|
le sens et l'opérateur d'agrégation | sont immuables après la première écriture : les modifier reclasserait l’historique en silence. Pour changer de mécanique, on crée une nouvelle génération, on ne modifie pas l’existante |
exactement une entrée par propriétaire et par génération | une deuxième soumission ne crée pas une deuxième ligne |
une entrée | n’est pas une entité : ni cycle de vie propre, ni machine à états. Elle est créée par la première soumission et modifiée par l’opérateur d’agrégation que le classement a déclaré |
les champs hors de la clé de tri n'influent jamais sur l'ordre | ils servent à l’affichage, et c’est pour cela qu’on les déclare à part |
une génération | expire, elle n’est pas supprimée : open → expired → evicted from retention, et les générations expirées restent lisibles pendant la durée de rétention déclarée |
un classement | est du contenu créé par l’équipe : déclaré dans le code, désigné par une key, il arrive dans la console d’administration en mode de propriété seed, si bien que les changements de calendrier d’un game designer survivent à la publication suivante |
Ce qu’est un cycle, et ce que fait sa clôture.
| Ce que c’est | |
|---|---|
une réinitialisation | clôt un cycle au lieu de le supprimer |
un cycle clos | n’accepte plus de soumissions et reste lisible sous son libellé : Top("weekly-score", 100, cycle: label) est un paramètre de lecture, pas une tâche d’export |
l'événement de clôture | porte ce libellé : un handler lit ainsi exactement le tableau qui s’est clos, et non le tableau vide qui vient de s’ouvrir |
Les deux hooks d’un classement, et ils sont de nature différente.
| Hook | Ce qu’il peut faire |
|---|---|
pre-submit | un garde-fou, appelé avant l’écriture, que la plateforme attend. Il rend l’un de trois verdicts : accepter, accepter une soumission corrigée ou rejeter. Il peut corriger les valeurs en les confrontant à vos propres entités, ou les plafonner ; il ne peut pas changer le propriétaire de l’enregistrement ni son classement, déjà établis. Il bloque en cas d’échec, et un rejet parvient à l’appelant sous la forme d’un Problem typé (Core) |
cycle-closed | un observateur : déclenché après coup, il ne peut pas opposer de veto, et s’il échoue, le cycle reste clos |
weekly-score : pre-submit rejette un score impossible, cycle-closed récompense le 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)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 à l’événement de clôture de cycle. Les fonctions écrites dans le moteur sont prévues : graphes Blueprint et C++ (traduit vers une cible de script validée par la plateforme). Les deux sont analysées et publiées comme n’importe quelle déclaration ; l’exécution reste 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 à l’événement de clôture de cycle.
La récompense est une attribution de Catalog & Commerce, pas une mécanique de ce module : chest.gold est un id du catalogue, et c’est l’origine reward qui distingue cette attribution d’un achat. Remboursements, révocation et événement de changement de droit d’usage s’y appliquent exactement comme à un objet acheté.
Tournois. Un tournoi, c’est ce classement plus des contraintes de participation : pas de second mécanisme, pas d’entité à part. Voici les quatre contraintes, avec leurs unités et leur comportement à la limite :
| Contrainte | Déclarée par | À la limite |
|---|---|---|
| période d’inscription | entryWindow: TimeSpan : combien de temps les inscriptions restent ouvertes après l’ouverture du cycle | une inscription après la fermeture est refusée ; le cycle continue jusqu’à sa réinitialisation |
| nombre maximal de participants | maxEntrants: int : les enregistrements d’un cycle | le 65ᵉ participant sur 64 est refusé comme conflit, et personne n ’est évincé : un classement qui éliminerait ses pires lignes récompenserait les premiers arrivés |
| tentatives par cycle | attemptsPerCycle: int : les soumissions par propriétaire | la soumission suivante répond « tentatives épuisées » : un conflit, pas une erreur de permission, et le compteur se réinitialise avec le cycle |
| inscription obligatoire | joinRequired: true : les participants forment une liste d’inscrits, pas l’ensemble des joueurs | la soumission d’un non-inscrit est refusée |
La recette Tournoi quotidien déclare les quatre de bout en bout.
Erreurs
Une session player qui appelle une opération que ce classement réserve à fn (une soumission sur un classement réservé au serveur, une clôture de cycle anticipée) est refusée pour défaut de permission, avant toute écriture ; le même appel depuis une fonction cloud passe. Une soumission dans un cycle déjà clos est, elle, un conflit ; pour réessayer, soumettez dans le cycle en cours.
Limites
Chaque limite, avec ce qui se passe quand on l’atteint.
| Limite | À la limite | Valeur |
|---|---|---|
| lignes par lecture | la page est rognée, « il y en a d’autres » reste à vrai, after: poursuit | plafond de page défini par projet |
| fenêtre autour d’un propriétaire | rognée symétriquement | plafond de fenêtre défini par projet |
| enregistrements dans un cycle | la soumission est refusée comme conflit ; aucune éviction | maxEntrants par classement ; illimité si non défini |
| tentatives par propriétaire et par cycle | conflit « tentatives épuisées », levé par la réinitialisation | attemptsPerCycle par classement ; illimité si non défini |
| fréquence de soumission par propriétaire | refus pour limite de débit, qui indique quand un nouvel essai est permis | fréquence définie par projet |
| classements par projet | une nouvelle déclaration est refusée au déploiement | limite définie par projet |
| rétention des cycles clos | le cycle quitte le stockage avec un événement ; les lectures répondent ensuite not found | fenêtre de rétention définie par projet |