Skip to main content
Version: 2.0.0

Keep usernames unique and block offensive ones

Prerequisites

You need a module and a client connected to it, and an OpenAI API key. If you don't have a module yet, follow the quickstart for your language first.

There's no C++ version of this guide yet.

In this guide, you'll let players pick a username that no one else has, ignoring case, and reject names that are offensive. You'll use a unique column to keep names unique, a profanity library to catch swear words, and OpenAI's moderation API to catch hateful or harassing names. The API is called from a procedure, with an API key stored in an environment variable.

How it works​

  • A public player table holds each player's name, as they typed it, and its lowercase form, name_key. name_key is unique, so "Bob" and "bob" can't both exist. A player only gets a row once they've picked a valid name, so the table also tells you who's ready to play.
  • A set_username procedure checks a name in three steps, cheapest first:
    1. The name must be 3 to 20 letters, digits or underscores.
    2. A profanity library rejects swear words and, depending on the library, disguised spellings that use digits in place of letters. It runs inside your module, so it's instant and free.
    3. The OpenAI moderation API rejects names that its model judges hateful, harassing, sexual or violent.
  • A private name_attempt table records when each player last asked for a name, so a player can't flood the moderation API with requests.
  • An OPENAI_API_KEY environment variable stores your API key on the database.

The two content checks cover different things. A word list only knows the words on it, but it catches swearing, which isn't one of the moderation API's categories. The moderation API judges what a name means, so it can reject a hateful or threatening name that uses no swear words at all.

set_username is a procedure, not a reducer, because reducers can't make HTTP requests. The procedure calls the moderation API outside of any transaction, and only opens short transactions to read and write tables.

Install a profanity library​

Add the obscenity package to your module. It recognizes common disguises, such as digits in place of letters and repeated letters:

npm install obscenity

Define the tables​

Add the player and name_attempt tables, and declare the OPENAI_API_KEY environment variable. Clients can read player to show everyone's names, but they can't read name_attempt, which is private.

import { TimeDuration } from 'spacetimedb';
import {
  schema,
  table,
  t,
  SenderError,
  type InferSchema,
  type ProcedureCtx,
  type ReducerCtx,
} from 'spacetimedb/server';
import {
  DataSet,
  RegExpMatcher,
  englishDataset,
  englishRecommendedTransformers,
  pattern,
} from 'obscenity';

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

const nameAttempt = table(
  { name: 'name_attempt' },
  {
    identity: t.identity().primaryKey(),
    lastAttempt: t.timestamp(),
  }
);

const spacetimedb = schema(
  { player, nameAttempt },
  { env: { OPENAI_API_KEY: t.string() } }
);
export default spacetimedb;

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

The API key is required, so SpacetimeDB refuses to publish the module without one. Pass it in the publishing command's environment the first time you publish:

OPENAI_API_KEY='your-api-key' spacetime publish my-game

The key stays in the database across later publishes. To replace it, run the same command with --env-only, which updates environment variables without uploading your module. See Environment Variables for other ways to supply it.

warning

Keep the key away from anything clients can read: don't put it in a spacetime.json file you commit, and don't return it from a procedure or a view, or write it to the logs.

Check the name's format​

Add a name_key function that rejects names that are too short, too long, or that contain anything other than ASCII letters, digits and underscores. It returns the name's key: its lowercase form, which the player table keeps unique.

const MIN_NAME_LENGTH = 3;
const MAX_NAME_LENGTH = 20;
const NAME_PATTERN = new RegExp(`^[A-Za-z0-9_]{${MIN_NAME_LENGTH},${MAX_NAME_LENGTH}}$`);

// Checks the name's format and returns its key: the lowercase form that must be unique.
function nameKey(name: string): string {
  if (!NAME_PATTERN.test(name)) {
    throw new Error(
      `Names must be ${MIN_NAME_LENGTH} to ${MAX_NAME_LENGTH} letters, digits or underscores`
    );
  }
  return name.toLowerCase();
}

Limiting names to ASCII keeps uniqueness simple. Unicode has many characters that look alike, such as the Latin a and the Cyrillic а, and characters that don't show at all. If you allowed them, two names could look identical on screen while having different keys. If your players need names in other scripts, look into Unicode normalization and the confusable detection described by the Unicode standard before you allow them.

Filter profanity with a library​

