Crux Kata #2: The Leaderboard Players Actually Look At (Top, Around-Me, Friends)

Hands-on kata: build a real game leaderboard with Crux in under an hour: global top, the around-me window, a friends slice via the social graph, and the server-authoritative submit pattern that keeps cheaters off it.

This is Kata #2 in the Crux Kata series: one real backend feature per exercise, working code, under an hour. Start with Kata #1 (cloud saves and player identity) if you have not, this kata reuses its anonymous-auth setup. Next up: the server browser and matchmaking.

Here is the dirty secret of leaderboards: the global top 10 is the least-viewed leaderboard surface in your game. Players glance at it once, see a score they will never reach, and stop looking. The leaderboard players return to is the one about them: their rank, the three people just above them, and their friends. This kata builds all three surfaces, and then closes the hole that ruins every leaderboard eventually: client-submitted scores.

The kata: create a leaderboard, submit scores, render global top / around-me / friends views, then move the submit server-side. Time: ~45 minutes. You need: a free Crux project (create one) and the player auth from Kata #1.

Step 1: create the board and submit a score

Leaderboards in Crux live per environment, so your dev environment gets its own board and your production numbers stay clean. Create one named global in the dashboard (or via the admin API), then from the client:

import { GSBClient } from "@supercraft/gsb";

const crux = GSBClient.forPlayer(CRUX_API_URL, "proj_...", "env_...", "gsb_apikey_...");
const auth = await crux.loginAnonymous();

// Submit a score
await crux.submitScore("global", auth.player_id, 9900);

One decision to make now, not later: what is a score? Highest-single-run games (arcade, speedrun-style) want best-score semantics; accumulating games (season XP) want totals you re-submit as they grow. Pick one meaning per board and name the board after it; the worst leaderboards are the ones where nobody remembers what the number means.

Step 2: the three surfaces

The read API gives you each surface in one call:

// Surface 1: global top (the lobby wall)
const top = await crux.getTop("global", 10);
top.forEach(e => console.log("#" + e.rank + " " + e.player_id + ": " + e.score));

// Surface 2: the player's own standing (the scoreboard header)
// GET .../leaderboards/global/entries/players/{player_id}
const me = await crux.getPlayerStanding("global", auth.player_id);

// Surface 3: around-me (the one they scroll)
// GET .../leaderboards/global/entries/players/{player_id}/around
const window = await crux.getAroundPlayer("global", auth.player_id, 5);

The around-me window is the retention surface. Rank 48,211 means nothing; "you are 140 points behind player_8841" is a goal for tonight's session. Render it as five above and five below, with the player highlighted, and put it on the post-game screen where the motivation is hottest. The standing call powers the persistent header ("Rank #48,211, top 12%") so the player never has to go looking for their number.

Step 3: the friends slice

Crux ships a social graph (friend requests, accept, remove, block) in the same project, which is exactly enough to build the leaderboard surface with the highest emotional stakes: beating people you actually know. The social endpoints are REST-first today (typed SDK wrappers are on the way), so this step talks to the API directly with the same auth the SDK uses:

const base = CRUX_API_URL + "/projects/proj_.../environments/env_...";
const headers = { "Authorization": "Bearer " + auth.access_token,
                  "Content-Type": "application/json" };

// Befriend (one-time flow in your UI)
await fetch(base + "/players/" + auth.player_id + "/social/friends/request",
  { method: "POST", headers, body: JSON.stringify({ target_id: otherPlayerId }) });
// ...the other client accepts via its own /social/friends/accept call.

// The friends leaderboard: friends list + standings, client-composed
const friends = await (await fetch(
  base + "/players/" + auth.player_id + "/social/friends", { headers })).json();

const rows = [];
for (const f of friends) {
  const s = await crux.getPlayerStanding("global", f.friend_id);
  if (s) rows.push({ id: f.friend_id, rank: s.rank, score: s.score });
}
rows.sort((a, b) => a.rank - b.rank);

Friends lists are small (tens, not thousands), so composing the slice client-side from standings is the simple, correct first version; cache it for the session. The block API matters more than it looks for leaderboards: the same graph that powers the friendly rivalry also lets a player remove a harasser from every social surface at once.

