Skip to content

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.

submit(board, subject, value) resolves the subject to a storage key and stores the value in the board’s queue:

  • A Player becomes its UserId as a string, a session becomes its player’s UserId string, and anything else is passed through tostring, so guild names and NPC ids work as keys.
  • On a KeepBest board (the default) the queue keeps the best pending value per key: the smaller one on an Ascending board, the larger one otherwise. With KeepBest = false it keeps the newest.
  • submit returns false without 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.

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:

  1. Takes its budget: WritesPerFlush (30), capped by the backend’s budget() when the backend has one. The default backend reports the smaller of the server’s OrderedRead and OrderedWrite request budgets, because each write is an UpdateAsync that spends both (30 + 5 x players per minute each). On a small server that is well under 30.
  2. 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.
  3. 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.
  4. Writes. A backend with submit gets the keep-best rule as data (keepBest, ascending) and applies it atomically in its own store. Otherwise set runs an UpdateAsync-shaped updater that returns nil (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.

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.

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 uses BatchGetAsync in chunks of 100 keys. It returns { [key] = value }, leaves out keys with no stored value, does not include queued submissions, and returns nil when the read failed.
  • names(entries) adds DisplayName and Username to entries keyed by UserId and returns the same table. Players in this server resolve locally. The rest share one UserService:GetUserInfosByUserIdsAsync call 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.

destroy() stops the service and drains what is left:

  1. New submits are refused, the pending flush window is cancelled, and every reader waiting on a backend read is released with the cached page.
  2. 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.
  3. 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 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.

src/Server/Game/Services/BoardService.luau
--!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 Username
end
-- 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
end
end
return BoardService

The 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.

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.
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.
Topic Payload When
Leaderboard.Updated name, entries A top() read refreshed the cache from the backend. Failed reads publish nothing.

ChloeKernel v0.9.1. Access to the framework is private and granted individually by Chloe. Games built with it credit CryptedChloes in their description.