Leaderboards
Leaderboards is the game-wide top-N — kill counts, speedrun times — on OrderedDataStores or any custom store, built with the persistence layer’s budget discipline: submits queue in memory, flushes are deduped and capped, failed writes retry instead of vanishing.
Mental model
Section titled “Mental model”Scores never write directly. submit puts the value into an in-memory queue keyed by board and player; a flush loop (every FlushSeconds) drains the queue against the backend. That indirection buys four guarantees:
- One write per player per window, no matter how fast scores change. The queue keeps only the newest value per key — a player whose kill count ticks 40 times in a flush window costs one write.
- Keep-best boards only overwrite improvements. With the default DataStore backend the comparison runs inside an atomic
UpdateAsyncupdater — no read-modify race with another server. Custom backends receive the rule declaratively and apply it their own way. - Failed writes stay queued. A DataStore outage doesn’t lose scores; the entry keeps its queue spot and retries next window.
WritesPerFlushcaps each window. A score spike spends at most the budget per flush; overflow drains across later windows. Provider budgets survive.
Reads are cached the same way: top() serves ranked entries from a per-board cache with one backend read per CacheSeconds window — a hundred UI readers cost one GetSortedAsync. A failed refresh serves the previous page and retries next window.
The backend contract
Section titled “The backend contract”The backend is a first-class seam, asserted at attach. Implement submit or set — sorted is required by both:
| Function | Signature | For |
|---|---|---|
submit |
submit(board, key, value, keepBest, ascending) → ok |
Custom stores — external DBs over HttpService, MemoryStores. The keep-best rule arrives as data for your engine to apply atomically its own way: SQL upsert, Redis script, conditional PUT |
set |
set(board, key, updater) → ok |
DataStore-shaped stores. UpdateAsync semantics: call updater(old) and write a non-nil result; nil means “no write, the stored value stands” |
sorted |
sorted(board, ascending, count) → rows? |
Ranked reads. Returns { {Key, Value} } or nil on failure |
The default backend wraps OrderedDataStores: set is UpdateAsync on GetOrderedDataStore(StorePrefix .. board), sorted is one GetSortedAsync page. When a backend has both, submit wins — the flush prefers the declarative path.
local Root = game:GetService("ServerScriptService").ChloeKernelServerlocal Leaderboards = require(Root.Leaderboards)
return function(kernel) local Boards = Leaderboards.attach(kernel, { Boards = { Kills = {}, -- descending, keep-best, top 100, 60s cache BestTime = { Ascending = true }, -- speedruns: smaller wins }, })
kernel.Bus:subscribe("Combat.Kill", function(_, session) session.Data.Kills = (session.Data.Kills or 0) + 1 Boards:submit("Kills", session, session.Data.Kills) -- instant, allocation-cheap end)
local Top = Boards:top("Kills", 10) -- { {Key, Value, Rank} } from cacheendsubmit’s subject resolves to a storage key automatically: a Player becomes its UserId string, a session becomes its player’s UserId string, anything else is tostringed — so guild names and NPC ids work as keys too.
Configuration
Section titled “Configuration”Leaderboards.attach(kernel, options) — options:
| Option | Default | Description |
|---|---|---|
Boards |
required | { [name] = BoardConfig } — the board roster. Submits to unknown boards return false |
FlushSeconds |
15 |
Flush cadence (Background-priority Scheduler job) |
Backend |
OrderedDataStores | The contract above. Asserted at attach |
WritesPerFlush |
30 |
Write cap per flush window across all boards |
StorePrefix |
"CKBoard_" |
OrderedDataStore name prefix (default backend only) |
Clock |
os.clock |
Injectable time source (cache windows in specs) |
SkipLoop |
false |
Don’t schedule the flush loop (specs call _flush by hand) |
Per-board BoardConfig:
| Field | Default | Description |
|---|---|---|
Ascending |
false |
Smaller value wins (times). Default: bigger wins (scores) |
KeepBest |
true |
Only write improvements. Set false for “current value” boards |
MaxEntries |
100 |
top() page size and the backend read size |
CacheSeconds |
60 |
top() cache window |
API reference
Section titled “API reference”| Member | Description |
|---|---|
Leaderboards.attach(kernel, options) → boards |
Build the service; starts the flush loop unless SkipLoop |
boards:submit(board, subject, value) → boolean |
Queue a score. false for unknown boards, NaN, and ±inf — bad values never reach the store |
boards:top(board, count?) → { {Key, Value, Rank} } |
Ranked entries from cache, at most min(count, MaxEntries). Empty table for unknown boards or when no page has ever loaded |
boards:flushNow() |
Immediate flush — shutdown paths, game:BindToClose |
boards:destroy() |
Cancel the loop, then flush once |
boards.Stats |
{ Submitted, Written, Failed } counters |
Bus topics
Section titled “Bus topics”| Topic | Payload | When |
|---|---|---|
Leaderboard.Updated |
name, entries |
A top() call refreshed the cache from the backend (successful reads only) |
Design notes
Section titled “Design notes”For the simple Roblox leaderboard-folder stats (health, kills, coins shown above a player’s head), see Leaderstats — a different, simpler module.