Add an is_profane function that checks a name against the library's English word list. Every word list blocks some innocent words and misses some offensive ones, so the code also shows how to allow a word that the list blocks, and block a word that it allows. Here, it blocks moderator, so players can't pose as your staff.

const words = new DataSet<{ originalWord: string }>()
  .addAll(englishDataset)
  // Allow a word that the built-in list blocks: "cumin" is a spice.
  .addPhrase(phrase => phrase.addWhitelistedTerm('cumin'))
  // Block a word that the built-in list allows, so players can't pose as staff.
  .addPhrase(phrase => phrase.setMetadata({ originalWord: 'moderator' }).addPattern(pattern`moderator`));

const matcher = new RegExpMatcher({ ...words.build(), ...englishRecommendedTransformers });

function isProfane(text: string): boolean {
  return matcher.hasMatch(text);
}

The word list is part of your module's code, so you change it by publishing your module again.

Check names with the moderation API​

Add an is_flagged function that sends a name to OpenAI's moderation endpoint, POST /v1/moderations, with the omni-moderation-latest model. OpenAI doesn't charge for this endpoint. The reply has one result per input, and its flagged field is true when the text falls into any harmful category, such as hate, harassment or sexual content.

// Asks the OpenAI moderation API whether the text is harmful.
function isFlagged(ctx: Ctx, text: string): boolean {
  const response = ctx.http.fetch('https://api.openai.com/v1/moderations', {
    method: 'POST',
    headers: {
      'content-type': 'application/json',
      authorization: `Bearer ${ctx.env.OPENAI_API_KEY}`,
    },
    body: JSON.stringify({ model: 'omni-moderation-latest', input: text }),
    timeout: TimeDuration.fromMillis(10_000),
  });
  if (response.status !== 200) {
    throw new Error(`The moderation API returned status ${response.status}`);
  }

  const flagged = response.json().results?.[0]?.flagged;
  if (typeof flagged !== 'boolean') {
    throw new Error('The moderation API returned no result');
  }
  return flagged;
}

flagged uses OpenAI's own thresholds. To be stricter or more lenient, read the per-category scores in the result's category_scores instead, from 0 to 1. OpenAI may change these scores when it updates the model, so check your thresholds again from time to time.

Other moderation services work the same way: only is_flagged changes.

Set a username​

Add the set_username procedure that clients call with the name they want. Players call it both to pick their first name and to change it later.

The procedure runs the checks in order of cost, and stops at the first one that fails:

  1. It checks the name's format, then runs the word list on the key, with underscores read as spaces so the library sees bad_word as two words. The moderation API later gets the same text.
  2. In a short transaction, it rejects names that another player already has, then rejects players who asked for a name less than 5 seconds ago. A taken name doesn't count towards the limit, because it never reaches the API.
  3. It calls the moderation API, outside of any transaction.
  4. In a second transaction, it checks again that the name is free, because another player may have taken it while the API was answering, and saves it.

The procedure returns an empty string on success, or a message that explains what went wrong. Returning errors as values, instead of throwing, means clients get the same kind of result whichever language your module is written in.

const COOLDOWN_MICROS = 5_000_000n; // 5 seconds

export const setUsername = spacetimedb.procedure(
  { name: t.string() },
  t.string(),
  (ctx, { name }) => {
    try {
      trySetUsername(ctx, name.trim());
      return '';
    } catch (error) {
      return error instanceof Error ? error.message : String(error);
    }
  }
);

function trySetUsername(ctx: Ctx, name: string) {
  // Check the format and the word list first: they're free.
  const key = nameKey(name);
  const text = key.replaceAll('_', ' ');
  if (isProfane(text)) {
    throw new Error('This name is not allowed');
  }

  // Reject names that are taken, then check and record the caller's cooldown.
  const error = ctx.withTx(tx => {
    const other = tx.db.player.nameKey.find(key);
    if (other && !other.identity.isEqual(tx.sender)) {
      return 'This name is taken';
    }
    const attempt = tx.db.nameAttempt.identity.find(tx.sender);
    if (attempt && tx.timestamp.since(attempt.lastAttempt).micros < COOLDOWN_MICROS) {
      return 'Too many attempts, try again in a few seconds';
    }
    if (attempt) {
      tx.db.nameAttempt.identity.update({ ...attempt, lastAttempt: tx.timestamp });
    } else {
      tx.db.nameAttempt.insert({ identity: tx.sender, lastAttempt: tx.timestamp });
    }
    return '';
  });
  if (error) {
    throw new Error(error);
  }

  // Call the moderation API outside any transaction.
  let flagged: boolean;
  try {
    flagged = isFlagged(ctx, text);
  } catch (error) {
    console.error('Moderation check failed:', error);
    throw new Error('Could not check this name, try again later');
  }
  if (flagged) {
    throw new Error('This name is not allowed');
  }

  // Save the name. Check again: another player may have taken it during the API call.
  const taken = ctx.withTx(tx => {
    const other = tx.db.player.nameKey.find(key);
    if (other && !other.identity.isEqual(tx.sender)) {
      return true;
    }
    const row = { identity: tx.sender, name, nameKey: key };
    if (tx.db.player.identity.find(tx.sender)) {
      tx.db.player.identity.update(row);
    } else {
      tx.db.player.insert(row);
    }
    return false;
  });
  if (taken) {
    throw new Error('This name is taken');
  }
}

