Skip to main content
Version: 2.0.0

Chat rooms and private DMs

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. This guide uses a minimal player table with a name, and doesn't cover how players get their row. To let players pick a unique name and block offensive ones, see Keep usernames unique and block offensive ones: the code here works the same with its player table.

For a single chat that everyone can read, see the chat app tutorial instead.

In this guide, you'll let players create and join chat rooms, and send each other direct messages (DMs). Each player only receives the messages of the rooms they're in, and their own DMs. You'll store messages in private tables, and use views built with the query builder to give each player their own messages.

How it works​

  • A public player table holds each player's name. How a player gets their row is up to the rest of your module.
  • A public chat_room table lists the rooms, so that players can find rooms to join.
  • A private room_member table has one row for each player in each room.
  • A private message table holds the messages of every room, and a private direct_message table holds every DM.
  • Reducers create, join and leave rooms, and send messages to a room or to a player.
  • Views return the rooms the caller is in, the messages of those rooms, and the DMs the caller sent and received.
  • Clients subscribe to the room list and to the views, and sort the messages themselves.

The room_member, message and direct_message tables are private, so clients can't read who is in which room, or anyone else's messages. They only see the rows the views return, and each view only returns the caller's own rows.

Define the tables​

Add the five tables. The four new ones each have an id primary key that's assigned automatically, in the order rows are created. room_member has an index on room_id and one on member, message has an index on room_id, and direct_message has an index on sender and one on recipient. These indexes let the reducers and views find each player's rows without reading the whole table.

import type { Identity } from 'spacetimedb';
import { schema, table, t, SenderError, type InferSchema, type ReducerCtx } from 'spacetimedb/server';

const player = table(
  { name: 'player', public: true },
  {
    identity: t.identity().primaryKey(),
    name: t.string(),
  }
);

const chatRoom = table(
  { name: 'chat_room', public: true },
  {
    id: t.u64().primaryKey().autoInc(),
    name: t.string(),
  }
);

const roomMember = table(
  { name: 'room_member' },
  {
    id: t.u64().primaryKey().autoInc(),
    roomId: t.u64().index('btree'),
    member: t.identity().index('btree'),
  }
);

const message = table(
  { name: 'message' },
  {
    id: t.u64().primaryKey().autoInc(),
    roomId: t.u64().index('btree'),
    sender: t.identity(),
    text: t.string(),
    sent: t.timestamp(),
  }
);

const directMessage = table(
  { name: 'direct_message' },
  {
    id: t.u64().primaryKey().autoInc(),
    sender: t.identity().index('btree'),
    recipient: t.identity().index('btree'),
    text: t.string(),
    sent: t.timestamp(),
  }
);

const spacetimedb = schema({ player, chatRoom, roomMember, message, directMessage });
export default spacetimedb;

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

Create, join and leave rooms​

Add a require_player function that rejects players who don't have a player row yet, and a find_membership function that returns the row recording that a player is in a room, if there is one. find_membership reads the player's rows through the member index, and looks for the room among them. The C++ module also needs a trim function, to remove the spaces at both ends of a string.

Then add three reducers:

  • create_room creates a room, and adds the caller to it. It rejects players who don't have a player row yet, and room names that are empty or longer than 50 characters, after removing the spaces at both ends.
  • join_room adds the caller to a room. It rejects players who don't have a player row yet, and rooms that don't exist. Joining a room twice changes nothing, so a client that sends the request twice doesn't get an error.
  • leave_room removes the caller from a room.
const MAX_ROOM_NAME_LENGTH = 50;

// Throws unless the caller has a `player` row.
function requirePlayer(ctx: Ctx) {
  if (!ctx.db.player.identity.find(ctx.sender)) {
    throw new SenderError('Pick a name first');
  }
}

// Returns the row that records `member` being in the room, if there is one.
function findMembership(ctx: Ctx, roomId: bigint, member: Identity) {
  for (const row of ctx.db.roomMember.member.filter(member)) {
    if (row.roomId === roomId) {
      return row;
    }
  }
  return undefined;
}

export const createRoom = spacetimedb.reducer({ name: t.string() }, (ctx, { name }) => {
  requirePlayer(ctx);
  const trimmed = name.trim();
  if (trimmed.length === 0 || trimmed.length > MAX_ROOM_NAME_LENGTH) {
    throw new SenderError(`Room names must be 1 to ${MAX_ROOM_NAME_LENGTH} characters`);
  }
  const room = ctx.db.chatRoom.insert({ id: 0n, name: trimmed });
  ctx.db.roomMember.insert({ id: 0n, roomId: room.id, member: ctx.sender });
});

