Skip to Content
Game ServerLeaderboard

Leaderboard Implementation

A leaderboard is a global collection with one item per player, keyed by the player’s account. Using the account as the item id (options.id) turns every submission into an upsert of that player’s single entry, so there are no duplicates to clean up and every query stays small.

Server

server/src/server.ts
export class Server { /** * Records a score if it beats the player's best. * @returns {Promise<{ updated: boolean, entry: Item | null }>} */ async submitScore(score, nickname) { if (typeof score !== "number" || !Number.isFinite(score) || score < 0) { throw new Error("Invalid score."); } const name = typeof nickname === "string" ? nickname.trim() : ""; if (name.length < 1 || name.length > 15) { throw new Error("Nickname must be between 1 and 15 characters."); } // Read-compare-write on this player's entry: the lock stops two // submissions in flight from both passing the comparison. return $lock(`score:${$sender.account}`, async () => { const best = await this.getMyEntry(); if (best && best.score >= score) return { updated: false, entry: best }; const entry = await $global.addCollectionItem( "rankings", { account: $sender.account, nickname: name, score, updatedAt: Date.now() }, { id: $sender.account } // upsert: replaces this player's previous entry ); return { updated: true, entry }; }); } /** The top 20, highest first. */ async getTopRankings() { return $global.getCollectionItems("rankings", { orderBy: [{ field: "score", direction: "desc" }], limit: 20, }); } /** The caller's entry and rank; ties share a rank. */ async getMyRank() { const entry = await this.getMyEntry(); if (!entry) return { entry: null, rank: null }; const higher = await $global.countCollectionItems("rankings", { filters: [{ field: "score", operator: ">", value: entry.score }], }); return { entry, rank: higher + 1 }; } // A filtered read rather than getCollectionItem, which throws for a player // who has no entry yet — this returns null instead. async getMyEntry() { const [entry] = await $global.getCollectionItems("rankings", { filters: [{ field: "account", operator: "==", value: $sender.account }], }); return entry ?? null; } }

Why it holds up:

  • No composite index needed. getTopRankings orders on score with no filter, and getMyEntry filters on account with no order — each touches one field. See Query Options.
  • The rank is exact. countCollectionItems is not capped at 100. It does not yet include writes from the last ~2s, so a very recent submission from another player can be missing from the count.
  • Scores are not currency. The last ~2s of global collection writes can be lost if the verse’s in-memory store fails — acceptable for a leaderboard, never for rewards. Grant rewards with $asset.

To keep a history of every play as well, write each one to a second collection (e.g. plays). Keep the leaderboard itself at one item per player.

Client

components/Leaderboard.tsx
import { useEffect, useState } from "react"; import { useGameServer } from "@agent8/gameserver"; type Entry = { __id: string; account: string; nickname: string; score: number }; export default function Leaderboard() { const { connected, server, account } = useGameServer(); const [top, setTop] = useState<Entry[]>([]); const [mine, setMine] = useState<{ entry: Entry | null; rank: number | null } | null>(null); useEffect(() => { if (!connected) return; // Neither function takes arguments, so the args array is omitted. Promise.all([ server.remoteFunction("getTopRankings"), server.remoteFunction("getMyRank"), ]).then(([t, m]) => { setTop(t); setMine(m); }); }, [connected, server]); return ( <div> <ol> {top.map((e) => ( <li key={e.__id}> {e.account === account ? <b>{e.nickname}</b> : e.nickname} — {e.score} </li> ))} </ol> {mine?.entry && ( <p> Your best: {mine.entry.score} (#{mine.rank}) </p> )} </div> ); }

Submitting from a game-over screen is one call. updated is false when the score did not beat the player’s best:

const { updated } = await server.remoteFunction("submitScore", [score, nickname]);

To skip the nickname prompt for a score that cannot place, call getMyRank first and compare against entry.score.

Last updated on