Progression

Leaderboards

Persistent ranked boards: keep-best, around-me, rolling seasons.

Server
services.AddCrossplayLeaderboards();
Unity package
com.crossplay.leaderboards
Depends on
core

Providers you can swap 2

Infrastructure seams. Crossplay ships a working implementation of each — replacing one changes where data lives or how it moves, never a game rule.

ILeaderboardClock provider

SPI supplying "now" (unix UTC ms) to the rolling-season math — implement it to control where season boundaries fall (and to make season rollover deterministic in tests). The current rolling-season key is computed purely from this clock, SeasonEpochUtcMs, and SeasonRollHours — there is no scheduler and no stored reset, so advancing this clock past a boundary IS the season rolling. Piece-local on purpose: feature pieces depend on Core only, never on the Scheduler piece.

  • long UtcNowMs { get; }

    The current time as unix-epoch milliseconds (UTC).

ILeaderboardStore provider

Persistence-swappable storage for leaderboard entries: an upsertable set of ScoreRecord per (board, season). Pure data — it holds no ordering policy; the ILeaderboardService applies higher/lower-is-better and computes ranks. The in-memory default is overridden by a document-backed implementation when persistence is configured (like the ban/audit stores). The four write/read primitives (Set, Increment, Get, Entries) are the storage contract. On top of them sit two families of default interface methods so every store gets correct behavior with zero code, while a native backing store (the Redis ZSET twin) can override them to run server-side: Ranked queries (TopN, RankOf, Around): the defaults sort/scan Entries (an O(n) full-board transfer against a remote store); the Redis twin overrides them with native ZREVRANGE/ZRANK/ZCOUNT so the whole board is never shipped. Direction (higherIsBetter) is a parameter because ordering is a service policy, not stored data.Atomic keep-best (SubmitBest): the default is the single-node-correct read-modify-write the service used to do inline; the Redis twin overrides it with a ZADD GT/ZADD LT that is atomic ON THE SERVER, so two nodes submitting for one account can never clobber the better score. The ordering the defaults (and the overrides) reproduce is the service's: better score first per higherIsBetter, ties broken by ascending accountId (deterministic + persistence-safe).

  • LeaderboardWindow Around(string board, string season, long accountId, int window, bool higherIsBetter)

    Returns the window of window records each side of accountId's own rank (best first, with the global ranks conveyed via FromRank), or Ranked = false when the account has no record. The caller is responsible for clamping window to config. Default sorts Entries; a native store overrides with a server-side windowed range.

  • IReadOnlyList<ScoreRecord> Entries(string board, string season)

    Returns every record on a board+season (unordered).

  • ScoreRecord Get(string board, string season, long accountId)

    Returns a player's record on a board+season, or null if they have none.

  • ScoreRecord Increment(string board, string season, long accountId, string name, double delta)

    Atomically ADDS delta to accountId's stored score on a board+season and returns the resulting record (create-at-delta when the account has no record yet, treating the missing score as 0). The name is refreshed onto the record. Performed under the store's own concurrency guard — the whole read-modify-write is one atomic step, so concurrent increments conserve (a service-level Get-then-Set would race and lose deltas). Ordering/keep-best policy does NOT apply here: an increment is a raw add (a server-authoritative cumulative counter), never a best-of comparison.

  • LeaderboardRankInfo RankOf(string board, string season, long accountId, bool higherIsBetter)

    Returns the 1-based rank, board total and best score for accountId, or Ranked = false (with the current total) when it has no record. Rank = 1 + entries strictly ahead by score + tied entries with a lower accountId. Default scans Entries; a native store overrides with server-side rank (Redis: ZCOUNT + a tie probe).

  • void Set(string board, string season, ScoreRecord record)

    Inserts or replaces a player's record on a board+season.

  • SubmitBestResult SubmitBest(string board, string season, long accountId, string name, double value, bool higherIsBetter)

    Keeps the BETTER of a player's stored score and value on a board+season, atomically, and returns the resulting best record (name always refreshed) plus whether this submit improved it. "Better" is by higherIsBetter (strictly greater / strictly lesser); a tie is not an improvement. When the account has no record yet, value is stored (create) and the result is an improvement. Default: the single-node-correct read-modify-write the service used to do inline (Get the current best, keep the better, refresh the name). A native store overrides this so the keep-best is atomic ACROSS NODES — the Redis twin uses ZADD GT/ZADD LT, a single server-side compare-and-set, so a better score from any node is never clobbered by a concurrent worse one.

  • IReadOnlyList<ScoreRecord> TopN(string board, string season, int count, bool higherIsBetter)

    Returns the top count records in ranked order (best first per higherIsBetter, ties by ascending accountId). Default sorts Entries; a native store overrides with a server-side top-N (Redis: ZREVRANGE/ZRANGE by score).

