Leaderboards & Leaderstats
Two modules, two scopes. 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. Leaderstats is the per-server Roblox leaderboard (the player list panel): declare which profile or session fields to show and the values track the data automatically.
Leaderboards
Section titled “Leaderboards”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”Leaderstats
Section titled “Leaderstats”Mental model
Section titled “Mental model”Roblox’s player-list leaderboard is convention, not API: a Folder named leaderstats under the player, with Value instances inside. Leaderstats owns that wiring against the kernel’s session lifecycle:
kernel:onSession— on every session start, aleaderstatsfolder is created (or yourGetContainersupplies one), and one Value instance is created per declared stat, seeded from data.- A slow sweep (every
UpdateSeconds, default 1s, Background priority) copies current data values into the Value instances. You never push updates — write your data, the leaderboard follows within a second. session:bind— cleanup runs when the session ends. If the module created the folder it destroys it; if you suppliedGetContainerit destroys only the Value instances it made.
Each stat reads from the session’s profile data (session.Profile.Data, the persisted record from DataDriver) or the session’s transient data (session.Data). The default is profile if one exists, else session — persisted stats show persisted values without configuration.
local Root = game:GetService("ServerScriptService").ChloeKernelServerlocal Leaderstats = require(Root.Leaderstats)
return function(kernel) Leaderstats.attach(kernel, { Coins = { Field = "Coins" }, -- from session.Profile.Data Stage = { Field = "BestStage" }, Title = { Field = "Title", Kind = "StringValue", From = "Session" }, })endThat is the whole integration. session.Profile.Data.Coins += 100 shows on the leaderboard within the next sweep.
Configuration
Section titled “Configuration”Leaderstats.attach(kernel, stats, options?) — stats maps display names (what the player list shows) to:
| Field | Default | Description |
|---|---|---|
Field |
required | The data field to read |
From |
Profile if present, else Session | "Profile" (persisted, session.Profile.Data) or "Session" (transient, session.Data) |
Kind |
"IntValue" |
"IntValue", "NumberValue", or "StringValue" |
Options:
| Option | Default | Description |
|---|---|---|
UpdateSeconds |
1 |
Sweep cadence |
GetContainer |
creates leaderstats |
(player) → Instance — supply your own container (e.g. a folder another system also writes to). The module then only owns its Value instances |
SkipLoop |
false |
Don’t schedule the sweep (specs call sweep() by hand) |
API reference
Section titled “API reference”| Member | Description |
|---|---|
Leaderstats.attach(kernel, stats, options?) → handle |
Wire the mapping; hooks session start/end immediately |
handle.sweep() |
Run one update pass by hand |
handle.detach() |
Cancel the sweep, disconnect the session hook, clean up every tracked container/value |