Skip to content

Leaderboard ​

Build a server-wide top-10 leaderboard. Scores are stored permanently in a SQLite table, the current top 10 is mirrored into a replicated collection, and a GUI window on every client lists it and updates live the moment anyone's score changes. Nobody polls and nobody reopens the window.

By the end you will have:

  • a scores table in data/databases/leaderboard.db holding every account's score,
  • a leaderboard collection that always contains exactly the current top 10,
  • an F6 window listing rank, name and score, with your own row highlighted,
  • a /coin chat command that earns a point (a stand-in for a real score source),
  • an onAddScore hook other server scripts can call.

What you'll learn

The finished files are in docs/examples/leaderboard/:

FileRunsGoes to
weapons/leaderboard.tsserverthe Weapons editor, leaderboard entry, Serverside tab
weapons/leaderboard.client.tsevery clientthe same entry's Clientside tab

Why two storage systems? ​

SQLite (opendatabase)Collection (collection)
Holdsevery score everonly the current top 10
Good atORDER BY score DESC LIMIT 10 across thousands of rowspushing small, live data to clients
Visible to clientsneveryes, a read-only mirror with change events

You could store every score in the collection and sort it on the client, but then every client would download every account's score. SQL does the ranking on the server, and the collection ships only the answer. See SQLite databases and Replicated collections.

Step 0: Define the collection ​

Scripts can read and write collections but cannot create them: definitions are a deliberate staff action. In GRC, open the Collections tool and create:

FieldValueWhy
Nameleaderboard
Scopeglobal: one shared setone board for the whole server
Audienceall: every game clienteveryone may see it
Replicateeager: full replica at login10 small records, every client wants them
Backingmemory: volatileSQLite is the source of truth, and the script rebuilds the board at startup

Using a collection that isn't defined throws at the call site, which the script below catches and logs.

Step 1: The table ​

ts
const db = opendatabase('leaderboard')
const board = collection('leaderboard')
const TOP = 10

async function setup() {
    await db.exec(`CREATE TABLE IF NOT EXISTS scores (
        account TEXT PRIMARY KEY,
        nick    TEXT NOT NULL,
        score   INTEGER NOT NULL DEFAULT 0,
        updated INTEGER NOT NULL)`)
    await publishTop()
}

function onCreated() {
    setup().catch(e => echo('[leaderboard] setup failed: ' + (e instanceof Error ? e.message : String(e))))
}
  • opendatabase does no I/O, so opening at module top level is fine. The file is created on the first statement.
  • onCreated runs on server start and on every hot reload, and CREATE TABLE IF NOT EXISTS makes it safe to repeat. It then publishes the current top 10, which also refills the memory-backed collection after a restart.
  • Database calls return promises. Always attach a .catch (or use try/catch inside async functions): a failed statement rejects, and an unhandled rejection only shows up in the log.

Step 2: Adding points ​

ts
/**
 * Adds points to a player's score. Other server scripts call it through
 * findweapon('leaderboard')?.trigger('onAddScore', player, points).
 */
async function onAddScore(player: Player, points: number) {
    if (!Number.isFinite(points) || points === 0) return
    try {
        await db.exec(
            `INSERT INTO scores (account, nick, score, updated) VALUES (?, ?, ?, ?)
             ON CONFLICT(account) DO UPDATE SET
                 score = score + excluded.score,
                 nick = excluded.nick,
                 updated = excluded.updated`,
            [player.account, player.nick || player.account, Math.floor(points), Date.now()])
        await publishTop()
    } catch (e) {
        echo('[leaderboard] could not add score: ' + (e instanceof Error ? e.message : String(e)))
    }
}
  • One statement, no race. INSERT ... ON CONFLICT(account) DO UPDATE SET score = score + excluded.score creates the row on a player's first point and increments it atomically afterwards. A read-then-write pair could lose points when two updates interleave. All SQL runs in order on one server-wide worker, and each statement is atomic.
  • Parameters bind to bare ? placeholders in order. Never paste player input (like nicks) into SQL text.
  • player.account and player.nick are read before the first await. A Player is a snapshot taken when the event fired.

