Skip to main content
Version: 2.0.0

Build a matchmaking queue

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 matchmaking queue: players ask to find a match, wait in a queue, and are put into a 4-player match as soon as enough players are waiting. You'll use reducers to join and leave the queue, the client_disconnected lifecycle reducer to clean up after players who leave, and a per-player view to tell each player about their match.

How it works​

  • A private queue_entry table holds the players who are waiting.
  • A join_queue reducer adds the caller to the queue, then creates a match if enough players are waiting.
  • A leave_queue reducer and the client_disconnected reducer remove players from the queue.
  • Private game_match and match_player tables record each match and its players.
  • A public my_match view returns the players in the caller's match, and nothing else.

Define the tables​

Add three tables. All of them are private: players only learn about their own match, through the view.

  • queue_entry has one row per waiting player, with the time they joined.
  • game_match has one row per match.
  • match_player has one row per player in a match. Its primary key is the player's identity, so a player can only be in one match at a time. It has an index on match_id, to find every player in a match.
import { schema, table, t, SenderError, type InferSchema, type ReducerCtx } from 'spacetimedb/server';

// Number of players in each match.
const MATCH_SIZE = 4;

const queueEntry = table(
  { name: 'queue_entry' },
  {
    identity: t.identity().primaryKey(),
    joinedAt: t.timestamp(),
  }
);

const gameMatch = table(
  { name: 'game_match' },
  {
    id: t.u64().primaryKey().autoInc(),
    createdAt: t.timestamp(),
  }
);

const matchPlayer = table(
  { name: 'match_player' },
  {
    identity: t.identity().primaryKey(),
    matchId: t.u64().index('btree'),
  }
);

const spacetimedb = schema({ queueEntry, gameMatch, matchPlayer });
export default spacetimedb;

type Ctx = ReducerCtx<InferSchema<typeof spacetimedb>>;

Join the queue and create matches​

Add a join_queue reducer. It refuses players who are already waiting or already in a match, adds the caller to the queue, then checks whether enough players are waiting to start a match.

If they are, the reducer creates a match with the players who have waited longest and removes them from the queue. A reducer runs as a single transaction, so two players joining at the same moment can never be put into two matches, or into a match with a player who just left.

export const joinQueue = spacetimedb.reducer(ctx => {
  if (ctx.db.queueEntry.identity.find(ctx.sender)) {
    throw new SenderError('already in the queue');
  }
  if (ctx.db.matchPlayer.identity.find(ctx.sender)) {
    throw new SenderError('already in a match');
  }
  ctx.db.queueEntry.insert({ identity: ctx.sender, joinedAt: ctx.timestamp });
  tryCreateMatch(ctx);
});

function tryCreateMatch(ctx: Ctx) {
  const waiting = Array.from(ctx.db.queueEntry.iter());
  if (waiting.length < MATCH_SIZE) {
    return;
  }
  // Match the players who have waited longest.
  waiting.sort((a, b) => {
    const aTime = a.joinedAt.microsSinceUnixEpoch;
    const bTime = b.joinedAt.microsSinceUnixEpoch;
    return aTime < bTime ? -1 : aTime > bTime ? 1 : 0;
  });

  const game = ctx.db.gameMatch.insert({ id: 0n, createdAt: ctx.timestamp });
  for (const entry of waiting.slice(0, MATCH_SIZE)) {
    ctx.db.queueEntry.identity.delete(entry.identity);
    ctx.db.matchPlayer.insert({ identity: entry.identity, matchId: game.id });
  }
}

Because a match is created as soon as enough players are waiting, the queue never holds more than 3 players at a time, so reading the whole queue in join_queue stays cheap.

Leave the queue​

Players should be able to cancel their search. Add a leave_queue reducer, and remove players from the queue when they disconnect, so nobody is matched with a player who already closed the game.

export const leaveQueue = spacetimedb.reducer(ctx => {
  ctx.db.queueEntry.identity.delete(ctx.sender);
});

export const onDisconnect = spacetimedb.clientDisconnected(ctx => {
  ctx.db.queueEntry.identity.delete(ctx.sender);
});

Tell players about their match​

Add a public my_match view. It looks up the caller's row in match_player, then returns every player in the same match. Players who aren't in a match get an empty result.

Unlike an anonymous view, this view depends on who is asking, so SpacetimeDB computes it separately for each subscribed player. That's what keeps one player from seeing another player's match.

export const myMatch = spacetimedb.view(
  { name: 'my_match', public: true },
  t.array(matchPlayer.rowType),
  ctx => {
    const me = ctx.db.matchPlayer.identity.find(ctx.sender);
    if (!me) {
      return [];
    }
    return Array.from(ctx.db.matchPlayer.matchId.filter(me.matchId));
  }
);

When a match ends, delete its match_player rows so its players can join the queue again.

Find a match from the client​

On the client, subscribe to my_match, and call join_queue when the player asks to find a match. When the match is created, the client receives one row for each player in it, including the player's own row. Use the player's own row as the signal that a match was found.

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

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

// Call this when the player presses "Find match".
function findMatch() {
  conn.reducers.joinQueue({}).catch(console.error);
}

// Call this when the player cancels the search.
function cancelSearch() {
  conn.reducers.leaveQueue({}).catch(console.error);
}

// The player's own row arrives once, when their match is created.
conn.db.myMatch.onInsert((_ctx, player) => {
  if (!player.identity.isEqual(conn.identity!)) {
    return;
  }
  const players = Array.from(conn.db.myMatch.iter());
  console.log(`Match ${player.matchId} found with ${players.length} players`);
});

What's next?​

You now have a queue that groups waiting players into 4-player matches:

  • join_queue adds players to the queue and creates a match as soon as enough of them are waiting, all in one transaction.
  • leave_queue and client_disconnected keep players who left out of new matches.
  • The per-player my_match view tells each player about their own match, and only theirs.

To learn more about the features used here, see Reducers, Lifecycle Reducers and Views.

Next, you can make a turn-based game with turn timeouts.