Aller au contenu principal
Services autour du jeu

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

ActeurCe qu’il fait sur cette page
playerlit le top N, la fenêtre autour de lui et son propre rang ; s’abonne aux changements de rang
backend-servicesoumet les résultats ; les corrige ou les rejette dans le hook avant soumission ; attribue les récompenses à la clôture d’un cycle
operatordéclare les classements ; clôt un cycle en avance, corrige des enregistrements (avec audit), surveille la fréquence des soumissions

En bref

Déclarer 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 it
Bientôt — Go

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 = "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 — la même classe modèle compile dans Unity, sur la base 2021.3 : aucune syntaxe C# 12 ici
[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 :

AggUne deuxième soumissionIdempotent
Setremplace l’enregistrement par les valeurs soumisesoui
Bestne le remplace que si les nouvelles valeurs se classent plus haut selon la clé de trioui
Incrementajoute les valeurs soumises à l’enregistrement : éliminations, tours, contribution à la guildenon : passez une clé d’idempotence
Decrementles soustraitnon : 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 :

Un seul Submit : les deux champs classés et le champ d’affichage, depuis la fonction qui détient le résultat
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")
Bientôt — Go

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 autre acteur fait cet appel

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.

Un autre acteur fait cet appel

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’appelCe que c’est
PlayServ · playservle 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 soumissionla 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
playerIdl’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 :

Le top 100, la fenêtre autour de moi, les lignes d’une guilde par liste de propriétaires, et un abonnement au rang en direct
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
Bientôt — Go

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.

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 — s’exécute tel quel dans Unity avec les types générés
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) 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.

AxeValeursComment le définir
Propriétairejoueur · groupeOwner = Owner.Player : un classement de guildes est le même classement avec Owner.Group
Clé de triun ou plusieurs champs déclarés, chacun croissant ou décroissant[Rank(1, Sort.Descending)] int Score
Agrégationset · best · increment · decrementAgg = Aggregation.Best
Réinitialisationun calendrier en UTC ; un cycle expire, il n’est jamais suppriméReset = Reset.Weekly(DayOfWeek.Monday)
Qui peut soumettrele serveur seul (par défaut) · les joueursSubmit = Submit.ServerOnly
Champs d’affichagedéclarés et typés ; jamais pris en compte dans le tri[Display] string Map
Liste de propriétaireschoisie à la lecture, pas déclaréeForOwners("weekly-score", ids) : des amis, une guilde, un lobby
Règles de tournoipériode d’inscription · nombre maximal de participants · tentatives par cycle · inscription obligatoireRules = 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.

ToujoursCe que c’est
le sens et l'opérateur d'agrégationsont 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érationune deuxième soumission ne crée pas une deuxième ligne
une entréen’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'ordreils servent à l’affichage, et c’est pour cela qu’on les déclare à part
une générationexpire, 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 classementest 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
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
Un cycle : les soumissions passent par votre garde-fou, la réinitialisation clôt le tableau, et le hook de clôture de cycle le lit par son libellé pendant que le cycle suivant démarre vide

Ce qu’est un cycle, et ce que fait sa clôture.

Ce que c’est
une réinitialisationclôt un cycle au lieu de le supprimer
un cycle closn’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ôtureporte 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.

HookCe qu’il peut faire
pre-submitun 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-closedun observateur : déclenché après coup, il ne peut pas opposer de veto, et s’il échoue, le cycle reste clos
Les deux hooks de 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)
Bientôt — Go

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.

S’exécute hors du moteur

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.

S’exécute hors du moteur

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 :

ContrainteDéclarée parÀ la limite
période d’inscriptionentryWindow: TimeSpan : combien de temps les inscriptions restent ouvertes après l’ouverture du cycleune inscription après la fermeture est refusée ; le cycle continue jusqu’à sa réinitialisation
nombre maximal de participantsmaxEntrants: int : les enregistrements d’un cyclele 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 cycleattemptsPerCycle: int : les soumissions par propriétairela soumission suivante répond « tentatives épuisées » : un conflit, pas une erreur de permission, et le compteur se réinitialise avec le cycle
inscription obligatoirejoinRequired: true : les participants forment une liste d’inscrits, pas l’ensemble des joueursla 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 limiteValeur
lignes par lecturela page est rognée, « il y en a d’autres » reste à vrai, after: poursuitplafond de page défini par projet
fenêtre autour d’un propriétairerognée symétriquementplafond de fenêtre défini par projet
enregistrements dans un cyclela soumission est refusée comme conflit ; aucune évictionmaxEntrants par classement ; illimité si non défini
tentatives par propriétaire et par cycleconflit « tentatives épuisées », levé par la réinitialisationattemptsPerCycle par classement ; illimité si non défini
fréquence de soumission par propriétairerefus pour limite de débit, qui indique quand un nouvel essai est permisfréquence définie par projet
classements par projetune nouvelle déclaration est refusée au déploiementlimite définie par projet
rétention des cycles closle cycle quitte le stockage avec un événement ; les lectures répondent ensuite not foundfenêtre de rétention définie par projet