Step 3: Publishing the top 10 ​

ts
interface BoardRecord { rank: number; nick: string; score: number }

/** Re-reads the top 10 and makes the collection match it exactly. */
async function publishTop() {
    const rows = await db.query(
        'SELECT account, nick, score FROM scores ORDER BY score DESC, updated ASC LIMIT ?', [TOP])

    const keep = new Set<string>()
    rows.forEach((row, i) => {
        const key = String(row.account).toLowerCase()
        const record: BoardRecord = { rank: i + 1, nick: String(row.nick), score: Number(row.score) }
        keep.add(key)
        // Only write what changed: every write is a delta to every client.
        if (JSON.stringify(board.get(key)) !== JSON.stringify(record))
            board.set(key, record)
    })
    for (const key of board.keys())
        if (!keep.has(key)) board.delete(key)
}

The collection is keyed by lower-cased account name, and each record carries its rank. The function makes the collection match the query exactly:

  • records whose content changed are set, and unchanged ones are skipped,
  • accounts that dropped out of the top 10 are deleted.

Collection writes are synchronous against server memory and are delta-synced to the audience once per tick. Skipping identical records keeps those deltas tiny: one player gaining a point usually sends one record, or a few when ranks shift.

Collections don't need a player

triggerClient only works while a player's event is being handled. After an await there is no "current player" and the call is dropped. Collection writes have no such restriction, which is why this system can be fully async: the result reaches clients through the collection, not through a reply.

Step 4: Scoring from the client (demo) ​

ts
// Demo score source: the client's /coin command. A client can send anything,
// so the server rate-limits it — in a real game, award points from
// server-side logic (an NPC's onShot, a quest script) via onAddScore instead.
const lastCoin = new Map<string, number>()

function onActionServerSide(player: Player, action: string) {
    if (action !== 'coin') return
    const now = Date.now()
    if (now - (lastCoin.get(player.account) ?? 0) < 1000) return
    lastCoin.set(player.account, now)
    onAddScore(player, 1)
}

In the clientside half:

ts
function onPlayerChats(who: ChatPlayer, chat: string) {
    if (who.id === player.id && chat === '/coin')
        triggerServer('weapon', this.name, 'coin')
}

triggerServer('weapon', this.name, 'coin') reaches the weapon's own serverside onActionServerSide(player, 'coin'). The server only accepts it from players who have the weapon, but anything else in the request is under the client's control. Hence the server-side rate limit. For real scoring, award points where the server already knows they were earned:

ts
// e.g. in an NPC's serverside script
function onShot(this: NpcThis, data: any) {
    // ...the projectile's payload says who fired it; look the player up and:
    // findweapon('leaderboard')?.trigger('onAddScore', player, 10)
}

findweapon(name).trigger(event, ...params) calls another server script's exported function right away, passing the Player object intact.

Step 5: The window ​

The clientside half builds the window once, with ten fixed rows of labels, and later only changes their text. That is cheaper and flicker-free compared with destroying and recreating controls.

ts
const ROWS = 10
const ROW_H = 26
const WIN_W = 280
const TOP = 44                        // clear the window's title bar
const WIN_H = TOP + ROWS * ROW_H + 16

let win: GuiWindowCtrl
const rankLabels: GuiTextCtrl[] = []
const nickLabels: GuiTextCtrl[] = []
const scoreLabels: GuiTextCtrl[] = []
const rowPanels: GuiPanelCtrl[] = []