export const joinRoom = spacetimedb.reducer({ roomId: t.u64() }, (ctx, { roomId }) => {
  requirePlayer(ctx);
  if (!ctx.db.chatRoom.id.find(roomId)) {
    throw new SenderError('Room not found');
  }
  if (findMembership(ctx, roomId, ctx.sender)) {
    return; // Already in the room.
  }
  ctx.db.roomMember.insert({ id: 0n, roomId, member: ctx.sender });
});

export const leaveRoom = spacetimedb.reducer({ roomId: t.u64() }, (ctx, { roomId }) => {
  const row = findMembership(ctx, roomId, ctx.sender);
  if (row) {
    ctx.db.roomMember.id.delete(row.id);
  }
});

Send messages​

Add a message_text function that removes the spaces at both ends of a message, and rejects messages that are then empty or longer than 1,000 characters. Then add two reducers:

  • send_message sends a message to a room. It rejects players who aren't in the room.
  • send_direct_message sends a message to another player. It rejects players who don't have a player row yet, messages to yourself, and messages to a player who doesn't exist.

Both reducers set the sender to ctx.sender and the time to ctx.timestamp, so clients can't send messages in another player's name, or change when a message was sent.

const MAX_MESSAGE_LENGTH = 1000;

// Removes the spaces at both ends of a message, and checks its length.
function messageText(text: string) {
  const trimmed = text.trim();
  if (trimmed.length === 0 || trimmed.length > MAX_MESSAGE_LENGTH) {
    throw new SenderError(`Messages must be 1 to ${MAX_MESSAGE_LENGTH} characters`);
  }
  return trimmed;
}

export const sendMessage = spacetimedb.reducer(
  { roomId: t.u64(), text: t.string() },
  (ctx, { roomId, text }) => {
    if (!findMembership(ctx, roomId, ctx.sender)) {
      throw new SenderError("You're not in this room");
    }
    ctx.db.message.insert({
      id: 0n,
      roomId,
      sender: ctx.sender,
      text: messageText(text),
      sent: ctx.timestamp,
    });
  }
);

export const sendDirectMessage = spacetimedb.reducer(
  { recipient: t.identity(), text: t.string() },
  (ctx, { recipient, text }) => {
    requirePlayer(ctx);
    if (recipient.isEqual(ctx.sender)) {
      throw new SenderError("You can't message yourself");
    }
    if (!ctx.db.player.identity.find(recipient)) {
      throw new SenderError('Player not found');
    }
    ctx.db.directMessage.insert({
      id: 0n,
      sender: ctx.sender,
      recipient,
      text: messageText(text),
      sent: ctx.timestamp,
    });
  }
);

Show each player their messages​

Add four views:

  • my_rooms returns the rooms the caller is in.
  • my_messages returns the messages of the rooms the caller is in.
  • my_sent_dms returns the DMs the caller sent, and my_received_dms the DMs the caller received. A single view that combines both filters with or would also work, but SpacetimeDB can't use an index for an or, so it would read every DM in the database each time a player subscribes. Two views each read only the caller's DMs, through their own index.

my_rooms and my_messages start from the caller's rows in room_member, then use a semijoin to keep the rooms, or the messages, that match them.

// The rooms the caller is in.
export const myRooms = spacetimedb.view(
  { name: 'my_rooms', public: true },
  t.array(chatRoom.rowType),
  ctx =>
    ctx.from.roomMember
      .where(m => m.member.eq(ctx.sender))
      .rightSemijoin(ctx.from.chatRoom, (m, room) => m.roomId.eq(room.id))
);

// The messages of the rooms the caller is in.
export const myMessages = spacetimedb.view(
  { name: 'my_messages', public: true },
  t.array(message.rowType),
  ctx =>
    ctx.from.roomMember
      .where(m => m.member.eq(ctx.sender))
      .rightSemijoin(ctx.from.message, (m, msg) => m.roomId.eq(msg.roomId))
);

// The DMs the caller sent.
export const mySentDms = spacetimedb.view(
  { name: 'my_sent_dms', public: true },
  t.array(directMessage.rowType),
  ctx => ctx.from.directMessage.where(dm => dm.sender.eq(ctx.sender))
);

// The DMs the caller received.
export const myReceivedDms = spacetimedb.view(
  { name: 'my_received_dms', public: true },
  t.array(directMessage.rowType),
  ctx => ctx.from.directMessage.where(dm => dm.recipient.eq(ctx.sender))
);

These views return a query instead of rows, so SpacetimeDB evaluates them with its query engine. When a row changes, it works out which rows of each view changed, instead of running the view again. Each view depends on who asks, so SpacetimeDB keeps one per subscribed player, but each one only covers that player's rooms and DMs.

When a player joins a room, my_messages adds the room's earlier messages, so the player sees the room's history. When they leave, my_messages removes them again.

note

A semijoin can only match columns that have their own single-column index. That's why room_member and message both index room_id.

Show the messages on the client​

