Skip to main content
Version: 2.0.0

Run a fixed-rate game tick

Prerequisites

You need a module you can publish. If you don't have one yet, follow the quickstart for your language first.

In this guide, you'll run your game's server logic at a fixed rate: a reducer that SpacetimeDB calls 20 times per second, which moves every entity in the world. You'll use a schedule table to call the reducer and the init lifecycle reducer to start it.

How it works​

  • A tick_timer schedule table holds one row that tells SpacetimeDB to call the tick reducer every 50 milliseconds.
  • The init reducer inserts that row when the database is created.
  • The tick reducer measures how much time has passed since the last tick, then moves each entity by its velocity.

Define the tables​

Add three tables:

  • tick_timer is the schedule table. Its scheduled_at column says when to call tick.
  • tick_state is a private table with a single row that stores when the last tick ran.
  • entity holds the things your tick moves. It's public, so clients can subscribe to it.
import { ScheduleAt } from 'spacetimedb';
import { schema, table, t, type InferSchema, type ReducerCtx } from 'spacetimedb/server';

// 50 ms between ticks: 20 ticks per second.
const TICK_INTERVAL_MICROS = 50_000n;

const tickTimer = table(
  { name: 'tick_timer' },
  {
    scheduledId: t.u64().primaryKey().autoInc(),
    scheduledAt: t.scheduleAt(),
  }
);

const tickState = table(
  { name: 'tick_state' },
  {
    id: t.u8().primaryKey(),
    lastTick: t.timestamp(),
  }
);

const entity = table(
  { name: 'entity', public: true },
  {
    id: t.u64().primaryKey().autoInc(),
    x: t.f32(),
    y: t.f32(),
    velocityX: t.f32(),
    velocityY: t.f32(),
  }
);

const spacetimedb = schema({ tickTimer, tickState, entity });
export default spacetimedb;

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

Start the tick​

Insert the timer row and the tick state from the init reducer. SpacetimeDB then calls tick every 50 milliseconds, until the row is deleted.

export const init = spacetimedb.init(ctx => {
  ctx.db.tickState.insert({ id: 0, lastTick: ctx.timestamp });
  ctx.db.tickTimer.insert({
    scheduledId: 0n,
    scheduledAt: ScheduleAt.interval(TICK_INTERVAL_MICROS),
  });
});
note

init only runs when the database is created. Publishing new code to an existing database doesn't run it again, so the tick won't start there. To start it, either republish with --delete-data, which erases all data, or insert the timer row from another reducer. Before inserting, check that tick_timer is empty: with two timer rows, tick would run twice as often.

Write the tick reducer​

The tick reducer does two things:

  1. It measures the time since the last tick, rather than assuming exactly 50 milliseconds. This is standard practice for game loops: the time between two ticks varies slightly, and moving by the measured time keeps every entity at a steady speed.
  2. It moves each entity by its velocity multiplied by that time. This is where your game logic goes.

Clients can't call tick themselves: scheduled reducers are private, so only the scheduler, the database owner and team collaborators can run them.

export const tick = spacetimedb.reducer(
  { onSchedule: tickTimer },
  { arg: tickTimer.rowType },
  ctx => {
    const state = ctx.db.tickState.id.find(0);
    if (!state) {
      throw new Error('tick state missing');
    }
    // Seconds since the last tick.
    const dt = Number(ctx.timestamp.since(state.lastTick).micros) / 1_000_000;
    ctx.db.tickState.id.update({ ...state, lastTick: ctx.timestamp });

    for (const e of ctx.db.entity.iter()) {
      if (e.velocityX === 0 && e.velocityY === 0) {
        continue;
      }
      ctx.db.entity.id.update({
        ...e,
        x: e.x + e.velocityX * dt,
        y: e.y + e.velocityY * dt,
      });
    }
  }
);

Stop the tick​

The tick runs until its timer row is deleted. To stop it, for example when a match ends, delete the row from your game logic:

function stopTick(ctx: Ctx) {
  for (const timer of ctx.db.tickTimer.iter()) {
    ctx.db.tickTimer.scheduledId.delete(timer.scheduledId);
  }
}

To start it again, insert a new timer row as in init, after checking that none exists.

Keep the tick fast​

  • Finish well within the interval. A tick that takes longer than 50 milliseconds delays everything else in the database, and the ticks it overlaps are skipped. Keep expensive work, like pathfinding or AI decisions, out of the tick, or spread it across several ticks.
  • Only write what changed. Every update to a public table is sent to the clients subscribed to it, so a tick that rewrites every row sends every row to every client 20 times per second. Skipping entities that aren't moving, as the reducer above does, avoids that. To also limit which entities each client receives, see Only sync what's near the player.

What's next?​

You now have server logic that runs 20 times per second:

  • A schedule table calls the tick reducer at a fixed interval, starting from init.
  • The reducer moves entities by the measured time since the last tick, so they move at a steady speed.
  • Deleting the timer row stops the tick.

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

Next, you can build a matchmaking queue.