Skip to main content
Version: 2.0.0

Only sync what's near the player

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 make each client receive only the players near it, rather than every player in the world. You'll split the world into square chunks, store the chunk each player is in, and have each client subscribe to the chunks around its player, moving that subscription as the player moves.

This doesn't hide players

Clients choose what they subscribe to, and the player table is public, so a modified client can subscribe to every chunk and see every player. This guide reduces what honest clients download. If players must not be able to see where other players are, for example hidden units in a strategy game, the server has to decide what each client can see: see Hide information from players.

How it works​

  • The world is a grid of square chunks, each 100 units wide.
  • A public player table holds each player's position and the chunk they're in, with an index on the chunk.
  • A move_player reducer updates the player's position and chunk.
  • Each client subscribes to its own row, and to the players in its chunk and the 8 chunks around it.
  • When the player enters a new chunk, the client subscribes to the new chunks, then drops its old subscription.

Define the table​

Add a player table with one row per online player. chunk is a single number that identifies a chunk: chunk_y * 100 + chunk_x. Its index lets SpacetimeDB find the players in a chunk directly, without scanning the whole table.

The world here is 10,000 units wide, or 100 chunks per side. Use the size of your own world.

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

// The world is a square, split into 100 x 100 square chunks.
const WORLD_SIZE = 10_000;
const CHUNK_SIZE = 100;
const CHUNKS_PER_SIDE = 100;

const player = table(
  { name: 'player', public: true },
  {
    identity: t.identity().primaryKey(),
    x: t.f32(),
    y: t.f32(),
    // The chunk the player is in. Updated whenever the player moves.
    chunk: t.u32().index('btree'),
  }
);

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

Track each player's chunk​

Add a chunk_at function that returns the chunk containing a position, and keep chunk up to date wherever a player's position changes:

  • The client_connected lifecycle reducer adds the player in the middle of the world. If the player already has a row, for example because they're connected from two tabs, it keeps it.
  • The client_disconnected reducer removes the player, so clients only see players who are online.
  • The move_player reducer rejects positions outside the world, then stores the new position and its chunk.

move_player accepts any position in the world. In your game, also check that the player didn't move further than they can.

// Returns the chunk that contains the position (x, y).
function chunkAt(x: number, y: number): number {
  const chunkX = Math.floor(x / CHUNK_SIZE);
  const chunkY = Math.floor(y / CHUNK_SIZE);
  return chunkY * CHUNKS_PER_SIDE + chunkX;
}

export const onConnect = spacetimedb.clientConnected(ctx => {
  if (ctx.db.player.identity.find(ctx.sender)) {
    return;
  }
  // New players appear in the middle of the world.
  const x = WORLD_SIZE / 2;
  const y = WORLD_SIZE / 2;
  ctx.db.player.insert({ identity: ctx.sender, x, y, chunk: chunkAt(x, y) });
});

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

export const movePlayer = spacetimedb.reducer({ x: t.f32(), y: t.f32() }, (ctx, { x, y }) => {
  if (!(x >= 0 && x < WORLD_SIZE && y >= 0 && y < WORLD_SIZE)) {
    throw new SenderError('position is outside the world');
  }
  const row = ctx.db.player.identity.find(ctx.sender);
  if (!row) {
    throw new SenderError('player not found');
  }
  ctx.db.player.identity.update({ ...row, x, y, chunk: chunkAt(x, y) });
});

Subscribe to nearby players​

The client keeps two subscriptions:

  • Its own row, for its whole session. That's how it learns which chunk the player is in.
  • The nearby chunks: one query per chunk, for the player's chunk and the chunks around it. Each query keeps only the rows whose chunk equals one value, which is the cheapest kind of query to keep up to date. When a player moves, SpacetimeDB only checks the subscriptions to that player's old and new chunks, however many clients are subscribed to other chunks.

When the player's own row shows they entered a new chunk, the client subscribes to the chunks around it. It drops the old subscription only once the new one is applied. Players in both areas then stay in the client cache, rather than being removed and added back.

The client cache now holds only the nearby players. Draw them from it each frame. When a player leaves the area, the client receives a delete for their row, so remove them from the screen in your delete callback.

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

// Must match the module.
const CHUNKS_PER_SIDE = 100;

// Returns the chunk and the chunks around it, up to 9 of them.
function chunksAround(chunk: number): number[] {
  const chunkX = chunk % CHUNKS_PER_SIDE;
  const chunkY = Math.floor(chunk / CHUNKS_PER_SIDE);
  const chunks = [];
  for (let y = chunkY - 1; y <= chunkY + 1; y++) {
    for (let x = chunkX - 1; x <= chunkX + 1; x++) {
      if (x >= 0 && x < CHUNKS_PER_SIDE && y >= 0 && y < CHUNKS_PER_SIDE) {
        chunks.push(y * CHUNKS_PER_SIDE + x);
      }
    }
  }
  return chunks;
}

const conn = DbConnection.builder()
  .withUri('ws://localhost:3000')
  .withDatabaseName('my-game')
  .onConnect((ctx, identity) => {
    // The player's own row tells us which chunk they're in.
    ctx.subscriptionBuilder().subscribe([tables.player.where(p => p.identity.eq(identity))]);
  })
  .build();

let nearby: SubscriptionHandle | undefined;
let nearbyChunk: number | undefined;

function subscribeAround(chunk: number) {
  if (chunk === nearbyChunk) {
    return;
  }
  nearbyChunk = chunk;
  // Subscribe to the new chunks first, and only then drop the old ones,
  // so players in both stay in the client cache.
  const previous = nearby;
  nearby = conn
    .subscriptionBuilder()
    .onApplied(() => previous?.unsubscribe())
    .subscribe(chunksAround(chunk).map(c => tables.player.where(p => p.chunk.eq(c))));
}

// Move the subscription when the player's own row arrives, or changes chunk.
conn.db.player.onInsert((_ctx, player) => {
  if (player.identity.isEqual(conn.identity!)) {
    subscribeAround(player.chunk);
  }
});
conn.db.player.onUpdate((_ctx, _old, player) => {
  if (player.identity.isEqual(conn.identity!)) {
    subscribeAround(player.chunk);
  }
});

// Call this when the player moves.
function move(x: number, y: number) {
  conn.reducers.movePlayer({ x, y }).catch(console.error);
}

// Each frame, draw the nearby players:
// for (const player of conn.db.player.iter()) { ... }

What's next?​

Each client now receives only the players around it:

  • Every player row stores its chunk, in an indexed column that move_player keeps up to date.
  • Clients subscribe to the 9 chunks around their player, with one query per chunk.
  • When the player changes chunk, the client subscribes to the new chunks before dropping the old ones.

To adapt it to your game:

  • Pick the chunk size from your view distance. The 3×3 area only covers the view distance in every direction if chunks are at least as wide as it. Smaller chunks send fewer players, but clients change subscriptions more often.
  • Sync other things the same way. Give NPCs, items or projectiles a chunk column with an index, and add a query per chunk for their tables to the same subscription.
  • Update chunks in your game tick. If a fixed-rate game tick moves your entities, recompute their chunk there too.

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

Next, you can hide information from players.