function buildWindow() {
    win = new GuiWindowCtrl('Leaderboard_Window')
    win.text = 'Top 10'
    win.extent = `${WIN_W},${WIN_H}`
    win.position = `${ScreenWidth - WIN_W - 20},80`

    for (let i = 0; i < ROWS; i++) {
        const y = TOP + i * ROW_H

        // A highlight bar behind the row; only colored for your own entry.
        const bar = new GuiPanelCtrl(`Leaderboard_Row${i}`)
        bar.position = `8,${y - 2}`
        bar.extent = `${WIN_W - 16},${ROW_H - 2}`
        bar.borderradius = 4
        win.addControl(bar)
        rowPanels.push(bar)

        rankLabels.push(label(`Leaderboard_Rank${i}`, 16, y, 30))
        nickLabels.push(label(`Leaderboard_Nick${i}`, 50, y, 150))
        const score = label(`Leaderboard_Score${i}`, WIN_W - 90, y, 70)
        score.style = 'c'
        scoreLabels.push(score)
    }
    win.hide()
}

function label(name: string, x: number, y: number, width: number): GuiTextCtrl {
    const l = new GuiTextCtrl(name)
    l.position = `${x},${y}`
    l.width = width
    l.fontsize = 14
    l.text = ''
    win.addControl(l)
    return l
}
  • Control names are global on the client, so prefix them with the weapon name (Leaderboard_...).
  • Children of a window are positioned relative to its top-left corner, including the title bar. That's why rows start at TOP = 44.
  • Setting a label's width switches off its auto-sizing. The score column needs this so style = 'c' has a box to center in.
  • A GuiPanelCtrl is invisible until it gets a color, which makes it a handy highlight bar.

Step 6: Live updates ​

ts
let open = false
let dirty = true
let f6WasDown = false

function onCreated() {
    buildWindow()
    // 'change' fires per record the server sets or deletes; 'reset' when the
    // whole mirror is replaced (e.g. the login snapshot). Either way, just
    // mark the list stale and redraw once in the next frame.
    collection('leaderboard')
        .on('change', () => { dirty = true })
        .on('reset', () => { dirty = true })
}

function onUpdate() {
    const f6 = keydown('f6')
    if (f6 && !f6WasDown) {
        open = !open
        if (open) {
            win.show()
            win.bringtofront()
            dirty = true
        } else {
            win.hide()
        }
    }
    f6WasDown = f6

    if (open && dirty) {
        dirty = false
        refresh()
    }
}

collection('leaderboard') on the client is a read-only mirror that the server keeps up to date:

  • change (key, record) fires for each record the server sets or deletes (record is null on delete),
  • reset fires when the whole mirror is replaced, for example by the login snapshot.

Rather than patching rows inside the listener, the handler just sets dirty, and onUpdate redraws once per frame, and only while the window is open. A burst of ten changes (ranks shifting) costs one redraw. Listeners belong to the script that registered them and are removed when the weapon unloads.

F6 is edge-detected with keydown (true while held), comparing with the previous frame so one press toggles once.

ts
function refresh() {
    const entries: (BoardRecord & { key: string })[] = []
    collection('leaderboard').forEach((key, record: BoardRecord) => {
        entries.push({ key, ...record })
    })
    entries.sort((a, b) => a.rank - b.rank)

    const me = player.account.toLowerCase()
    for (let i = 0; i < ROWS; i++) {
        const e = entries[i]
        rankLabels[i].text = e ? `${e.rank}.` : ''
        nickLabels[i].text = e ? e.nick : (i === 0 ? 'No scores yet.' : '')
        scoreLabels[i].text = e ? String(e.score) : ''
        rowPanels[i].color = e && e.key === me ? '255,215,90,70' : ''
    }
}

forEach walks the mirror. Records are frozen, so spread them into new objects before adding fields. The row whose key matches your account gets a translucent gold highlight ('r,g,b,a'), and assigning '' removes the fill again.

Complete files ​

Every file from this tutorial, in full, for copying into GRC.

weapons/leaderboard.client.ts
ts
// Clientside half of the leaderboard weapon: F6 toggles a window listing the
// top 10 from the live `leaderboard` collection. The rows update by
// themselves whenever the server changes the collection.
//
//   /coin   earn a point (demo score source, rate-limited by the server)

interface BoardRecord { rank: number; nick: string; score: number }

