Leaderboards
Global top-N boards on OrderedDataStores or a custom store, with queued keep-best submits, capped write windows, shared cached reads and a bounded shutdown drain.
- Realm
- Server
- Require
ServerScriptService.ChloeKernelServer .Leaderboards - Since
- v0.4.0
- Source
- Leaderboards.luau
Leaderboards keeps game-wide top-N boards, such as kill counts or speedrun times, on OrderedDataStores or any store you
plug in. Scores never write directly. submit puts the value in an in-memory queue, and a flush drains the queue in
capped windows against the backend; reads come from a cache that many callers share. That indirection is the whole
design: a burst of submits costs one write per player per window, failed writes retry instead of vanishing, and a
hundred UI readers cost one backend read. For the per-server numbers in the Roblox player list, see
Leaderstats.
Mental model
Section titled “Mental model”The submit queue
Section titled “The submit queue”submit(board, subject, value) resolves the subject to a storage key and stores the value in the board’s queue:
- A
Playerbecomes its UserId as a string, a session becomes its player’s UserId string, and anything else is passed throughtostring, so guild names and NPC ids work as keys. - On a
KeepBestboard (the default) the queue keeps the best pending value per key: the smaller one on anAscendingboard, the larger one otherwise. WithKeepBest = falseit keeps the newest. submitreturnsfalsewithout queueing for an unknown board, NaN, positive or negative infinity, a destroyed service, and, on the default backend, a fractional value. OrderedDataStores store integers only, so scale fractions (speedrun times in milliseconds). The fractional refusal warns once per board.
Flush windows
Section titled “Flush windows”The first pending submit arms one Scheduler deadline FlushSeconds later at Background
priority. When it fires, the flush runs on its own thread, off the scheduler step, because backend writes yield. It
arms again only while work remains, so an idle server runs no flushes at all.
Each window:
- Takes its budget:
WritesPerFlush(30), capped by the backend’sbudget()when the backend has one. The default backend reports the smaller of the server’sOrderedReadandOrderedWriterequest budgets, because each write is anUpdateAsyncthat spends both (30 + 5 x playersper minute each). On a small server that is well under 30. - Walks the boards in name order, resuming after the board it wrote last, one key per board per turn. A chatty board cannot starve a quiet one across windows. Each queued key gets one attempt per window.
- Detaches the key’s value from the queue before the yielding write. A submit that arrives during the write lands in the queue independently and waits for a later window, so a newer score is never overwritten by an older one finishing.
- Writes. A backend with
submitgets the keep-best rule as data (keepBest,ascending) and applies it atomically in its own store. Otherwisesetruns anUpdateAsync-shaped updater that returnsnil(no write) when the stored value is already as good.
A failed write goes back in the queue, merged with anything newer: the best of the two on a keep-best board, otherwise
the newer value stays. Consecutive failed windows are counted per key. When a key reaches MaxWriteAttempts (default
10), its value is dropped with a warning and Stats.Dropped increments, so a value the store will never accept cannot
cycle forever. A success clears the count. Shutdown drains never drop.
Cached reads
Section titled “Cached reads”top(board, count?) serves ranked entries from a per-board cache and reads the backend at most once per CacheSeconds.
When the cache is missing or stale, the first caller starts one read on its own thread and every concurrent caller for
the same board and page size waits on it, so top yields in that case. A caller that is cancelled while waiting does
not strand the others. On success the cache refreshes and Leaderboard.Updated(name, entries) publishes. A failed read
keeps serving the previous page and retries a full window later; with no previous page, top returns an empty list and
the next call tries again. An error thrown by a custom backend is raised in every waiting caller and does not wedge
later reads.
The default backend reads 100 entries per page. MaxEntries above 100 reads further pages with AdvanceToNextPageAsync
until the board has that many entries or runs out.
Your score and names
Section titled “Your score and names”A top-100 page answers “who is winning”. Two more calls answer the questions a UI asks next, and both yield:
scores(board, subjects)reads stored values for chosen players, sessions or keys (“your score”, a friends board). The default backend usesBatchGetAsyncin chunks of 100 keys. It returns{ [key] = value }, leaves out keys with no stored value, does not include queued submissions, and returnsnilwhen the read failed.names(entries)addsDisplayNameandUsernameto entries keyed by UserId and returns the same table. Players in this server resolve locally. The rest share oneUserService:GetUserInfosByUserIdsAsynccall within its limit of 250 results per minute; ids past the budget stay unnamed until a later call. Results are cached (the cache starts over past 1,000 users), ids with no account are remembered and never requested again, and a failed call warns and leaves the entries unnamed.
Shutdown
Section titled “Shutdown”destroy() stops the service and drains what is left:
- New submits are refused, the pending flush window is cancelled, and every reader waiting on a backend read is released with the cached page.
- The drain joins the write in flight, then runs capped windows until the queue is empty or
ShutdownSeconds(default 10) have passed. After a window with failures it waits up to 0.25 s before the next one, and it stops early when a window makes no progress. - The caller gets
(complete, result)once the drain ends. Repeated calls share one drain and one frozen result, and cancelling a caller does not cancel the drain.
The kernel runs every service stop() when the server closes, inside its 25-second close budget, so call destroy()
there and budget ShutdownSeconds alongside the game’s other shutdown work. At the deadline no new write starts; a
write still in flight may commit later and is reported as unresolved. A custom backend that calls destroy() from
inside its own write gets an immediate pending result instead of waiting on itself.
The backend contract
Section titled “The backend contract”The backend is checked at attach: it needs sorted plus submit or set.
| Function | Signature | Purpose |
|---|---|---|
submit |
submit(board, key, value, keepBest, ascending) -> ok |
Custom stores: an external database over HttpService, a MemoryStore sorted map. The keep-best rule arrives as data, and the store applies it atomically its own way (an upsert, a script, a conditional write). Preferred when a backend has both. |
set |
set(board, key, updater) -> ok |
DataStore-shaped stores, with UpdateAsync semantics: call updater(old) and write a non-nil result. nil means no write. |
sorted |
sorted(board, ascending, count) -> rows? |
Ranked reads. Returns { { Key, Value } }, or nil on failure. |
budget |
budget() -> number? |
Optional. Writes available now; each window takes the smaller of this and WritesPerFlush. |
scores |
scores(board, keys) -> { [key]: number }? |
Optional, needed by Leaderboards:scores. Missing keys omitted, nil on failure. |
The default backend wraps GetOrderedDataStore(StorePrefix .. board): set is UpdateAsync, sorted pages
GetSortedAsync, scores is BatchGetAsync, and budget reads GetRequestBudgetForRequestType.
--!strict-- BoardService: global kill and speedrun boards.
local ReplicatedStorage = game:GetService("ReplicatedStorage")local ServerScriptService = game:GetService("ServerScriptService")
local Leaderboards = require(ServerScriptService.ChloeKernelServer.Leaderboards)local Logger = require(ReplicatedStorage.ChloeKernel.Logger)
local Log = Logger.scope("BoardService")
local BoardService = { Name = "BoardService",}
function BoardService.init(self: any, kernel: any) self.Kernel = kernel self.Boards = Leaderboards.attach(kernel, { Boards = { Kills = {}, -- descending, keep-best, top 100, 60 s cache BestTime = { Ascending = true }, -- smaller wins; submit whole milliseconds }, })end
function BoardService.start(self: any) -- Combat.Kill and Run.Finished are this game's own topics self.Connections = { self.Kernel.Bus:subscribe("Combat.Kill", function(_, killer) local Profile = killer.Profile if Profile then Profile.Data.Kills += 1 self.Boards:submit("Kills", killer, Profile.Data.Kills) end end), self.Kernel.Bus:subscribe("Run.Finished", function(_, session, seconds: number) self.Boards:submit("BestTime", session, math.round(seconds * 1000)) end), }end
-- Yields: the first call per cache window reads the backend, and names() may look users up.function BoardService.topKills(self: any, count: number) return self.Boards:names(self.Boards:top("Kills", count)) -- entries gain DisplayName and Usernameend
-- Yields. nil when the read failed.function BoardService.killsOf(self: any, player: Player): number? local Scores = self.Boards:scores("Kills", { player }) return Scores and Scores[tostring(player.UserId)]end
function BoardService.stop(self: any) for _, Connection in self.Connections or {} do Connection:disconnect() end if self.Boards then local Complete, Result = self.Boards:destroy() -- yields while the last scores drain if not Complete then Log.warn(`board drain incomplete: {Result.Error} ({Result.Remaining} left, {Result.InFlight} in flight)`) end endend
return BoardServiceThe profile’s Kills field comes from the game’s Defaults. Push the board to clients through a Replica or a State
channel from a Leaderboard.Updated subscriber rather than reading it per client request.
Configuration
Section titled “Configuration”Leaderboards.attach(kernel, options) options:
| Option | Default | Description |
|---|---|---|
Boards |
required | { [name] = BoardConfig }. Submits to other names return false. |
FlushSeconds |
15 |
Delay from the first pending submit to its flush window. |
WritesPerFlush |
30 |
Write cap per window, across all boards. |
MaxWriteAttempts |
10 |
Consecutive failed windows before a queued value is dropped. |
ShutdownSeconds |
10 |
Total destroy() drain deadline, including the write in flight. Must be finite and not negative; 0 drains nothing. |
Backend |
OrderedDataStores | The contract above. Checked at attach. |
StorePrefix |
"CKBoard_" |
OrderedDataStore name prefix (default backend only). |
Clock |
os.clock |
Time source for cache windows and the names budget. |
SkipLoop |
false |
Never arm flush windows; specs call _flush() by hand. |
UserService, Players |
engine services | Test injection points for names(). |
BoardConfig:
| Field | Default | Description |
|---|---|---|
Ascending |
false |
Smaller values rank first (times). The default ranks bigger values first (scores). |
KeepBest |
true |
Write only improvements, and keep the best pending value. false keeps and writes the newest value. |
MaxEntries |
100 |
top() page size and backend read size. Above 100 the default backend reads several pages. |
CacheSeconds |
60 |
top() cache window. |
API reference
Section titled “API reference”| Member | Description |
|---|---|
Leaderboards.attach(kernel, options) -> boards |
Checks the backend and ShutdownSeconds, and builds the service. Nothing runs until the first submit. |
boards:submit(board, subject, value) -> boolean |
Queues a score. false for an unknown board, NaN, infinities, after destroy(), and fractional values on the default backend. |
boards:top(board, count?) -> { Entry } |
Up to min(count, MaxEntries) entries { Key, Value, Rank } from the cache. Yields when the cache is stale. Empty for an unknown board or when no page has loaded. Raises an error that a custom backend’s sorted threw. |
boards:scores(board, subjects) -> { [key]: number }? |
Yields. Stored values for chosen Players, sessions or keys. {} for an unknown board; nil when the read failed. Errors when the backend has no scores. |
boards:names(entries) -> { Entry } |
Yields. Adds DisplayName and Username to entries keyed by UserId, within the 250-per-minute budget, and returns the same table. |
boards:flushNow() |
Runs one capped window now. Returns at once if a flush is already running. |
boards:destroy() -> (boolean, ShutdownResult) |
Refuses new submits, releases waiting readers, joins the write in flight and drains capped windows until empty or ShutdownSeconds. Yields. |
boards.Stats |
{ Submitted, Written, Failed, Dropped } counters. |
ShutdownResult is frozen and shared by every destroy() caller:
| Field | Description |
|---|---|
Complete |
Nothing queued, nothing in flight, no error. |
Remaining |
Queued submissions plus the unresolved write in flight. |
InFlight |
1 when a write was still unresolved at the end, else 0. |
Failed |
Failed write attempts during the drain. |
Error |
"leaderboard shutdown deadline exceeded", "leaderboard shutdown flush made no progress", a flush error, or nil. |
Bus topics
Section titled “Bus topics”| Topic | Payload | When |
|---|---|---|
Leaderboard.Updated |
name, entries |
A top() read refreshed the cache from the backend. Failed reads publish nothing. |