Step 4: the part that decides if your leaderboard survives

Everything so far has a flaw you should refuse to ship to production: the client submits the score, and clients lie. Any player who opens the network tab can post 999999999. The fix is structural, not clever: scores must come from something you trust, and the client is never that thing.

Crux draws this line with two credential types. The client API key from Kata #1 identifies players; a server token (minted in the dashboard) identifies your trusted code, a game server, or a tiny score-validation service. The production pattern:

// On YOUR server (never in the client build):
const cruxServer = GSBClient.forServer(CRUX_API_URL, "proj_...", "env_...", "gsb_servertoken_...");

// Client sends the match result to your server; your server validates and submits:
function plausible(result) {
  // your sanity rules: score vs match duration, max points/minute, replay hash...
  return result.score <= result.duration_sec * MAX_SCORE_PER_SEC;
}

if (plausible(result)) {
  await cruxServer.submitScore("global", result.player_id, result.score);
}

If your game has authoritative dedicated servers, this is free: the server already knows the real result, so it submits directly and the client never holds a write path at all (that server-side world is Kata #3's subject). If your game is client-authoritative (most mobile and casual games), the validation service plus plausibility rules will not stop a determined forger completely, but it stops the network-tab tourist, which is 95% of the problem, and the ban API handles the rest when you spot the impossible scores.

Step 5: prove it worked

Acceptance test: submit scores from three anonymous players, watch all three surfaces update (top, standing, around), befriend two of them and render the friends slice, then try to submit an absurd score through the client path you just closed and watch it bounce. The dashboard shows the board live as you go.

Production notes before you ship it

Names, not IDs. Players want to beat "VoidReaper", not plr_8841. Store a display-name in a profile document (Kata #1) and resolve IDs to names when rendering rows; keep the leaderboard itself ID-keyed so renames never corrupt history.

Seasons are new boards, not resets. When season two starts, create global_s2 and point the UI at it. Old boards stay queryable for "hall of fame" screens, and you never write a migration. Boards are cheap; archaeology is not.

Decide the tie story. Same score, who ranks higher? Earliest submission is the common answer and the one players accept as fair; whatever you choose, make it deliberate, because the first tie at rank #1 will generate a support ticket either way.

Watch the top like a product surface. An impossible score at #1 costs real retention, honest players quietly stop competing. Pair the plausibility checks with a weekly glance at the top 100 and use the ban API without sentimentality; a leaderboard is only as motivating as it is believable.

Kata variations to try

  • The weekly-sprint variation: a weekly board the UI swaps every Monday; short ladders give mid-skill players a winnable race and your game a weekly appointment.
  • The per-map variation: one board per map or track (speedrun pattern); the around-me window matters even more when every map has its own ladder.
  • The guild variation: submit a shared team score under a team ID from your trusted path, and the same three surfaces become a clan war screen.

Kata FAQ

How big can a board get before reads slow down? The top, standing, and around-me calls are rank lookups, not table scans, so they stay flat as the board grows; this is the part you would otherwise be hand-tuning in a sorted-set store at 2 a.m.

Lower-is-better scores (lap times)? Submit the time as a negative number or as maxValue - time, the classic encodings, and decode in the UI. Name the board accordingly so future-you remembers.

Do I need the server-token path for a jam game or prototype? No, client submits are fine while the stakes are low. The kata's point is that the trusted path exists and is a one-line switch later, so the prototype's leaderboard code survives contact with production instead of being rewritten.

What you just avoided building

The self-built version of this kata is a sorted-set store, a ranking service that stays fast at a million rows, a social graph with request/accept/block states, rate limiting, and the moderation tooling for the first forged score. It is also the feature most teams rebuild three times as the game grows, which is why "leaderboards" is the single most common reason teams adopt a backend platform at all (we compared how the platforms handle it in the PlayFab vs Crux comparison). The kata version took 45 minutes and four read calls.

Run this kata: create a free Crux project, make a board named global, and paste the snippets into a Node 18+ script. The Unity, Godot, and Roblox SDKs expose the same calls. Next: Kata #3, a server browser with registry and heartbeats, where the trusted-server side of this kata becomes the whole story.