const ROWS = 10
const ROW_H = 26
const WIN_W = 280
const TOP = 44                        // clear the window's title bar
const WIN_H = TOP + ROWS * ROW_H + 16

let win: GuiWindowCtrl
const rankLabels: GuiTextCtrl[] = []
const nickLabels: GuiTextCtrl[] = []
const scoreLabels: GuiTextCtrl[] = []
const rowPanels: GuiPanelCtrl[] = []

function buildWindow() {
    win = new GuiWindowCtrl('Leaderboard_Window')
    win.text = 'Top 10'
    win.extent = `${WIN_W},${WIN_H}`
    win.position = `${ScreenWidth - WIN_W - 20},80`

    for (let i = 0; i < ROWS; i++) {
        const y = TOP + i * ROW_H

        // A highlight bar behind the row; only colored for your own entry.
        const bar = new GuiPanelCtrl(`Leaderboard_Row${i}`)
        bar.position = `8,${y - 2}`
        bar.extent = `${WIN_W - 16},${ROW_H - 2}`
        bar.borderradius = 4
        win.addControl(bar)
        rowPanels.push(bar)

        rankLabels.push(label(`Leaderboard_Rank${i}`, 16, y, 30))
        nickLabels.push(label(`Leaderboard_Nick${i}`, 50, y, 150))
        const score = label(`Leaderboard_Score${i}`, WIN_W - 90, y, 70)
        score.style = 'c'
        scoreLabels.push(score)
    }
    win.hide()
}

function label(name: string, x: number, y: number, width: number): GuiTextCtrl {
    const l = new GuiTextCtrl(name)
    l.position = `${x},${y}`
    l.width = width
    l.fontsize = 14
    l.text = ''
    win.addControl(l)
    return l
}

let open = false
let dirty = true
let f6WasDown = false

function onCreated() {
    buildWindow()
    // 'change' fires per record the server sets or deletes; 'reset' when the
    // whole mirror is replaced (e.g. the login snapshot). Either way, just
    // mark the list stale and redraw once in the next frame.
    collection('leaderboard')
        .on('change', () => { dirty = true })
        .on('reset', () => { dirty = true })
}

function onUpdate() {
    const f6 = keydown('f6')
    if (f6 && !f6WasDown) {
        open = !open
        if (open) {
            win.show()
            win.bringtofront()
            dirty = true
        } else {
            win.hide()
        }
    }
    f6WasDown = f6

    if (open && dirty) {
        dirty = false
        refresh()
    }
}

function refresh() {
    const entries: (BoardRecord & { key: string })[] = []
    collection('leaderboard').forEach((key, record: BoardRecord) => {
        entries.push({ key, ...record })
    })
    entries.sort((a, b) => a.rank - b.rank)

    const me = player.account.toLowerCase()
    for (let i = 0; i < ROWS; i++) {
        const e = entries[i]
        rankLabels[i].text = e ? `${e.rank}.` : ''
        nickLabels[i].text = e ? e.nick : (i === 0 ? 'No scores yet.' : '')
        scoreLabels[i].text = e ? String(e.score) : ''
        rowPanels[i].color = e && e.key === me ? '255,215,90,70' : ''
    }
}

function onPlayerChats(who: ChatPlayer, chat: string) {
    if (who.id === player.id && chat === '/coin')
        triggerServer('weapon', this.name, 'coin')
}
weapons/leaderboard.ts
ts
// Serverside half of the leaderboard weapon.
//
// Scores live in SQLite (data/databases/leaderboard.db), the permanent record
// for every account. The top 10 are mirrored into the `leaderboard`
// collection (global scope, audience all, memory backing, eager replication —
// define it in GRC's Collections tool), which every client holds a live copy
// of. SQL answers "who is on top"; the collection ships the answer.

const db = opendatabase('leaderboard')
const board = collection('leaderboard')
const TOP = 10