When the moderation API can't be reached, or answers with an error, the procedure refuses the name and asks the player to try again later. It logs the details for you, and sends players a generic message. If you'd rather keep accepting names during an OpenAI outage, log the error and continue instead: the name then only passes the word list.

The unique constraint on name_key is the final guarantee: even if your code had a bug, the database would never store two rows with the same key.

Require a name before playing​

Players only get a player row once their name has passed every check, so a row means the player is ready to play. Add a require_player helper that returns the caller's row, or an error if they haven't picked a name yet, and call it at the start of every reducer that needs one. The join_game reducer below shows how; in Build a matchmaking queue, join_queue is a good place for it.

// Returns the caller's player row, or throws if they haven't picked a name yet.
function requirePlayer(ctx: ReducerCtx<InferSchema<typeof spacetimedb>>) {
  const row = ctx.db.player.identity.find(ctx.sender);
  if (!row) {
    throw new SenderError('Pick a username first');
  }
  return row;
}

export const joinGame = spacetimedb.reducer(ctx => {
  const me = requirePlayer(ctx);
  // Your game logic goes here.
  console.info(`${me.name} joined the game`);
});

Clients see the same thing: they subscribe to player to list the other players, so players who haven't picked a name yet never show up in lobbies or leaderboards.

Pick a name from the client​

On the client, subscribe to player to show players' names. Once the subscription is applied, look up the player's own row: if they don't have one, show the name picker. When they submit a name, call set_username and show the error if there is one. Their row arrives through onInsert once a name passes every check, which is your signal to start the game. Returning players get theirs as soon as the subscription is applied.

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

const conn = DbConnection.builder()
  .withUri('ws://localhost:3000')
  .withDatabaseName('my-game')
  .onConnect((ctx, identity) => {
    ctx
      .subscriptionBuilder()
      .onApplied(() => {
        // Players without a row haven't picked a name yet.
        if (!conn.db.player.identity.find(identity)) {
          console.log('Pick a username');
        }
      })
      .subscribe([tables.player]);
  })
  .build();

// The player's own row arrives once their name has passed every check.
conn.db.player.onInsert((_ctx, row) => {
  if (row.identity.isEqual(conn.identity!)) {
    console.log(`Ready to play as ${row.name}`);
  }
});

// Call this when the player submits a name.
async function pickName(name: string) {
  const error = await conn.procedures.setUsername({ name });
  if (error) {
    console.error(`Could not use this name: ${error}`);
  } else {
    console.log(`Your name is now ${name.trim()}`);
  }
}

You can also check the format on the client before calling set_username, so players see a mistake as they type. Keep the checks on the server too: clients can be modified, so only the module can enforce them.

What's next?​

You now have usernames that are unique and checked before anyone sees them:

  • A unique name_key column keeps names unique, ignoring case.
  • A profanity library rejects swear words for free, inside your module.
  • The OpenAI moderation API rejects names that its model judges harmful, even without swear words, with the API key stored in an environment variable.
  • The set_username procedure runs the cheap checks first, calls the API outside any transaction, and returns errors as values that every client can read.
  • Only players with a name have a player row, so reducers that need one start with require_player.

To learn more about the features used here, see Constraints, Procedures and Environment Variables.

To allow each player a number of name requests per time window, such as 5 per minute, instead of one request every 5 seconds, use the Rate Limit submodule in a TypeScript module. It's a package you add to your module.

To call other web services from your module the same way, see Call an AI model from your module.

Next, you can let players build a friends or follow list.