# Roles & moderation

> Give room roles, decide game roles, fill the Room management modal, and kick or ban players.

Most community rooms have some kind of staff: an owner, admins, maybe
moderators. disko doesn't decide what those are. Instead, it gives your room
a few building blocks:

- **Room roles:** your room's own staff, such as Admin, with a badge.
- **Game roles:** decide who holds the roles the loaded game declares.
- **The Room management modal:** give players and staff buttons, settings
  and role switches.
- **Moderation:** kick and ban.

Every call on this page returns a Promise that settles once the change has
taken effect.

## Room roles

A room role is a power over the room: kick, ban, options, the game it runs.
Games never see room roles.

```js title="room.js"
const admin = room.role("admin", {
  label: "Admin",
  color: "accent",
  badge: "Admin",
});
/** @param {{ viewer: import("@disko-game/room").Player }} context */
const isAdmin = ({ viewer }) => admin.has(viewer);

// The owner is an Admin when they join; Admins hand out Admin by hand.
room.on("player-join", ({ player }) => {
  if (player.isOwner) {
    void admin.give(player);
  }
});
await room.roles.editable(isAdmin);
```

- `room.role(name, { label, color?, badge? })` declares a role. Declare
  roles from highest to lowest rank: a player's name shows the color and
  badge of the highest-ranking role they hold, unless the room set the
  player's presentation.
- `role.give(player)` and `role.take(player)` change who holds it;
  `role.has(player)` and `role.holders()` read it right away.
- `room.roles.editable(rule)` decides who may give and take room roles by
  hand in the modal: a function of `{ viewer, target, role }`, or
  `true`/`false`. Without one, the room's owner may.
- Every change, in code or by hand, arrives as `role-change`
  (`{ player, role, held }`).

## Game roles