Services you call 1

Crossplay implements these. Resolve them from DI and call them from your own systems.

ILeaderboardService

Genre-neutral leaderboard service — the server API GAME CODE calls (resolve it from DI: [Inject] ILeaderboardService leaderboards). Players submit scores to named boards (optionally per-season) over the wire; the server ranks them. Ordering (higher/lower-is-better) and keep-best-vs-latest are configured, not assumed. Depends only on Core primitives (sessions + identity).

  • bool AroundMe(long accountId, string board, string season, int window, out IReadOnlyList<LeaderboardEntry> entries, out int myRank, out int total, out ushort reason)

    Returns the page of entries around accountId's own rank — window entries each side (clamped to config). False with reason when the account has no score on the board.

  • bool Increment(ISession session, string board, string season, double delta, out SubmitOutcome outcome, out ushort reason)

    Atomically ADDS delta to session's current score on board / season (create-at-delta when the caller has no score yet) and returns the resulting outcome — for an increment, BestScore is the NEW cumulative score, Improved is whether the score changed (delta != 0), and Rank/Total are the post-add rank + board size. The add is atomic under the store's concurrency model, so concurrent increments conserve — this is the server-authoritative cumulative counter (wins/kills/points) that replaces the racy read-modify-write of Rank-then-Submit. Keep-best / ordering policy is NOT applied: a raw add, so a negative delta genuinely subtracts. Server-side only — increments are unforgeable server ops (there is no client-facing wire path); clients affect scores through the game's own rules, never by asking to increment. On failure returns false with reason set to a notification code. Requires an authenticated session.

  • bool Rank(long accountId, string board, string season, out int rank, out double score, out int total, out ushort reason)

    Returns the 1-based rank, best score and board total for accountId. Returns false with reason when the account has no score on the board.

  • void SeasonInfo(out string currentSeason, out long rollsAtUtcMs)

    The current rolling-season key + next roll time (empty/0 when rolling is off).

  • bool Submit(ISession session, string board, string season, double score, out SubmitOutcome outcome, out ushort reason)

    Submits score for session on board / season. On success returns the resulting outcome; on failure returns false with reason set to a notification code.

  • IReadOnlyList<LeaderboardEntry> Subset(string board, string season, IReadOnlyList<long> accountIds)

    Ranks an explicit account subset; entries keep their GLOBAL board ranks (accounts without a score are simply absent). The list size is clamped to config.

  • IReadOnlyList<LeaderboardEntry> Top(string board, string season, int count)

    Returns the top count entries of a board (best first), clamped to config.

Configuration 1

Every tunable lives in an options object — there are no magic numbers to hunt for.

LeaderboardOptions

