Skip to content

Room recipes

Chat commands

Let trusted players run the room from the chat, and keep commands out of it.

This room reads messages that start with !, runs them as commands and hides them from other players. A player becomes an admin by sending a passphrase only you know.

The command table

Each command declares whether it needs an admin, and receives the sender and its arguments:

commands.mjs
export function createCommands(room, { passphrase }) {
  const findPlayer = (name) =>
    room
      .getPlayerList()
      .find(
        (p) => p.name.toLowerCase() === name?.toLowerCase(),
      );

  return {
    help: {
      admin: false,
      run: (player) =>
        room.sendAnnouncement(
          'Commands: !help, !admin <passphrase>, !start, ' +
            '!stop, !swap, !mute <name>',
          player.id,
        ),
    },
    admin: {
      admin: false,
      run: async (player, [attempt]) => {
        if (attempt !== passphrase)
          return room.sendAnnouncement(
            'Wrong passphrase.',
            player.id,
          );
        await room.setPlayerAdmin(player.id, true);
        await room.sendAnnouncement(
          `${player.name} is now an admin.`,
        );
      },
    },
    start: { admin: true, run: () => room.startGame() },
    stop: { admin: true, run: () => room.stopGame() },
    swap: {
      admin: true,
      run: async () => {
        for (const p of room.getPlayerList()) {
          if (p.team !== 0)
            await room.setPlayerTeam(
              p.id,
              p.team === 1 ? 2 : 1,
            );
        }
      },
    },
    mute: {
      admin: true,
      run: async (player, [name]) => {
        const target = findPlayer(name);
        if (!target)
          return room.sendAnnouncement(
            `No player named ${name}.`,
            player.id,
          );
        await room.setPlayerMuted(target.id, true);
      },
    },
  };
}

Wire it to the chat

onPlayerChat must decide synchronously whether a message is shown, so hide the command first and run it afterwards:

host.mjs
import { createRoom } from 'ball2d/node';
import { createCommands } from './commands.mjs';

const room = await createRoom({
  apiKey: process.env.BALL2D_API_KEY,
  roomName: 'Pickup football',
  noPlayer: true,
  public: true,
});

const commands = createCommands(room, {
  passphrase: process.env.ROOM_PASSPHRASE,
});

room.onPlayerChat = (player, text) => {
  if (!text.startsWith('!')) return;
  const [name, ...args] = text.slice(1).trim().split(/\s+/);
  const command = commands[name?.toLowerCase()];
  if (!command) {
    void room.sendAnnouncement(
      `Unknown command. Try !help`,
      player.id,
    );
  } else if (
    command.admin &&
    !room.getPlayer(player.id)?.admin
  ) {
    void room.sendAnnouncement(
      'Only admins can do that.',
      player.id,
    );
  } else {
    Promise.resolve(command.run(player, args)).catch((error) =>
      room.sendAnnouncement(
        `Command failed: ${error.message}`,
        player.id,
      ),
    );
  }
  // Never show commands, the passphrase included, to the room.
  return false;
};

room.onError = (message) => console.error(message);
console.log(room.roomLink);

The passphrase travels through the room like any chat message, and the room host can read it. Use a passphrase for this room only, rotate it by restarting with a new ROOM_PASSPHRASE, and never reuse a real password.

Why check admin on every command

Admin rights can change while a message is on its way. Reading room.getPlayer(player.id)?.admin at the moment the command runs uses the room's current state, not a copy from when the player joined.