Skip to content

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.

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 UpdateAsync updater — 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.
  • WritesPerFlush caps 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 is a first-class seam, asserted at attach. Implement submit or setsorted 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").ChloeKernelServer
local 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 cache
end

submit’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.

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
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
Topic Payload When
Leaderboard.Updated name, entries A top() call refreshed the cache from the backend (successful reads only)

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:

  1. kernel:onSession — on every session start, a leaderstats folder is created (or your GetContainer supplies one), and one Value instance is created per declared stat, seeded from data.
  2. 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.
  3. session:bind — cleanup runs when the session ends. If the module created the folder it destroys it; if you supplied GetContainer it 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").ChloeKernelServer
local 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" },
})
end

That is the whole integration. session.Profile.Data.Coins += 100 shows on the leaderboard within the next sweep.

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)
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