# Permissions & moderation

> Resolve game permissions, 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 roles are. Instead, it gives your
room a few building blocks, and you combine them into whatever roles your room
needs:

- **Permissions:** decide who may do the things games let you restrict.
- **The Room management modal:** give players and staff buttons and settings.
- **Presentation:** show who's who, such as an "Admin" badge.
- **Moderation:** kick and ban.

An owner-and-admins room, for example, needs nothing more than these
pieces. Every call on this page returns a Promise that settles once the change
has taken effect.

## Permissions

Games [declare permissions](/games/players-teams-actions#permissions) with a
default, such as "pick-teams, restricted by default". In a room that decides
nothing, those defaults apply: `"everyone"`, `"restricted"`, or `"owner"` (the
player signed in with the room owner's account).

Your room can take over any permission with a **resolver**: a function that
answers "may this player do this?". `room.game.permissions` lists what the
loaded game declares, each with its `name`, `label` and `default`.

### Answers are cached

A game checks permissions constantly, many times a second, and a resolver
might be slow (it could look something up in a database). So the game never
waits for your resolver. disko asks your resolver 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 calls game.can, it reads the kept answer immediately, without waiting for the room."
flowchart LR
  moments["Player joins, game loads,<br/>or room.permissions.refresh()"] --> resolver["Your resolver<br/>(may be async)"]
  resolver -- "answer" --> cache[("Kept answers")]
  game["game.can(...)"] -- "instant" --> cache
```

When your room's own facts change, for example when someone becomes an admin,
call `room.permissions.refresh(...)` so disko asks again.

```js title="room.js"
// One permission the game declares, by name.
await room.permissions.resolve("manage-match", ({ player }) => isAdmin(player));

// A catch-all for every other permission the loaded game declares: admins
// hold what normal players don't (`"restricted"`), and `undefined` keeps the
// game's default for everything else, so `"everyone"` abilities stay with
// every player and `"owner"` permissions stay the owner's.
await room.permissions.resolve(({ player, permission }) =>
  permission.default === "restricted" && isAdmin(player) ? true : undefined,
);

// After the room's own facts change, ask the resolvers again.
/** @param {import("@disko-game/room").Player} player */
async function promote(player) {
  if (player.account !== null) {
    admins.add(player.account);
    await room.permissions.refresh({ player });
    await room.actions.refresh({ viewer: player });
  }
}
```

- `room.permissions.resolve(name, resolver)` decides one permission, and
  `room.permissions.resolve(resolver)` is a catch-all for every permission
  the loaded game declares. A named resolver takes precedence, and
  registering again replaces the resolver. `room.permissions.remove(name?)`
  removes a named resolver, or the catch-all without a name.
- A resolver receives `{ player, permission, game }` and returns `true`,
  `false`, or `undefined` for the game's default (the declared default, or
  what the game set for that player with `permission.setDefault`). Any other value, or an
  error, applies the default for that decision; errors are not caught and
  reach Node's `uncaughtException`.
- disko asks again when a player joins, when a game loads, when resolvers
  change, and on `room.permissions.refresh({ player?, permission? })`. Until
  the first answer arrives, the game reads the permission as denied.

## 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.

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 or
  Give admin.
- **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("promote", {
  label: "Make admin",
  confirm: true,
  visible: ({ viewer, target }) =>
    isAdmin(viewer) && target.account !== null && !isAdmin(target),
  run: ({ target }) => promote(target),
});

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

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

await room.options.choice("game", {
  label: "Game",
  value: "gabriel/arena@1.4.2",
  choices: [
    { value: "gabriel/arena@1.4.2", label: "Arena" },
    { value: "gabriel/hockey@2.0.0", label: "Hockey" },
  ],
  editable: ({ viewer }) => isAdmin(viewer),
  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. Call `room.actions.refresh({ viewer? })` when the facts
  behind `visible` change.

## Presentation and moderation

**Presentation** is how a player's name looks everywhere it appears: a color
and a short badge, such as "Admin". **Moderation** is removing players: a kick
lets them come back, a ban doesn't.

```js title="room.js"
room.on("player-join", ({ player }) => {
  if (isAdmin(player)) {
    void player.setPresentation({ color: "accent", badge: "Admin" });
  }
});

// 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-permissions) for
every signature.
