Skip to content
Work in progress. These docs describe Spawnite at launch, and some parts are still being built.

Leaderboards

A game declares its boards once with defineLeaderboards. A room submits a player’s score with submitScore, and the score rides the checkpoint that saves the progress that earned it: the board and the save never disagree. A page shows a board with useLeaderboard. A game with no rooms sends its scores from the page with sendScore, and its board says the scores are unverified.

Roblox gives a game an ordered data store and leaves periods, trust and prizes to its scripts. Steam, PlayFab and Unity store a score per board and reset it on a schedule. Here a board has its period built in, so a weekly board needs no new store each week, and a room’s score is written in the same transaction as the player’s save.

Each board sits under a camelCase key, which is its identity once published:

  • writtenBy: "room" for a board a room writes in its checkpoint’s commit, which makes it trusted; "page" for a game with no rooms, whose page sends its scores.
  • keyed: true: one board for each key the game names, such as a track.
  • parent: "world": one board for each world, which the world’s wipe closes, apart from keyed. A world board’s handle is .for(worldId), and .for(worldId, key) where it is keyed too, with the id readHeldWorld(world) names.
  • audience: who reads the board. "game", the default, is everyone who plays; "world" is anyone who has entered the board’s world, and "members" the world’s owners and members alone, so a farm ranks its own members. Both need parent: "world".
  • sortOrder, min, max and unit for a board that ranks on one number; score: [...] for up to three named fields, compared in order.
  • update: "best" keeps the better score, "latest" the last one, and "total" adds a whole-number delta.
  • period: "all-time", "daily", "weekly" or "monthly" on UTC boundaries, "world" for each reset of a world, or { windows } for competition windows.
  • metadata: a schema for a row’s detail, such as the car of a lap, 256 bytes at most.

A published board keeps its key’s identity: a later version that changes its writer, its keying, its fields’ order, its rule, its period or its audience is refused at upload. Declare a new board under a new key instead, and mark the old one retired: true.

A game with rooms exports its boards from the room’s scene file, beside game: export { leaderboards } from "../leaderboards". A room whose scene exports none refuses each score as no-board.

In the room’s step, submitScore(step, handle, player, score) keeps the score with the player’s record until the next checkpoint. A keyed board’s handle comes from for, such as leaderboards.fastestLap.for("sunset-loop"). A total takes a delta, with an id from createScoreId(step) where the game may submit the same event again: the same id adds once.

packages/room/test/outside/raceBoards.ts
import {
createScoreId,
defineLeaderboards,
submitScore,
type Authority,
type PlayerEntity,
} from "@spawnite/engine/core";
export const leaderboards = defineLeaderboards({
fastestLap: {
name: "Fastest lap",
writtenBy: "room",
keyed: true,
sortOrder: "ascending",
min: 0,
unit: "milliseconds",
update: "best",
period: "weekly",
},
coinsFound: {
name: "Coins found",
writtenBy: "room",
sortOrder: "descending",
update: "total",
period: "all-time",
},
});
/** In the room's step, as a lap on `track` ends. */
export function finishLap(
step: Authority,
player: PlayerEntity,
track: string,
milliseconds: number,
) {
return submitScore(
step,
leaderboards.fastestLap.for(track),
player,
milliseconds,
);
}
/** In the room's step, as a player picks up coins. */
export function findCoins(
step: Authority,
player: PlayerEntity,
coins: number,
) {
return submitScore(step, leaderboards.coinsFound, player, {
delta: coins,
id: createScoreId(step),
});
}
  • The score is stamped with the period of the tick it was submitted on.
  • A player’s commit carries at most 8 changed rows; the rest wait in the player’s record and go out with the next commit.
  • submitScore answers a refusal, such as out-of-bounds or no-period between two windows, and null where the score waits. A score of the wrong shape throws in development.
  • ScoreCommittedEvent tells the game each row’s outcome on the tick after its commit: changed, unchanged, late for a period that had closed, or refused.

useLeaderboard(handle, { period, view }) reads a board on the page: its top, the player’s friends, or the rows around-player. The top is a snapshot every player shares, rebuilt every 2 minutes while anyone looks, so builtAt says how old it is. A period that ended reads its final result, which keeps each rank as it was at the close.

packages/engine/test/outside/LapBoard.tsx
import { Text, useLeaderboard, type LeaderboardHandle } from "@spawnite/engine";
/** A track's lap board, such as `leaderboards.fastestLap.for(track)`. */
export function LapBoard({ board }: { board: LeaderboardHandle }) {
const read = useLeaderboard(board);
return (
<Text as="ol">
{read.rows.map((row) => (
<Text as="li" key={row.subject.id}>
{`${row.rank}. ${row.name} ${(row.score[0] / 1000).toFixed(2)} s`}
</Text>
))}
</Text>
);
}

A room reads a board with fetchLeaderboard(step, handle, { period, limit }), answered on a later tick by LeaderboardFetchedEvent, for a top-10 sign in the world. To award last week’s winners, fetch period: "previous" once its state is final, and send each winner a deposit whose id is createAwardId(handle, { period, prize, playerId }): two rooms that award one result send one id, and the prize lands once.

What a score means is the game’s. A room that lets a player set their own lap time has a fair board of unfair scores. A page game’s board takes the page’s word, since a page can edit anything it holds, and the board shows it as unverified. The creator and support remove a row, exclude a player and reset a board’s key with spawnite leaderboards and the MCP’s leaderboards tool.

The game’s dev server keeps a stand-in board in .spawnite/leaderboards.json: the development room’s commits write it, a page’s sendScore writes it, and useLeaderboard reads it, so an agent sees a board fill before the game is published.

A playtest’s rooms keep their saves and their boards in the playtest’s own folder, which the playtest’s page reads. So a playtest starts with no rows, its page shows what its room commits, and the game’s own file stays as it was. A playtest started with --room-env ROOM_SAVES=1 keeps its boards in the game’s file, beside its saves.