Lifecycle: HISTORICAL (published KEEP). For the promoted reader route and current implementation caveat, see Nakama Ranking Cheatsheet.
🏆 Nakama Ranking & Leaderboards — Funday Cheatsheet
A condensed view of how Nakama leaderboards rank owners and how Funday integrates them.
🧠 Core Nakama Model
-
Leaderboard config (immutable)
id: canonical name (e.g.minigolf_highscores,fungame_highscore)authoritative:true= only server/runtime can write;false= clients can writesort:"asc"or"desc"— determines whether lower or higher scores rank higheroperator:"set" | "best" | "incr" | "decr"max_num_score: how many historical scores per owner to keep (ranking still uses one “active” record)reset_schedule: CRON expression for automatic resetsmetadata: arbitrary JSON, e.g. labels, metric type
-
Leaderboard record
owner_id: user or group ID — ranking is per-ownerscore: primary integer used for ranking (after operator applied)subscore: secondary integer used as deterministic tiebreakernum_score: submission counter for that ownermetadata: arbitrary JSON (build details, map, mode, device info, etc.)
-
Owner ranking mechanics
- One active record per
(leaderboard_id, owner_id)in the ranking. - On each write:
set→ replace the active record with the new score.best→ replace only if new score is better according tosort:sort = "desc"→ higher score is better.sort = "asc"→ lower score is better (e.g. lap time).
incr→score := score + newScore(cumulative).decr→score := score - newScore.
- Ranking is computed from
scoreandsubscorewithsort; ties fall back to server-defined stable ordering.
- One active record per
🎯 Funday Leaderboards — System Pattern
1) Canonical IDs via Plugin Metadata
-
Single source of truth: each game declares its leaderboards in its manifest:
// games/<id>/funday-plugin.json { "id": "minigolf", "leaderboards": { "default": "minigolf_highscores", }, } -
Examples:
fungame→leaderboards.default = "fungame_highscore"minigolf→leaderboards.default = "minigolf_highscores"
-
The platform loader (
frontend/src/lib/server/plugins.ts) injects this into theGametype asgame.leaderboards.
2) Nakama Seeding
-
Runtime module (
nakama-modules/data-seed.lua) creates leaderboards on startup:local boards = { 'minigolf_highscores', 'fungame_highscore', -- ...other boards } for _, id in ipairs(boards) do nk.leaderboard_create(id, false, 'desc', 'best', '', { game = id }) end -
Rule: canonical IDs in manifests must match the IDs seeded in Nakama.
3) Submission Path (Guest‑First + Bridge)
-
Games → Host
-
Web/iframe games use
FundayBridge:bridge.submitScore("fungame_highscore", score, { mode: "demo" }) // -> emits { type: 'funday:score-submitted', leaderboardId, score, meta }
-
-
Host → API → Nakama
-
Game viewport receives
funday:score-submittedand POSTs:await fetch("/api/leaderboards/submit", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ leaderboardId: "fungame_highscore", score, meta, }), }) -
POST /api/leaderboards/submit:- Validates
leaderboardId(canonical) andscore. - Uses authenticated session if present; otherwise uses device ID:
- Cookie
funday-device-id(guest-first). - If missing, create one and authenticate with Nakama (
authenticateDevice).
- Cookie
- Calls
writeLeaderboardRecord(session, leaderboardId, { score, subscore?, metadata }).
- Validates
-
Owner identity:
- Authenticated player →
owner_id = user_id. - Guest player →
owner_id = device ID(persistent via cookie).
- Authenticated player →
-
4) Reading Leaderboards (Game Detail Page)
-
Frontend uses the game’s default leaderboard:
<LeaderboardTable leaderboardId={data.game.leaderboards?.default ?? data.game.id} title={`${data.game.title} Leaderboard`} limit={20} /> -
GET /api/leaderboards/[id]:- Treats
idas canonical (no alias maps). - Uses a guest or user Nakama session to call
listLeaderboardRecords.
- Treats
🧩 Complex / Structured Scoring (Funday Pattern)
- Primary score: single integer, used for rank.
- Examples: total strokes, total points, milliseconds, MMR bucket index.
- Subscore (tiebreaker): secondary integer.
- Examples:
time_ms,under_par,deaths,rounds_won.
- Examples:
- Metadata: structured JSON for UI and analytics.
- Examples:
{ courseId, mapId, mode, platform, build, region }.
- Examples:
Examples:
-
Minigolf
score=totalScore(strokes; lower is better)subscore=totalScore - totalPar(under/over par)- Prefer
sort = "asc",operator = "best"on the Nakama board.
-
Arcade highscores
score= points (higher is better)subscore= time to reach score, combo count, etc.sort = "desc",operator = "best".
-
Season XP
score= accumulated XP- Use
operator = "incr"to accumulate.
🪣 Bucketed Leaderboards (Scaling Pattern)
When global boards get too dense, use buckets:
- ID pattern:
minigolf_highscores:eu:gold,minigolf_highscores:na:silver, etc.
- Bucket key sources:
- Region, platform, skill tier, playlist, season.
- Implementation:
- Compute bucket key in runtime code.
- Write to
leaderboardId = baseId .. ':' .. bucketKey. - Use Nakama’s bucketed leaderboards guide for details.
For Funday, this can be layered on top of the manifest model:
"leaderboards": {
"default": "minigolf_highscores",
"buckets": {
"region_skill": "minigolf_highscores:{region}:{skillTier}"
}
}✅ Universal Funday Leaderboard Rule (Condensed)
-
Canonical IDs only
- No server-side alias maps. IDs in games, manifests, and Nakama must match.
-
Config in manifests
- Every game must declare
leaderboards.default. - Extra boards (modes/buckets) also live in
leaderboards.*.
- Every game must declare
-
Nakama as source of truth
- All boards are seeded (or lazily created) in a Nakama runtime module.
-
Guest-first owners
- If no auth session → authenticate by device ID and use that as
owner_id.
- If no auth session → authenticate by device ID and use that as
-
Single submission path
- Games emit
funday:score-submittedvia FundayBridge. - Host forwards to
/api/leaderboards/submit→ Nakama.
- Games emit
-
Ranking semantics
- Choose
sort+operatorper game mode:- Time trials →
asc+best. - Highscore / arcade →
desc+best. - Long-term XP →
desc+incr.
- Time trials →
- Choose
-
Structure for complexity
- Use
score+subscore+metadatafor multi-dimensional scoring. - Never encode complex state in
scorealone when a tiebreaker is needed.
- Use