On the client, subscribe to player and chat_room, and to the four views. chat_room lists every room, so players can pick one to join, and my_rooms tells you which ones they're in. The code below shows the room list when the player asks for it, for example when they open a room picker. player gives you the names of the senders.

Views don't keep their rows in any order, so sort the messages on the client. Each message's id is larger than the ids of the messages sent before it, so sort by id. To show a conversation with another player, merge the DMs you sent to them with the ones you received from them.

Show the open room or conversation again whenever a message is added or removed. Joining a room adds many messages at once, and leaving it removes them.

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

// Shows what's on screen: a room or a conversation.
let render = () => {};

const conn = DbConnection.builder()
  .withUri('ws://localhost:3000')
  .withDatabaseName('my-game')
  .onConnect(ctx => {
    ctx
      .subscriptionBuilder()
      .onApplied(() => render())
      .subscribe([
        tables.player,
        tables.chatRoom,
        tables.myRooms,
        tables.myMessages,
        tables.mySentDms,
        tables.myReceivedDms,
      ]);
  })
  .build();

conn.db.myMessages.onInsert(() => render());
conn.db.myMessages.onDelete(() => render());
conn.db.mySentDms.onInsert(() => render());
conn.db.myReceivedDms.onInsert(() => render());

function nameOf(identity: Identity) {
  return conn.db.player.identity.find(identity)?.name ?? 'Unknown player';
}

function listRooms() {
  const joined = Array.from(conn.db.myRooms.iter());
  for (const room of conn.db.chatRoom.iter()) {
    const isJoined = joined.some(r => r.id === room.id);
    console.log(isJoined ? `${room.name} (joined)` : room.name);
  }
}

function showRoom(roomId: bigint) {
  render = () => {
    const messages = Array.from(conn.db.myMessages.iter())
      .filter(m => m.roomId === roomId)
      .sort((a, b) => (a.id < b.id ? -1 : 1));
    for (const m of messages) {
      console.log(`${nameOf(m.sender)}: ${m.text}`);
    }
  };
  render();
}

function showConversation(other: Identity) {
  render = () => {
    const sent = Array.from(conn.db.mySentDms.iter()).filter(m => m.recipient.isEqual(other));
    const received = Array.from(conn.db.myReceivedDms.iter()).filter(m => m.sender.isEqual(other));
    const messages = [...sent, ...received].sort((a, b) => (a.id < b.id ? -1 : 1));
    for (const m of messages) {
      console.log(`${nameOf(m.sender)}: ${m.text}`);
    }
  };
  render();
}

async function createRoom(name: string) {
  try {
    await conn.reducers.createRoom({ name });
  } catch (error) {
    console.error(`Could not create the room: ${error}`);
  }
}

function joinRoom(roomId: bigint) {
  conn.reducers.joinRoom({ roomId }).catch(console.error);
}

function leaveRoom(roomId: bigint) {
  conn.reducers.leaveRoom({ roomId }).catch(console.error);
}

async function sendToRoom(roomId: bigint, text: string) {
  try {
    await conn.reducers.sendMessage({ roomId, text });
  } catch (error) {
    console.error(`Could not send the message: ${error}`);
  }
}

async function sendToPlayer(recipient: Identity, text: string) {
  try {
    await conn.reducers.sendDirectMessage({ recipient, text });
  } catch (error) {
    console.error(`Could not send the message: ${error}`);
  }
}

onInsert and onDelete run once for each row that changes, so joining a room can show it many times in a row. If rendering is expensive, render at most once per frame instead.

What's next?​

You now have chat rooms and DMs that stay live on every client:

  • Private tables hold the room members, the messages and the DMs.
  • Reducers check who can send what, and set the sender and time of each message themselves.
  • Views built with the query builder give each player the messages of their own rooms, and their own DMs, and update incrementally.
  • Clients sort the messages, and merge the DMs of each conversation.

To adapt it to your app:

  • Long histories. my_messages returns every message of every room the player is in, and the DM views every DM they sent or received. To keep that bounded, delete old messages with a scheduled reducer.
  • Invite-only rooms. Add a column to chat_room that marks the room as private, and a reducer that lets members add other players. Make join_room reject private rooms. To keep private rooms out of the public list, make chat_room private, and add a view that returns only the public rooms.
  • Group DMs. A group DM is an invite-only room that isn't listed.
  • Blocking players. Add a private table of blocked players that send_direct_message checks before it sends a DM.
  • Moderation. Room names are public, and every client downloads them. To check room names and messages for offensive words, use the same approach as Keep usernames unique and block offensive ones.
  • Spam. To stop a player from creating many rooms or sending many messages in a row, store when they last did it, and reject requests that come too soon. The usernames guide uses the same kind of cooldown for name requests.

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

Next, you can show who's typing with typing indicators and other short-lived signals.