Skip to main content
Version: 2.0.0

Make a turn-based game with turn timeouts

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 game of tic-tac-toe where each player has 30 seconds to make a move, and loses the game if they run out of time. The server checks every move, so players can't cheat. You'll use a reducer to check and apply moves, and a schedule table to end turns that take too long.

The same pattern works for any turn-based game, such as a card game or a strategy game: replace the board and the rules with your own.

How it works​

  • A private game table holds each game: its players, its board, whose turn it is, and when that turn times out.
  • A make_move reducer checks that a move is legal, applies it, then gives the turn to the other player.
  • A private turn_timer schedule table holds one timer per game in progress. When the current turn's deadline passes, it calls the turn_timeout reducer.
  • Each move replaces the game's timer with a new one. If the timer fires, the player whose turn it is forfeits.
  • A my_games view returns the caller's games. Clients subscribe to it, and show the board and a countdown.

Define the tables​

Add two tables and a view:

  • game has one row per game. It's private: players read their games through the my_games view. It has an index on each player column. The view combines the two columns with or, and SpacetimeDB can't use an index for an or, so it reads every game. That's fine while the table is small. If you expect many games, split the view in two, one for each player column, as the chat guide does for DMs: each view then reads only the caller's games through its index.
  • turn_timer is the schedule table. It's private too: clients read the deadline from their games instead. Its game_id column is unique, so a game never has more than one timer.
  • my_games is a public view that returns the caller's games, as X or as O. Keeping tables private and exposing what clients need through views is the recommended practice: see Hide information from players.
import { ScheduleAt, Timestamp, type Identity } from 'spacetimedb';
import { schema, table, t, SenderError, type InferSchema, type ReducerCtx } from 'spacetimedb/server';

// How long each player has to make a move: 30 seconds.
const TURN_TIME_LIMIT_MICROS = 30_000_000n;

const game = table(
  { name: 'game' },
  {
    id: t.u64().primaryKey().autoInc(),
    playerX: t.identity().index('btree'),
    playerO: t.identity().index('btree'),
    // The 9 cells, row by row: 'X', 'O', or '-' when empty.
    board: t.string(),
    // The player whose turn it is.
    turn: t.identity(),
    // When the current turn times out.
    turnDeadline: t.timestamp(),
    finished: t.bool(),
    // The winner, or undefined while the game is in progress or after a draw.
    winner: t.option(t.identity()),
  }
);

const turnTimer = table(
  { name: 'turn_timer' },
  {
    scheduledId: t.u64().primaryKey().autoInc(),
    scheduledAt: t.scheduleAt(),
    gameId: t.u64().unique(),
  }
);

const spacetimedb = schema({ game, turnTimer });
export default spacetimedb;

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

export const myGames = spacetimedb.view(
  { name: 'my_games', public: true },
  t.array(game.rowType),
  ctx => ctx.from.game.where(g => g.playerX.eq(ctx.sender).or(g.playerO.eq(ctx.sender)))
);

Start a game​

Add two functions:

  • set_turn_timer replaces the game's timer with one that fires once, at the turn's deadline. Starting a game and every move both use it.
  • start_game creates a game between two players, gives the first turn to X, and starts the timer.

start_game isn't a reducer, so clients can't call it. Call it from your matchmaking when two players are matched. See the matchmaking queue guide.

// Replaces the game's timer with one that fires at `deadline`.
function setTurnTimer(ctx: Ctx, gameId: bigint, deadline: Timestamp) {
  ctx.db.turnTimer.gameId.delete(gameId);
  ctx.db.turnTimer.insert({
    scheduledId: 0n,
    scheduledAt: ScheduleAt.time(deadline.microsSinceUnixEpoch),
    gameId,
  });
}

/** Starts a game between two players. Call it from your matchmaking. */
function startGame(ctx: Ctx, playerX: Identity, playerO: Identity) {
  const deadline = new Timestamp(ctx.timestamp.microsSinceUnixEpoch + TURN_TIME_LIMIT_MICROS);
  const row = ctx.db.game.insert({
    id: 0n,
    playerX,
    playerO,
    board: '-'.repeat(9),
    turn: playerX,
    turnDeadline: deadline,
    finished: false,
    winner: undefined,
  });
  setTurnTimer(ctx, row.id, deadline);
}

Make a move​

Add a make_move reducer. Clients can call it with any arguments, so it checks everything before it changes the board:

  • The game exists and isn't over.
  • It's the caller's turn. Players who aren't in the game never have the turn, so this also rejects them.
  • The cell is on the board and empty.

It then places the caller's mark and looks for three in a row. If the caller won, or the board is full, the game is over and its timer is deleted. Otherwise, the turn goes to the other player, with a new deadline and a new timer.

// The cells of each row, column and diagonal.
const LINES = [
  [0, 1, 2],
  [3, 4, 5],
  [6, 7, 8],
  [0, 3, 6],
  [1, 4, 7],
  [2, 5, 8],
  [0, 4, 8],
  [2, 4, 6],
];