async function setup() {
    await db.exec(`CREATE TABLE IF NOT EXISTS scores (
        account TEXT PRIMARY KEY,
        nick    TEXT NOT NULL,
        score   INTEGER NOT NULL DEFAULT 0,
        updated INTEGER NOT NULL)`)
    await publishTop()
}

function onCreated() {
    setup().catch(e => echo('[leaderboard] setup failed: ' + (e instanceof Error ? e.message : String(e))))
}

/**
 * Adds points to a player's score. Other server scripts call it through
 * findweapon('leaderboard')?.trigger('onAddScore', player, points).
 */
async function onAddScore(player: Player, points: number) {
    if (!Number.isFinite(points) || points === 0) return
    try {
        await db.exec(
            `INSERT INTO scores (account, nick, score, updated) VALUES (?, ?, ?, ?)
             ON CONFLICT(account) DO UPDATE SET
                 score = score + excluded.score,
                 nick = excluded.nick,
                 updated = excluded.updated`,
            [player.account, player.nick || player.account, Math.floor(points), Date.now()])
        await publishTop()
    } catch (e) {
        echo('[leaderboard] could not add score: ' + (e instanceof Error ? e.message : String(e)))
    }
}

interface BoardRecord { rank: number; nick: string; score: number }

/** Re-reads the top 10 and makes the collection match it exactly. */
async function publishTop() {
    const rows = await db.query(
        'SELECT account, nick, score FROM scores ORDER BY score DESC, updated ASC LIMIT ?', [TOP])

    const keep = new Set<string>()
    rows.forEach((row, i) => {
        const key = String(row.account).toLowerCase()
        const record: BoardRecord = { rank: i + 1, nick: String(row.nick), score: Number(row.score) }
        keep.add(key)
        // Only write what changed: every write is a delta to every client.
        if (JSON.stringify(board.get(key)) !== JSON.stringify(record))
            board.set(key, record)
    })
    for (const key of board.keys())
        if (!keep.has(key)) board.delete(key)
}

// Demo score source: the client's /coin command. A client can send anything,
// so the server rate-limits it — in a real game, award points from
// server-side logic (an NPC's onShot, a quest script) via onAddScore instead.
const lastCoin = new Map<string, number>()

function onActionServerSide(player: Player, action: string) {
    if (action !== 'coin') return
    const now = Date.now()
    if (now - (lastCoin.get(player.account) ?? 0) < 1000) return
    lastCoin.set(player.account, now)
    onAddScore(player, 1)
}

Try it ​

  1. Create the leaderboard collection in GRC (step 0).
  2. Create both halves of leaderboard in GRC's Weapons editor, save, and grant leaderboard to yourself from GRC's Players window (right-click, Grant weapon…).
  3. Press F6: the window says No scores yet.
  4. Type /coin a few times (at most one point per second counts). Your row appears and its score climbs, highlighted in gold.
  5. Log in with a second account and earn some coins there. The first client's window reorders in real time.
  6. Open GRC's SQL Explorer, select leaderboard.db, and run SELECT * FROM scores ORDER BY score DESC: every account is there, not just the top 10.

The scores survive server restarts: after one, the memory-backed collection starts empty, onCreated republishes the top 10 from SQLite, and the board looks exactly as before.

Editing scores by hand

Changing rows in the SQL Explorer doesn't notify the script. The board updates on the next publishTop() (the next point anyone earns, or a reload of the weapon).

Next steps ​

  • Weekly boards: add a week column (for example strftime('%Y-%W')), make the primary key (account, week), and filter the top-10 query by the current week.
  • Your own rank: when you're outside the top 10, show "You: #57". Count the rows with a higher score in SQL (SELECT COUNT(*) + 1 ...). The answer arrives after an await, so deliver it through a second collection with account scope and owner audience (collection('myrank').for(account).set('rank', {...})), not through triggerClient.
  • Several boards: one collection per board (board_coins, board_kills), or one collection with keys like coins:alice, with the window gaining tabs.
  • Nicer rows: show each player's head with a GuiImageCtrl (store head in the record).