Skip to main content
Version: 2.0.0

Build a friends or follow list

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.

In this guide, you'll let players follow each other, and show each player, live, who they follow and who follows them. Players who follow each other are mutual follows. Nobody else can see a player's lists. You'll store follows in a private table, and use views built with the query builder to give each player their own lists.

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 private follow table has one row for each follow: follower follows followed. Both columns have an index.
  • follow_player and unfollow_player reducers add and remove the caller's follows. A remove_follower reducer lets players remove someone who follows them.
  • Two views, my_following and my_followers, return the player rows of the players the caller follows, and of the players who follow the caller.
  • Clients subscribe to both views. A player who is in both lists is a mutual follow.

The follow table is private, so clients can't read who follows whom. They only see the rows the views return, and each view only returns the caller's own lists.

Define the tables​

Add the player and follow tables. follow has an id primary key, so a row can be deleted by its id, and an index on each of the other columns: follower to list who a player follows, and followed to list a player's followers.

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 follow = table(
  { name: 'follow' },
  {
    id: t.u64().primaryKey().autoInc(),
    follower: t.identity().index('btree'),
    followed: t.identity().index('btree'),
  }
);

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

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

Follow and unfollow players​

Add a find_follow function that returns the row recording that one player follows another, if there is one. It reads the follower's follows through the follower index, and looks for the followed player among them.

Then add three reducers:

  • follow_player makes the caller follow target. It rejects players who don't have a player row yet, following yourself, and following a player who doesn't exist. Following someone twice changes nothing, so a client that sends the request twice doesn't get an error. It also limits each player to 1,000 follows, so that no one can follow every player in your game, and so that find_follow never reads more than 1,000 rows.
  • unfollow_player makes the caller stop following target.
  • remove_follower makes follower stop following the caller.
const MAX_FOLLOWING = 1000;

// Returns the row that records `follower` following `followed`, if there is one.
function findFollow(ctx: Ctx, follower: Identity, followed: Identity) {
  for (const row of ctx.db.follow.follower.filter(follower)) {
    if (row.followed.isEqual(followed)) {
      return row;
    }
  }
  return undefined;
}

export const followPlayer = spacetimedb.reducer({ target: t.identity() }, (ctx, { target }) => {
  if (!ctx.db.player.identity.find(ctx.sender)) {
    throw new SenderError('Pick a name first');
  }
  if (target.isEqual(ctx.sender)) {
    throw new SenderError("You can't follow yourself");
  }
  if (!ctx.db.player.identity.find(target)) {
    throw new SenderError('Player not found');
  }
  if (findFollow(ctx, ctx.sender, target)) {
    return; // Already following.
  }
  if (Array.from(ctx.db.follow.follower.filter(ctx.sender)).length >= MAX_FOLLOWING) {
    throw new SenderError(`You can't follow more than ${MAX_FOLLOWING} players`);
  }
  ctx.db.follow.insert({ id: 0n, follower: ctx.sender, followed: target });
});

export const unfollowPlayer = spacetimedb.reducer({ target: t.identity() }, (ctx, { target }) => {
  const row = findFollow(ctx, ctx.sender, target);
  if (row) {
    ctx.db.follow.id.delete(row.id);
  }
});

export const removeFollower = spacetimedb.reducer({ follower: t.identity() }, (ctx, { follower }) => {
  const row = findFollow(ctx, follower, ctx.sender);
  if (row) {
    ctx.db.follow.id.delete(row.id);
  }
});

Show each player their lists​

Add two views. my_following returns the players the caller follows, and my_followers returns the players who follow the caller. Each view starts from the caller's rows in follow, then uses a semijoin to keep the player rows that match them.

// The players the caller follows.
export const myFollowing = spacetimedb.view(
  { name: 'my_following', public: true },
  t.array(player.rowType),
  ctx =>
    ctx.from.follow
      .where(f => f.follower.eq(ctx.sender))
      .rightSemijoin(ctx.from.player, (f, p) => f.followed.eq(p.identity))
);

// The players who follow the caller.
export const myFollowers = spacetimedb.view(
  { name: 'my_followers', public: true },
  t.array(player.rowType),
  ctx =>
    ctx.from.follow
      .where(f => f.followed.eq(ctx.sender))
      .rightSemijoin(ctx.from.player, (f, p) => f.follower.eq(p.identity))
);

These views return a query instead of rows, so SpacetimeDB evaluates them with its query engine. When a follow 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 follows.

The views return player rows, so they keep the player table's primary key. When a player renames, clients receive the change as an update to the existing row.

note

A semijoin can only match columns that have their own single-column index. That's why follow indexes follower and followed separately, rather than with one index on both columns.

Show the lists on the client​

On the client, subscribe to my_following and my_followers. A player who is in both lists is a mutual follow, so the client finds them by comparing the two lists. Re-render the lists when a row is added, removed, or updated.

Call the reducers from your UI, for example from a button on a player's profile.

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

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

function showLists() {
  const following = Array.from(conn.db.myFollowing.iter());
  const followers = Array.from(conn.db.myFollowers.iter());
  const mutual = following.filter(p => followers.some(f => f.identity.isEqual(p.identity)));
  console.log('Following:', following.map(p => p.name).join(', '));
  console.log('Followers:', followers.map(p => p.name).join(', '));
  console.log('Mutual:', mutual.map(p => p.name).join(', '));
}

for (const view of [conn.db.myFollowing, conn.db.myFollowers]) {
  view.onInsert(() => showLists());
  view.onDelete(() => showLists());
  view.onUpdate(() => showLists());
}

async function follow(target: Identity) {
  try {
    await conn.reducers.followPlayer({ target });
  } catch (error) {
    console.error(`Could not follow this player: ${error}`);
  }
}

function unfollow(target: Identity) {
  conn.reducers.unfollowPlayer({ target }).catch(console.error);
}

function removeFollower(follower: Identity) {
  conn.reducers.removeFollower({ follower }).catch(console.error);
}

onInsert, onDelete and onUpdate run once for each row that changes, so a change can re-render the lists more than once. If rendering is expensive, re-render at most once per frame instead.

Use it as a friends list​

Follows are one-way, like on most social networks. To build friends that both players must agree on, like in most games, use the same table and reducers, and treat a follow that hasn't been returned yet as a friend request:

In a friends listWith follows
Send a friend requestfollow_player
Incoming requestsPlayers in my_followers but not in my_following
Outgoing requestsPlayers in my_following but not in my_followers
Accept a requestfollow_player, to follow them back
Decline a requestremove_follower
FriendsMutual follows
Remove a friendunfollow_player and remove_follower

To remove a friend in a single transaction, add a reducer that deletes both rows.

After a decline, nothing stops the other player from sending the request again, as often as they like. To prevent that, add a block list: a private table of blocked players that follow_player checks before it adds a follow.

What's next?​

You now have follow lists that stay live on every client:

  • A private follow table records who follows whom, with an index on each side.
  • Reducers add and remove follows, and limit how many players each player can follow.
  • Two views built with the query builder show each player their own lists, and update incrementally.
  • Clients find mutual follows by comparing the two lists, which also gives you a friends list with requests.

From here, you could add a block list, as described above, or show which of a player's follows are online.

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

Next, you can let players talk to each other with chat rooms and private DMs.