Esta página aún no está traducida: se muestra la versión en inglés.
Leaderboards
Un solo modelo de tabla cuyos ejes se combinan para formar cualquier tipo de tabla de clasificación: posiciones del día, mejores vueltas, totales de gremio, temporadas, torneos.
Cuándo usarlo
- Las puntuaciones deben ordenar a los jugadores (posiciones del día, mejores vueltas, totales de gremio) con un único modelo declarado, no con un sistema distinto para cada tabla de clasificación (leaderboard).
- Necesitas las lecturas estándar (top-N, alrededor de mí, una lista concreta de dueños) sin modelar datos adicionales.
- Los ciclos deben cerrarse según el horario, archivarse (nunca borrarse) y disparar un hook de recompensas con la tabla final.
- Las puntuaciones sospechosas nunca deben entrar en la tabla: un hook previo al envío las valida, las limita o las rechaza con un motivo tipado.
- Un torneo es la misma tabla con una ventana de inscripción, un máximo de participantes y un número de intentos por ciclo.
- No lo necesitas si el número nunca se compara entre jugadores. Un contador personal o un total de carrera son datos comunes. El módulo ordena resultados, nunca los calcula, y no maneja llaves de eliminación.
Quién hace qué
| Actor | Qué hace en esta página |
|---|---|
player | lee el top-N, la ventana a su alrededor y su propia posición, y se suscribe a los cambios de posición |
backend-service | envía resultados; los corrige o los rechaza en el hook previo al envío; otorga recompensas cuando se cierra un ciclo |
operator | declara las tablas; cierra un ciclo antes de tiempo, corrige registros (con auditoría) y vigila la frecuencia de envíos |
En resumen
weekly-score: dueño, agregación, reinicio los lunes, envíos desde el servidor y la clave de orden[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 itTodavía no está decidido cómo declarar con atributos en Go (O-1). Los ejemplos llegarán cuando se elija la forma de escribirlo.
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 declaración vive junto al resto de tu esquema —en el proyecto del servidor o en el proyecto de UE o Unity—, y playserv push la compila y la sube: la tabla aparece vacía en el panel, con su próximo reinicio ya programado. Los horarios están en UTC, así que esta tabla cierra el lunes a las 00:00 UTC; Reset.Weekly(DayOfWeek.Monday, at: "03:00") cambia la hora. La hora local de cada jugador no es una opción de reinicio: una misma tabla no puede cerrarse en veinticuatro momentos distintos.
La clave de orden es una lista, no una puntuación más un criterio de desempate. Los campos ordenan en el orden en que los numeras, cada uno con su propia dirección, y el último nivel lo pone la plataforma: si las claves son iguales, el envío más antiguo queda más arriba, así que dos partidas idénticas nunca intercambian lugares entre una lectura y otra. Un campo fuera de la clave —aquí, Map— viaja solo para mostrarse y nunca mueve una fila.
Agg define qué le hace un segundo envío al único registro que tiene un dueño en el ciclo actual:
Agg | Un segundo envío | Idempotente |
|---|---|---|
Set | reemplaza el registro con los valores enviados | sí |
Best | lo reemplaza solo si los valores nuevos quedan más arriba según la clave de orden | sí |
Increment | suma los valores enviados al registro: bajas, vueltas, aporte al gremio | no: pasa una clave de idempotencia |
Decrement | los resta | no: pasa una clave de idempotencia |
Un envío que no supera un registro Best no es un error: vuelve como aceptado y el orden no cambia. Increment y Decrement son los dos que una llamada reintentada aplicaría dos veces, así que llevan la misma clave de idempotencia que cualquier otra escritura que se pueda reintentar.
Enviar es una sola llamada, y en esta tabla sale del código del servidor porque así lo dice la declaración:
Submit: los dos campos que ordenan y el campo que se muestra, desde la función dueña del resultadoawait 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")Todavía no está decidido cómo declarar con atributos en Go (O-1). Los ejemplos llegarán cuando se elija la forma de escribirlo.
La llamada existe en Unreal. Esta tabla conserva el valor por defecto Submit.ServerOnly, así que la plataforma acepta un envío solo desde una función en la nube o desde el host de una sala (room) con su clave de host. Declara Submit.Players y la misma llamada funciona desde el cliente. Consulta Access & Roles.
La llamada existe en Unity. Esta tabla conserva el valor por defecto Submit.ServerOnly, así que la plataforma acepta un envío solo desde una función en la nube o desde el host de una sala con su clave de host. Declara Submit.Players y la misma llamada funciona desde el cliente. Consulta Access & Roles.
Vale la pena mirar por separado tres cosas de esa llamada:
| En la llamada | Qué es |
|---|---|
PlayServ · playserv | el handle de la función en la nube y la instancia de cliente que el SDK te entrega al arrancar. La misma API con dos llamadores: en Go son ps y psv, y cada fragmento usa el que tiene quien llama |
| quien envía | la función dueña del resultado de la partida. En Tanks es el hook on dispose de la sala (Rooms), que se ejecuta con el estado final en la mano |
playerId | el id de jugador de la plataforma que viene de Auth & Players, nunca un nombre que elegiste tú: un hook lo lee de su payload (e.By.PlayerId en la lección), y el host de una sala envía el id del lugar (seat) que le pertenece |
Los valores son los campos que nombró la declaración: un campo no declarado se rechaza, no se guarda.
Las lecturas que necesita todo juego y la suscripción que las mantiene al día:
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 closesTodavía no está decidido cómo declarar con atributos en Go (O-1). Los ejemplos llegarán cuando se elija la forma de escribirlo.
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) es una ventana por posición, no una página: cinco filas por encima de ti, cinco por debajo y la tuya, once filas en total. Donde termina la tabla se recorta de forma simétrica, así que la posición 2 recibe una ventana más corta por ambos lados en lugar de una desplazada. Top es paginado: devuelve las primeras N filas y un cursor, y after: recorre el resto.
Con ForOwners funciona una tabla de amigos. La plataforma no guarda un grafo de amistades: le pasas los dueños que tu juego ya conoce —los miembros de un grupo o una lista de ids de tus propios datos— y cada fila vuelve con su posición en la tabla completa, no con su posición dentro de la lista.
OnRankChanged entrega solo la posición del jugador local: una tabla con cincuenta mil participantes no le envía cada reacomodo a cada cliente. El callback recibe la fila que cambió —posición, campos que ordenan, campos que se muestran— y Cancel() termina la suscripción. La posición en sí es un snapshot: dos lecturas con un segundo de diferencia pueden dar resultados distintos mientras llegan envíos, aunque tu propio envío siempre se ve en tu siguiente lectura.
El modelo
Qué declara una tabla.
| Eje | Valores | Cómo se define |
|---|---|---|
| Dueño | jugador · grupo | Owner = Owner.Player; una tabla de gremios es la misma tabla con Owner.Group |
| Clave de orden | uno o más campos declarados, cada uno ascendente o descendente | [Rank(1, Sort.Descending)] int Score |
| Agregación | set · best · increment · decrement | Agg = Aggregation.Best |
| Reinicio | un horario en UTC; un ciclo expira, nunca se borra | Reset = Reset.Weekly(DayOfWeek.Monday) |
| Quién puede enviar | solo el servidor (por defecto) · los jugadores | Submit = Submit.ServerOnly |
| Campos que se muestran | declarados y tipados; nunca forman parte del orden | [Display] string Map |
| Lista de dueños | se elige al leer, no se declara | ForOwners("weekly-score", ids): amigos, un gremio, un lobby |
| Reglas del torneo | ventana de inscripción · máximo de participantes · intentos por ciclo · inscripción obligatoria | Rules = Tournament.Define(…), en la tabla de Torneos |
No hay un eje de alcance: una tabla por región, por sala o por temporada es una tabla por clave, y la clave es lo que referencia tu código.
Qué se cumple para toda tabla.
| Siempre | Qué es |
|---|---|
la dirección y el operador de agregación | no cambian después de la primera escritura: cambiarlos reordenaría el historial en silencio. Para cambiar una mecánica se crea una generación nueva, no se edita la existente |
exactamente un registro por dueño en cada generación | un segundo envío no crea una segunda fila |
un registro | no es una entidad: no tiene ciclo de vida propio ni máquina de estados. Lo crea el primer envío y lo cambia el operador de agregación que declaró la tabla |
los campos fuera de la clave de orden nunca afectan el orden | solo se muestran, y por eso se declaran aparte |
una generación | expira, no se borra: open → expired → evicted from retention, y las generaciones expiradas siguen legibles durante el período de retención declarado |
una tabla | es contenido creado por el equipo: se declara en el código, se identifica por una key y llega a la consola de administración en el modo de propiedad seed, de modo que los cambios de horario de un diseñador sobreviven a la siguiente subida |
Qué es un ciclo y qué pasa al cerrarlo.
| Qué es | |
|---|---|
un reinicio | cierra un ciclo en lugar de borrarlo |
un ciclo cerrado | deja de aceptar envíos y sigue legible con su etiqueta: Top("weekly-score", 100, cycle: label) es un parámetro de lectura, no un trabajo de exportación |
el evento de cierre | lleva esa etiqueta, así que un handler lee justo la tabla que se cerró y no la vacía que se acaba de abrir |
Los dos hooks de una tabla, y son de tipos distintos.
| Hook | Qué puede hacer |
|---|---|
pre-submit | un filtro de entrada (gatekeeper): se llama antes de la escritura y la plataforma espera su respuesta. Devuelve uno de tres veredictos: aceptar, aceptar un envío corregido o rechazar. Puede corregir los valores contra tus propias entidades o limitarlos; no puede cambiar el dueño del registro ni su tabla, que ya están asignados. Si falla, rechaza, y el rechazo le llega a quien llama como un Problem tipado (Core) |
cycle-closed | un observador: se dispara después del hecho, no puede vetar nada, y si falla, el ciclo queda cerrado igual |
weekly-score: el previo al envío rechaza una puntuación imposible y el de cierre de ciclo premia al 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)Todavía no está decidido cómo declarar con atributos en Go (O-1). Los ejemplos llegarán cuando se elija la forma de escribirlo.
Un hook es una función en la nube: se ejecuta en la plataforma, no en el motor. Escríbelo en C#, TypeScript o Python; Unreal se suscribe al evento de cierre de ciclo. Están planeadas funciones escritas desde el motor: grafos de Blueprint y C++ (traducido a un formato de script que valida la plataforma). Ambas se analizan y se suben como cualquier declaración, y la ejecución sigue ocurriendo en la plataforma.
Un hook es una función en la nube: se ejecuta en la plataforma, no en el motor. Escríbelo en C#, TypeScript o Python; Unity se suscribe al evento de cierre de ciclo.
La recompensa es un otorgamiento de Catalog & Commerce, no una mecánica de este módulo: chest.gold es un id del catálogo, y el origen reward es lo que distingue ese otorgamiento de una compra. Los reembolsos, la revocación y el evento de cambio de derecho de uso funcionan sobre él exactamente igual que sobre un artículo comprado.
Torneos. Un torneo es esta misma tabla más restricciones de participación: no hay un segundo mecanismo ni una entidad aparte. Estas son las cuatro restricciones, con sus unidades y su comportamiento en el límite:
| Restricción | Cómo se declara | En el límite |
|---|---|---|
| ventana de inscripción | entryWindow: TimeSpan: cuánto tiempo sigue abierta la inscripción después de que se abre el ciclo | una inscripción después del cierre se rechaza; el ciclo sigue hasta su reinicio |
| máximo de participantes | maxEntrants: int: registros en un ciclo | el participante 65 de 64 se rechaza como conflicto, y no se saca a nadie: una tabla que descartara sus peores filas premiaría a quien llegó primero |
| intentos por ciclo | attemptsPerCycle: int: envíos por dueño | el siguiente envío responde “intentos agotados”: un conflicto, no un error de permisos, y el contador se reinicia con el ciclo |
| inscripción obligatoria | joinRequired: true: los participantes son una lista de inscritos, no todos los que juegan | se rechaza el envío de alguien que no está inscrito |
La receta Torneo diario declara las cuatro de principio a fin.
Errores
Si una sesión de player llama a una operación que esta tabla reserva para fn —un envío a una tabla solo para el servidor, un cierre anticipado de ciclo—, se rechaza como error de permisos antes de escribir nada; la misma llamada desde una función en la nube pasa. En cambio, un envío a un ciclo que ya se cerró es un conflicto; para reintentar, envía al ciclo actual.
Límites
Cada límite y lo que pasa al alcanzarlo.
| Límite | Al alcanzarlo | Cifra |
|---|---|---|
| filas por lectura | la página se recorta, “hay más” sigue en verdadero y after: continúa | tope de página definido por proyecto |
| ventana alrededor de un dueño | se recorta de forma simétrica | tope de ventana definido por proyecto |
| registros en un ciclo | el envío se rechaza como conflicto; no se saca a nadie | maxEntrants por tabla; sin límite si no se define |
| intentos por dueño en cada ciclo | conflicto “intentos agotados”, que se borra con el reinicio | attemptsPerCycle por tabla; sin límite si no se define |
| frecuencia de envíos por dueño | rechazo por límite de frecuencia que indica cuándo se permite reintentar | frecuencia definida por proyecto |
| tablas por proyecto | una declaración nueva se rechaza al desplegar | límite definido por proyecto |
| retención de ciclos cerrados | el ciclo sale del almacenamiento con un evento; desde entonces las lecturas responden not-found | ventana de retención definida por proyecto |