Appearance
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
scorestable indata/databases/leaderboard.dbholding every account's score, - a
leaderboardcollection that always contains exactly the current top 10, - an F6 window listing rank, name and score, with your own row highlighted,
- a
/coinchat command that earns a point (a stand-in for a real score source), - an
onAddScorehook other server scripts can call.
What you'll learn
- Creating tables and upserting rows with
opendatabase,execandquery - Mirroring query results into a collection with
set/delete - Reading a collection clientside and reacting to its
change/resetevents withon - A fixed-row GUI window built from
GuiWindowCtrl,GuiPanelCtrlandGuiTextCtrl - Why async server code can't reply with
triggerClientafter anawait, and why that's fine here
The finished files are in docs/examples/leaderboard/:
| File | Runs | Goes to |
|---|---|---|
weapons/leaderboard.ts | server | the Weapons editor, leaderboard entry, Serverside tab |
weapons/leaderboard.client.ts | every client | the same entry's Clientside tab |
Why two storage systems?
SQLite (opendatabase) | Collection (collection) | |
|---|---|---|
| Holds | every score ever | only the current top 10 |
| Good at | ORDER BY score DESC LIMIT 10 across thousands of rows | pushing small, live data to clients |
| Visible to clients | never | yes, 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:
| Field | Value | Why |
|---|---|---|
| Name | leaderboard | |
| Scope | global: one shared set | one board for the whole server |
| Audience | all: every game client | everyone may see it |
| Replicate | eager: full replica at login | 10 small records, every client wants them |
| Backing | memory: volatile | SQLite 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))))
}opendatabasedoes no I/O, so opening at module top level is fine. The file is created on the first statement.onCreatedruns on server start and on every hot reload, andCREATE TABLE IF NOT EXISTSmakes 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 usetry/catchinsideasyncfunctions): 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.scorecreates 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.accountandplayer.nickare read before the firstawait. APlayeris 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
widthswitches off its auto-sizing. The score column needs this sostyle = 'c'has a box to center in. - A
GuiPanelCtrlis invisible until it gets acolor, 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 (recordisnullon delete),resetfires 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
- Create the
leaderboardcollection in GRC (step 0). - Create both halves of
leaderboardin GRC's Weapons editor, save, and grantleaderboardto yourself from GRC's Players window (right-click, Grant weapon…). - Press F6: the window says No scores yet.
- Type
/coina few times (at most one point per second counts). Your row appears and its score climbs, highlighted in gold. - Log in with a second account and earn some coins there. The first client's window reorders in real time.
- Open GRC's SQL Explorer, select
leaderboard.db, and runSELECT * 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
weekcolumn (for examplestrftime('%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 anawait, so deliver it through a second collection with account scope and owner audience (collection('myrank').for(account).set('rank', {...})), not throughtriggerClient. - Several boards: one collection per board (
board_coins,board_kills), or one collection with keys likecoins:alice, with the window gaining tabs. - Nicer rows: show each player's head with a
GuiImageCtrl(storeheadin the record).