function opponent(row: { playerX: Identity; playerO: Identity }, player: Identity): Identity {
  return player.isEqual(row.playerX) ? row.playerO : row.playerX;
}

export const makeMove = spacetimedb.reducer(
  { gameId: t.u64(), cell: t.u8() },
  (ctx, { gameId, cell }) => {
    const row = ctx.db.game.id.find(gameId);
    if (!row) {
      throw new SenderError('game not found');
    }
    if (row.finished) {
      throw new SenderError('the game is over');
    }
    if (!row.turn.isEqual(ctx.sender)) {
      throw new SenderError('not your turn');
    }
    if (cell >= 9 || row.board[cell] !== '-') {
      throw new SenderError('invalid cell');
    }

    const mark = ctx.sender.isEqual(row.playerX) ? 'X' : 'O';
    const board = row.board.slice(0, cell) + mark + row.board.slice(cell + 1);
    const won = LINES.some(line => line.every(i => board[i] === mark));
    const full = !board.includes('-');

    const updated = { ...row, board };
    if (won || full) {
      updated.finished = true;
      updated.winner = won ? ctx.sender : undefined;
      ctx.db.turnTimer.gameId.delete(gameId);
    } else {
      updated.turn = opponent(row, ctx.sender);
      updated.turnDeadline = new Timestamp(ctx.timestamp.microsSinceUnixEpoch + TURN_TIME_LIMIT_MICROS);
      setTurnTimer(ctx, gameId, updated.turnDeadline);
    }
    ctx.db.game.id.update(updated);
  }
);

A reducer runs as a single transaction, so a move and a timeout can never both happen, even when the move arrives at the last moment:

  • If the move commits first, it replaces the timer. SpacetimeDB never calls turn_timeout for a timer that was deleted.
  • If the timeout commits first, the game is over, and the move is rejected.

End turns that take too long​

Add the turn_timeout reducer. SpacetimeDB calls it when a game's timer fires, and the player whose turn it is forfeits: the other player wins.

Clients can't call turn_timeout themselves: scheduled reducers are private, so only the scheduler, the database owner and team collaborators can run them. That's what stops a player from ending their opponent's turn early.

export const turnTimeout = spacetimedb.reducer(
  { onSchedule: turnTimer },
  { arg: turnTimer.rowType },
  (ctx, { arg }) => {
    const row = ctx.db.game.id.find(arg.gameId);
    if (!row || row.finished) {
      return;
    }
    // The player who ran out of time forfeits.
    ctx.db.game.id.update({
      ...row,
      finished: true,
      winner: opponent(row, row.turn),
    });
  }
);

A timer that fires once is deleted after it runs, so finished games don't leave timers behind.

You don't need to handle disconnects either: a player who closes the game runs out of time, and the other player wins.

Play from the client​

On the client, subscribe to my_games, and call make_move when the player clicks a cell.

Show the countdown from turn_deadline, and refresh it every frame in your game. The client's clock can be slightly off from the server's, so the countdown is only an estimate: the server decides when the turn ends.

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

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

function showGame(game: Game) {
  console.log([game.board.slice(0, 3), game.board.slice(3, 6), game.board.slice(6)].join('\n'));
  const me = conn.identity!;
  if (game.finished) {
    console.log(!game.winner ? 'Draw' : game.winner.isEqual(me) ? 'You won' : 'You lost');
  } else if (game.turn.isEqual(me)) {
    const secondsLeft = Math.ceil((game.turnDeadline.toDate().getTime() - Date.now()) / 1000);
    console.log(`Your turn: ${Math.max(secondsLeft, 0)} seconds left`);
  } else {
    console.log('Waiting for your opponent');
  }
}

conn.db.myGames.onInsert((_ctx, game) => showGame(game));
conn.db.myGames.onUpdate((_ctx, _old, game) => showGame(game));

// Call this when the player clicks a cell, numbered 0 to 8.
function play(gameId: bigint, cell: number) {
  conn.reducers.makeMove({ gameId, cell }).catch(console.error);
}

What's next?​

You now have a turn-based game where the server enforces both the rules and the time limit:

  • make_move rejects any move that isn't legal, then gives the turn to the other player.
  • Each game has one timer that calls turn_timeout when a turn takes too long, and each move replaces it.
  • Because each reducer is a transaction, a move and a timeout can never both happen.

To handle timeouts differently:

  • Skip the turn instead of forfeiting. In turn_timeout, give the turn to the other player and call set_turn_timer with a new deadline, as make_move does. To end games where both players are gone, count each player's missed turns and end the game after a few.
  • Use a chess clock. Give each player a time budget for the whole game, rather than a fixed time per turn. Store each player's remaining time in game, subtract the time each move took, and set the timer to the current player's remaining time.

In tic-tac-toe, players see the whole board, so my_games returns whole games. For games where players must not see everything, such as card games, see Hide information from players.

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

Next, you can learn to only sync what's near the player.