# Moderation

> Kick and ban players from room code, ban them from a project, and kick or mute them from your backend, the dashboard or the CLI.

A room can kick a connection and ban a player from itself in room code,
which suits games where players run their own rooms. To act from outside
a room, from your backend, the dashboard or the CLI, use bans, kicks and
voice moderation through the [HTTP API](/docs/http-api). Each takes an
API key, a personal token or a dashboard session.

Players are named by their player ID: your [player
token's](/docs/player-tokens) `sub`, or a guest's `guest:` ID. A room
sees it as `connection.player`.

## In room code

| Call | Close code | Kept | Scope |
|---|---|---|---|
| `room.kick(conn, reason?)` | 4006 | no; the player can join again | one connection |
| `room.ban(player, { reason?, expiresAt? })` | 4014 | in room state, across hibernation | this room |
| A project ban, below | 4013 | by the control server | every room of the project |

The client sees the code as `IrtError.code` and the room's `reason` as
`IrtError.reason`, so it can say why. A banned player is turned away
before `onJoin` runs, and never shows in other clients' presence; so is a
connection the room kicks in `onJoin` before any `await`.

This room lets its owner, the player whose token says so, kick and ban,
and turns banned players away with a message:

```ts
// moderated-room.ts
import { CallRejected, defineCalls, defineRoom, defineSchema, entity } from "@irt/room";

const calls = defineCalls<{
  ban(args: { player: string; reason: string; hours?: number }): null;
  unban(args: { player: string }): boolean;
}>();
const schema = defineSchema({ state: entity("lobby", { topic: "string" }), calls });

const isOwner = (meta: unknown) => (meta as { owner?: boolean } | null)?.owner === true;

export default defineRoom({
  schema,
  calls: {
    ban(room, conn, { player, reason, hours }) {
      if (!isOwner(conn.meta)) throw new CallRejected("only the room's owner can ban");
      room.ban(player, { reason: reason.slice(0, 100), expiresAt: hours ? Date.now() + hours * 3600_000 : undefined });
      return null;
    },
    unban(room, conn, { player }) {
      if (!isOwner(conn.meta)) throw new CallRejected("only the room's owner can unban");
      return room.unban(player);
    },
  },
});
```

On the client, tell kicked and banned players apart:

```ts
// closed.ts
import { CloseCode, type RoomClient } from "@irt/client";

export function onClosed(room: RoomClient, show: (text: string) => void) {
  room.on("closed", (e) => {
    if (e.code === CloseCode.RoomBanned) show(`You're banned from this room${e.reason ? `: ${e.reason}` : "."}`);
    else if (e.code === CloseCode.Kicked) show(`You were removed${e.reason ? `: ${e.reason}` : ""}. You can rejoin.`);
    else if (e.code === CloseCode.Banned) show("You're banned from this game.");
  });
}
```

To hide players from each other, such as after a block, give the room
`seesPresence(room, viewer, entry)` beside an interest `sees`: presence
then follows the same rule as entities. See
[Presence](/docs/reference/room-sdk#presence).

Room code mutes a player's voice with `room.muteVoice(player, {
expiresAt })`, kept in room state like a ban: the room leaves the player
out of every listener's speakers, and the voice server stops it
publishing until the mute ends. See
[Proximity voice](/docs/guides/proximity-voice#muting-a-player). Room
code has no call for project bans; call the HTTP API from your backend.

## Bans

A ban keeps a player out of every room of the project, for good or until
it expires.

```sh
curl -X POST https://api.irt.io/v1/projects/my-game/bans \
  -H "Authorization: Bearer $IRT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"player": "u_42", "reason": "cheating", "expiresAt": "2026-11-11T00:00:00Z"}'
```

- The router refuses a banned player's connections with close code
  4013, which the client SDK doesn't retry. Connections the player has
  when the ban is made close with 4013 within a few seconds.
- With a banned player's token, the client HTTP API's room creation,
  join code lookup and public room list answer `403` with code `banned`.
- `expiresAt` is optional; without it the ban is permanent. Once it
  passes, the player can connect again.
- Banning a player again replaces the ban's reason and expiry.
- A project can have 10,000 active bans. Expired bans don't count.

Banning a guest keeps out only that guest ID, and a guest gets a new one
by clearing the browser's storage. A game that needs bans to stick should
sign its own player tokens and turn guests off.

On the client, tell a banned player why they can't join:

```ts
// banned.ts
import { CloseCode, connect, defineSchema, entity, IrtError } from "@irt/client";

const schema = defineSchema({ state: entity("table", { players: "u8" }) });

try {
  const room = await connect({ router: "wss://eu.irt.io", project: "my-game", room: "main", schema });
  room.on("closed", (error) => {
    if (error.code === CloseCode.Banned) console.log("you were banned from this game");
  });
} catch (e) {
  if (e instanceof IrtError && e.code === CloseCode.Banned) console.log("you are banned from this game");
  else throw e;
}
```

## Kicks

A kick closes a player's connection to one room with close code 4006,
the same as `room.kick`. The client doesn't reconnect by itself, but the
player can join again; ban them to keep them out.

```sh
curl -X POST https://api.irt.io/v1/projects/my-game/rooms/forest-7/kick \
  -H "Authorization: Bearer $IRT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"player": "u_42"}'
```

It answers `204`, or `404` when the player isn't connected to the room.

## Voice

With [proximity voice](/docs/guides/proximity-voice) on, a player's voice
can be muted for everyone in a room, or the player disconnected from the
room's voice:

| Path | Body | Does |
|---|---|---|
| `POST /projects/{p}/rooms/{id}/voice/mute` | `{player, muted}` | Mutes or unmutes the player's microphone |
| `POST /projects/{p}/rooms/{id}/voice/remove` | `{player}` | Disconnects the player from the room's voice |

Both answer `204`, or `404` when the player isn't in the room's voice.
The mute applies to the player's tracks as they are, and its client can
unmute them; for a mute that holds, have the room call `room.muteVoice`.
Removing a player from voice doesn't stop them rejoining it with their
voice token; kick or ban them for that.

## In the dashboard and the CLI

The dashboard's Bans tab lists a project's bans and adds and removes
them, and each active room on the Rooms tab has a Moderate panel for
kicks and voice. In a terminal:

```sh
irt bans list
irt bans add u_42 --reason cheating --for 7d
irt bans remove u_42
```