Configurable leaderboard rules (defaults live here, never inline in the service).

  • int DefaultTopCount { get; set; }

    Top-N count used when a request does not specify one (0 or less).

  • bool HigherIsBetter { get; set; }

    True: higher scores rank first (points). False: lower scores rank first (times/golf).

  • bool KeepBest { get; set; }

    True: a player's entry keeps their best score (worse submits are ignored). False: latest wins.

  • int MaxAroundWindow { get; set; }

    Largest around-me window (entries per side) a query may request.

  • int MaxBoardIdLength { get; set; }

    Maximum board-id length.

  • int MaxSeasonLength { get; set; }

    Maximum season-key length (empty season = all-time).

  • int MaxSubsetSize { get; set; }

    Largest account subset a single subset query may rank.

  • int MaxTopCount { get; set; }

    Largest top-N a single query may return (requests are clamped down to this).

  • string RollingSeasonPrefix { get; set; }

    Prefix for computed rolling-season keys.

  • string RollingSeasonToken { get; set; }

    The request token that means "the current rolling season".

  • long SeasonEpochUtcMs { get; set; }

    Epoch (unix UTC ms) season 0 starts at.

  • double SeasonRollHours { get; set; }

    Rolling-season length in hours (0 = rolling seasons off). When on, requests whose season equals RollingSeasonToken resolve to the CURRENT computed key ("RollingSeasonPrefix{index}") — "resets" happen because the key changes, with no state and no scheduler, and past seasons stay queryable by their explicit key.

Wire messages 12

The protocol this piece speaks. Ids are allocated per piece so they can never collide.

1501 LeaderboardSubmitRequest

Client -> server: submit a score for the caller on a board (+ optional season).

1502 LeaderboardSubmitResponse

Server -> client: result of a LeaderboardSubmitRequest.

1503 LeaderboardTopRequest

Client -> server: request the top entries of a board (+ optional season).

1504 LeaderboardTopResponse

Server -> client: the top entries of a board (best-ranked first).

1505 LeaderboardRankRequest

Client -> server: request the caller's own rank on a board (+ optional season).

1506 LeaderboardRankResponse

Server -> client: result of a LeaderboardRankRequest.

1507 LeaderboardAroundRequest

Client → server: the page of entries around the caller's own rank. The reply is a LeaderboardAroundResponse. The caller's account is taken from the session (never sent).

1508 LeaderboardAroundResponse

Server → client: the around-me page (reply to LeaderboardAroundRequest).

1509 LeaderboardSubsetRequest

Client → server: rank an explicit account subset. The CLIENT composes the list (its friends, its guild, its lobby…) so this piece never references the social pieces. The reply is a LeaderboardSubsetResponse.

1510 LeaderboardSubsetResponse

Server → client: the subset's ranked entries (ranks are GLOBAL board ranks; accounts without a score are simply absent). Reply to LeaderboardSubsetRequest.

1511 LeaderboardSeasonInfoRequest

Client → server: ask for the current rolling-season key + when it rolls next. Carries no fields; the reply is a LeaderboardSeasonInfo.

1512 LeaderboardSeasonInfo

Server → client: rolling-season info (reply to LeaderboardSeasonInfoRequest; rolling disabled ⇒ empty key, 0 roll time).

Unity services you inject 1

Crossplay binds these in the client context. Inject and call them from your own MonoBehaviours and presenters.

ILeaderboardClient

Data-layer leaderboard service (no UI): async submit/top/rank RPCs. Self-registers under the Crossplay client context. Depends only on Crossplay Core.

  • UniTask<LeaderboardSubmitResponse> SubmitAsync(string boardId, string season, double score, CancellationToken ct = default)

    Submits a score for the local player on a board (+ optional season).

  • UniTask<LeaderboardTopResponse> TopAsync(string boardId, string season, int count, CancellationToken ct = default)

    Fetches the top count entries of a board.

  • UniTask<LeaderboardRankResponse> RankAsync(string boardId, string season, CancellationToken ct = default)

    Fetches the local player's own rank on a board.

  • UniTask<LeaderboardAroundResponse> AroundAsync(string boardId, string season, int window, CancellationToken ct = default)

    Fetches the page of entries around the local player's rank.

  • UniTask<LeaderboardSubsetResponse> SubsetAsync(string boardId, string season, long[] accountIds, CancellationToken ct = default)

    Ranks an explicit account subset (e.g. friends — the game composes the list).

  • UniTask<LeaderboardSeasonInfo> SeasonInfoAsync(CancellationToken ct = default)

    Fetches the current rolling-season key + when it rolls next.

UI views you can replace 1

Each ships a working uGUI panel you can drag into a scene. Substitute your own view to keep the logic and change the look — the presenter never knows.

ICrossplayLeaderboardView

Narrow contract the presenter drives. UI-only: render calls in, user intents out.