Skip to main content
Version: 2.0.0

How to make a top 100 leaderboard

Prerequisites

You need a module and a client connected to it. If you don't have them yet, follow the quickstart for your language first.

In this guide, you'll build a leaderboard that shows the 100 best scores in your game and updates live on every client. You'll use an index to read scores, an anonymous view to pick the top 100, and a subscription to keep each client up to date.

How it works​

  • A private player_score table stores each player's best score, with an index on the score.
  • A submit_score reducer records a new score.
  • A public top_100 view reads every score through the index and returns the 100 highest.
  • Clients subscribe to top_100, never to player_score itself.

The view is anonymous: it returns the same rows no matter who asks. SpacetimeDB computes it once and shares the result with every subscribed client, instead of computing it once per client.

Define the table​

Add a table that holds one row per player, with an index on the score column. The view needs this index: views can't scan a whole table with iter(), so they read rows through indexes instead.

import { schema, table, t, Range } from 'spacetimedb/server';

const playerScore = table(
  { name: 'player_score' },
  {
    identity: t.identity().primaryKey(),
    name: t.string(),
    score: t.u64().index('btree'),
  }
);

const spacetimedb = schema({ playerScore });
export default spacetimedb;

The table is private, so clients can't subscribe to it directly. They only see the 100 rows the view returns, however many players you have.

tip

Keep scores in their own table. The view re-runs every time a row in player_score changes. If the score lived on a row that changes often, like a player's position, the leaderboard would be recomputed on every movement.

Submit a score​

Add a reducer that records a score for the player who calls it. It only keeps a player's best score: a lower score than the one already stored is ignored.

export const submitScore = spacetimedb.reducer(
  { name: t.string(), score: t.u64() },
  (ctx, { name, score }) => {
    const existing = ctx.db.playerScore.identity.find(ctx.sender);
    if (!existing) {
      ctx.db.playerScore.insert({ identity: ctx.sender, name, score });
    } else if (score > existing.score) {
      // Keep the player's best score.
      ctx.db.playerScore.identity.update({ ...existing, name, score });
    }
  }
);

Define the top 100 view​

Add a public anonymous view named top_100. It reads every score with a range query on the index, starting at 0 and with no upper bound. Then it sorts the rows from highest to lowest score and keeps the first 100.

const LEADERBOARD_SIZE = 100;

export const top100 = spacetimedb.anonymousView(
  { name: 'top_100', public: true },
  t.array(playerScore.rowType),
  ctx => {
    const allScores = new Range({ tag: 'included', value: 0n }, { tag: 'unbounded' });
    return Array.from(ctx.db.playerScore.score.filter(allScores))
      .sort((a, b) => (a.score < b.score ? 1 : a.score > b.score ? -1 : 0))
      .slice(0, LEADERBOARD_SIZE);
  }
);
warning

Always sort inside the view, even though the rows come from an index. When a reducer changes a score, the view re-runs inside that reducer's transaction, and the rows that reducer just wrote can come out of the index before all the others. If you relied on the index order, a new high score could be left off the leaderboard.

What this view costs​

The range query covers every score, so the view reads the whole player_score table each time it runs. SpacetimeDB re-runs it whenever a row in player_score changes. Because the view is anonymous, that happens once per change, not once per subscribed client.

Because the view reads and sorts the whole table every time a score changes, its cost grows with your player count. That's fine for small and medium games. If you expect a very large player base, measure how long score submissions take as the table grows. See Performance Considerations for more on what makes a view expensive.

Subscribe from the client​

On the client, subscribe to top_100 and read the rows from the client cache. The client cache doesn't keep the order the view returned its rows in, so sort them again before you display them.

import { DbConnection, tables } from './module_bindings';

const conn = DbConnection.builder()
  .withUri('ws://localhost:3000')
  .withDatabaseName('my-game')
  .onConnect(ctx => {
    ctx
      .subscriptionBuilder()
      .onApplied(() => renderLeaderboard())
      .subscribe([tables.top100]);
  })
  .build();

// The client cache doesn't keep the view's order, so sort the rows here.
function getLeaderboard() {
  return Array.from(conn.db.top100.iter()).sort((a, b) =>
    a.score < b.score ? 1 : a.score > b.score ? -1 : 0
  );
}

function renderLeaderboard() {
  getLeaderboard().forEach((row, i) => {
    console.log(`${i + 1}. ${row.name} - ${row.score}`);
  });
}

// Re-render when a score enters or leaves the top 100, or changes in it.
conn.db.top100.onInsert(() => renderLeaderboard());
conn.db.top100.onDelete(() => renderLeaderboard());
conn.db.top100.onUpdate(() => renderLeaderboard());

// Call this when a game ends to submit the player's score.
function onGameOver(name: string, score: bigint) {
  conn.reducers.submitScore({ name, score }).catch(console.error);
}

onInsert and onDelete run once for each row that changes. When a new score enters the top 100, the client receives one insert for the new row and one delete for the score that dropped out, so the leaderboard is re-rendered twice. If rendering is expensive, re-render at most once per frame instead.

In TypeScript, the rows of top_100 keep the primary key of player_score. So when a player already in the top 100 improves their score, the client receives an update for their row instead of a delete and an insert, and onUpdate re-renders the leaderboard.

What's next?​

You now have a leaderboard that stays live on every client:

  • An index on score lets the view read the scores.
  • An anonymous view sorts them and keeps the top 100, computed once for all clients.
  • Clients subscribe to the view and sort the rows again before displaying them.

To learn more about the features used here, see Indexes, Views and Subscriptions.

Next, you can reset the leaderboard each season.