Games [declare roles](/games/roles) with a default, such as "host, the
owner by default". In a room that decides nothing, the defaults apply:
`"everyone"`, `"nobody"`, or `"owner"` (the player signed in with the room
owner's account). `room.game.roles.declared` lists them, each with its
`name`, `label` and `default`.

For one player, who holds a game role is decided in this order:

1. Your room's **resolver**, if it answers `true` or `false`.
2. A **person** in the Room management modal, where no resolver decides.
3. The **game's own** `give` and `take`.
4. The **default**.

### Answers are kept

A resolver might be slow (it could look something up in a database), so the
game never waits for it. disko asks ahead of time, at moments when the
answer can change, and keeps the answers. The game reads the kept answer
instantly.

```mermaid alt="When a player joins, a game loads, or the room calls refresh, disko asks the room's resolver and keeps the answer. When the game reads role.has, it reads the kept answer immediately, without waiting for the room."
flowchart LR
  moments["Player joins, game loads,<br/>or room.game.roles.refresh()"] --> resolver["Your resolver<br/>(may be async)"]
  resolver -- "answer" --> cache[("Kept answers")]
  game["role.has(...)"] -- "instant" --> cache
```

```js title="room.js"
// One role the game declares, by name: only Admins referee.
await room.game.roles.resolve("referee", ({ player }) => admin.has(player));

// A catch-all for every other role: Admins hold them all, and `undefined`
// leaves everyone else to a person in the room, then the game.
await room.game.roles.resolve(({ player }) =>
  admin.has(player) ? true : undefined,
);

// Admins hand out the game's other roles by hand.
await room.game.roles.editable(isAdmin);

// When Admin changes, ask the resolvers again.
room.on("role-change", ({ player, role }) => {
  if (role === admin) {
    void room.game.roles.refresh({ player });
  }
});
```

- `room.game.roles.resolve(name, resolver)` decides one role, and
  `room.game.roles.resolve(resolver)` every role the loaded game declares.
  A named resolver takes precedence; registering again replaces it.
  `room.game.roles.remove(name?)` removes one, or the catch-all without a
  name.
- A resolver receives `{ player, role, game }` and returns `true`, `false`,
  or `undefined` to leave it to a person and then the game. Errors are not
  caught: they reach Node's `uncaughtException`, and the game decides.
- disko asks again when a player joins, a game loads, resolvers change, and
  on `room.game.roles.refresh({ player?, role? })`. Until the first answer
  arrives, the game reads the role as not held.
- `room.game.roles.editable(rule)` decides who may give and take game roles
  by hand, as `room.roles.editable` does for room roles.
- To change anything else about a game, such as its match length, use the
  events it documents with `room.emit`.

## Room management modal

Every player has a **Room** button in the play client. It opens a modal with
three sections: the player list, the room's options, and actions. A
player's detail view also lists their room roles and game roles, with
switches for viewers who may change them.

Your room doesn't draw this modal. It describes what's in it (which actions
exist, which options, who may see or use them) and the client draws it the
same way in every room. So players always find moderation and settings in a
familiar place.

- **Player actions** appear when someone clicks a player, such as Kick.
- **Room actions** appear in the Actions section, such as Clear bans.
- **Options** are settings, such as which game to play or whether guests may
  join.

```js title="room.js"
await room.actions.player("kick", {
  label: "Kick",
  confirm: { reason: true },
  visible: ({ viewer, target }) => admin.has(viewer) && viewer !== target,
  run: ({ target, reason }) => target.kick(reason),
});

await room.actions.room("restart", {
  label: "Restart match",
  confirm: true,
  visible: isAdmin,
  run: async () => {
    await room.stop();
    await room.start();
  },
});

await room.options.choice("game", {
  label: "Game",
  value: "creator/arena@1.4.2",
  choices: [
    { value: "creator/arena@1.4.2", label: "Arena" },
    { value: "creator/hockey@2.0.0", label: "Hockey" },
  ],
  editable: isAdmin,
  change: async ({ value }) => {
    // Throwing rejects the change and shows the viewer the message.
    await room.load(value);
  },
});

await room.options.toggle("guests", {
  label: "Admit guests",
  value: room.guests,
  editable: ({ viewer }) => viewer.isOwner,
  change: ({ value }) => room.setGuests(value),
});
```

- **Player actions** (`room.actions.player(id, definition)`) appear in a
  player's detail view, **room actions** (`room.actions.room(id, definition)`)
  in the Actions section. A definition has a `label` (1–48 bytes), an
  optional `confirm` (`true`, or `{ reason: true }` for an optional reason
  field), an optional `visible` predicate (omitted: everyone), and `run`.
- **Options** come in four formats: `room.options.toggle`, `choice` (1–32
  choices), `number` (`min`, `max`, optional `step`) and `text`
  (`maxLength` up to 128, optional `secret`, whose value is never sent to
  viewers). Each has a `label`, the current `value`, and optional `visible`,
  `editable` (omitted: nobody, so the row is read-only) and `change`.
  `room.options.set(id, value)` changes a value from the room; every viewer
  sees values live.
- `visible` and `editable` are checked again before `run` or `change`, so a
  stale modal cannot act. A predicate that throws denies. When `run` or
  `change` throws, the action or change is rejected and the viewer sees the
  error message.
- Ids are 1–64 bytes, and defining an action with the same id replaces it.
  `room.actions.remove("player" | "room", id)` and `room.options.remove(id)`
  take entries away. disko asks `visible` and `editable` again whenever a
  room role changes hands; call `room.actions.refresh({ viewer? })` when
  other facts behind them change.

## Presentation and moderation

**Presentation** is how a player's name looks everywhere it appears: a color
and a short badge. Room roles set it for you; `setPresentation` overrides
it. **Moderation** is removing players: a kick lets them come back, a ban
doesn't.

```js title="room.js"
// Bans last for the room's lifetime. Keep their sealed exports to restore
// them after a restart of the same room.
const saved = JSON.parse(await readFile("bans.json", "utf8").catch(() => "[]"));

for (const sealed of saved) {
  await room.bans.import(sealed);
}

process.on("SIGTERM", async () => {
  const bans = await room.bans.list();

  await writeFile(
    "bans.json",
    JSON.stringify(
      bans.flatMap((ban) => (ban.sealed === null ? [] : [ban.sealed])),
    ),
  );
  await room.close({ reason: "restarting" });
});
```

- `player.setPresentation({ color, badge })` shows a palette role
  (`"accent"`, `"success"`, `"warning"`, `"danger"` or `"muted"`) and a short
  badge (at most 12 characters) wherever the player's name appears; `null`
  clears a field.
- `player.kick(reason?)` disconnects a player and shows them the reason. They
  may rejoin.
- `player.ban({ reason? })` bans and disconnects a player: signed-in players
  by account, guests by an engine-held network identity the room never sees.
  The reason is at most 256 bytes.
- `room.bans.list()` returns the bans, each with `id`, `kind` (`"account"`
  or `"guest"`), the player's `name`, the `reason`, a `sealed` export and
  `lift()`. A ban contains no address and no account.
- Bans last for the room's lifetime. To keep them across a restart, store the
  `sealed` exports and pass each to `room.bans.import(sealed)` when the same
  room starts again. Exports are sealed with a key derived from the room's
  hosting token, so they import only with that token.
- `player.isOwner` tells whether the player is signed in with the account
  that owns the room. Guests never are.

See the [`@disko-game/room` reference](/reference/room#room-roles) for
every signature.
