Moderation

Choose a moderation action by its scope and how long the exclusion must last.

ToolWhereReachesSurvives a restart
validate and ctx.denyRoom codeOne write or one calln/a
onAdmitRoom codeOne join, before a seat existsn/a, it is a rule not a record
room.kickRoom codeOne sessionNo; the client can reconnect
room.banRoom codeOne player, for this roomNo, a hibernation clears it
room.ejectRelay roomsOne peer, for this roomNo
Project banControl APIOne player, every room of the projectYes

Refuse a join with onAdmit

onAdmit runs before a seat is allocated and before onJoin. Return to admit, call ctx.deny(reason) to refuse.

// room.ts
import { defineRoom } from '@irtio/server';
import { schema } from './schema';

export default defineRoom(schema, {
  onAdmit(state, ctx) {
    if (!ctx.subject) ctx.deny('sign in to play');
    // Let this room's own players back in after a dropped connection.
    if (state.round.phase !== 'lobby' && !ctx.reconnecting) ctx.deny('round in progress');
  },

  onJoin(state, ctx) {
    state.scores.add(ctx.clientId, { points: 0 });
  },
});

A denied join costs the room nothing: no presence row, no client entry, onJoin never runs, and the client’s connection fails with E_ADMISSION carrying your reason. Refusing inside onJoin instead means the player is already in the room and the only move left is to kick them out of it.

What ctx carries:

PropertyTypeNotes
clientIdstringThe id this join would be seated under
subjectstring \| undefinedThe verified identity subject. undefined for a key join
auth'key' \| 'jwt' \| 'assertion'The credential the join presented
rolestringResolved by the same rules onJoin sees
namestringWhat the client asked to be called
ticknumberThe tick this join is being decided at
reconnectingbooleantrue when a player is resuming the seat they already hold
npcbooleantrue for a room.spawnNPC session
deny(reason)neverThrows. Nothing below it runs

Three rules to know:

  • It runs on every resume, with ctx.reconnecting set. A player whose connection drops and comes back goes through the same checks as a stranger: the room.ban cooldown, then onAdmit. A refused resume ends the seat (onLeave runs with 'kicked') and the client gets E_ADMISSION. If your onAdmit refuses joins once a match has started, check ctx.reconnecting, as the example above does. Otherwise a player whose wifi blinks is locked out of their own match.
  • It is synchronous, like every other room hook, and held to a 50 ms wall-clock budget. A hook that blows the budget refuses the join and logs.
  • A throw is a refusal. A broken policy check never reads as “yes”.

End a session, and keep them out of the room

room.kick(clientId, reason) ends a session. The player reconnects a second later, because nothing about a kick says anything about the next join.

room.ban(clientId, options) kicks and then refuses the next join, at admission, before onAdmit runs. That includes a resume: the banned player cannot use their resume token to get back in, even from another tab whose seat the room is still holding.

// room.ts
rpc: {
  report(state, params, ctx) {
    if (!isModerator(state, ctx.clientId)) ctx.deny('not a moderator');
    const subject = ctx.room.ban(params.clientId, {
      reason: 'spawn camping',
      minutes: 30,               // omit for the life of the room
    });
    if (subject === undefined) {
      // Kicked, but they can come straight back. See the note below.
      ctx.room.log('banned a key-join client; the exclusion will not hold');
    }
  },
}

room.ban returns the subject it excluded, or undefined when there was nothing durable to exclude. The exclusion is held in room memory and cleared by hibernation. Use a project ban with the subject room.ban returned for an exclusion that must survive a restart.

Key joins cannot be excluded

A project-key join carries no identity. Its playerId is the client id its resume token holds, and a player who wants a new one closes the tab. So a kick works and a ban does not, and room.ban tells you which happened by returning undefined.

If you need players you can actually keep out, you need identities. See identity. Everything above works unchanged once a player joins with an assertion instead of a key.

Remove a viewer from a relay room

A relay room runs no code, so it has no onAdmit and no room.ban. What it has is a platform message the room’s creator can send.

// host.ts
import { joinRelay } from '@irtio/client';

// Join first, with an identity, so this client is the room's creator.
const room = await joinRelay({ room: 'ABCD', role: 'host', identity: true });
room.eject(clientId, 'spamming the vote');

The rules:

  • The creator ejects. The creator is the first participant to join the empty room, and it can eject only if it joined with an identity. A role name, host included, confers nothing: any client can type one.
  • Nobody ejects the creator, and nobody ejects a peer of equal or higher rank. A streamer who opened the room is safe from the rest of the room even when it joined with a key, for as long as it stays. A creator with an identity keeps its rank when it leaves and comes back; one that joined with a key does not.
  • Viewers never eject. Other participants are refused with E_AUDIENCE_SEND, and so is any eject in a room with no audience configured.
  • A room that slept loses its creator. After it wakes, nobody in it can eject.

The ejected peer is disconnected with E_EJECTED and its credential subject is kept out of that room until the room ends. As with room.ban, a peer with no verified subject is disconnected and not excluded. Know the limit: an anonymous viewer’s subject is random and minted per credential, so an ejected viewer who reloads gets a fresh subject and a fresh seat. Against anonymous audience an eject is a strong kick, not a ban; a durable exclusion needs a verified identity behind the seat.

See audience rooms for the rest of the crowd-room rules.

Ban a player from the project

A project ban is a row in the control plane, keyed on the player’s per-project subject. It is enforced at the mint: a banned subject cannot exchange its credential for an assertion, so it never gets the thing a box would have admitted.

# Ban. Omit ttlMs for a permanent ban.
curl -X PUT https://irt.io/v1/projects/$PROJECT/bans/$SUBJECT 
  -H "authorization: Bearer $IRTIO_TOKEN" 
  -H "content-type: application/json" 
  -d '{"reason":"repeated harassment","ttlMs":604800000}'

# List, oldest subject first. Page with ?after=<the nextCursor you got back>.
curl https://irt.io/v1/projects/$PROJECT/bans?limit=100 
  -H "authorization: Bearer $IRTIO_TOKEN"

# Lift.
curl -X DELETE https://irt.io/v1/projects/$PROJECT/bans/$SUBJECT 
  -H "authorization: Bearer $IRTIO_TOKEN"

The subject is the player id your room saw. Pass it as room.ban returned it, with the irt: prefix, or without it; the platform stores and lists it without the prefix. A project holds at most 10,000 live bans; expired ones do not count against that.

It holds against a reconnect too. A resume token is bound to the credential that joined, so a reconnect has to present that same subject again. A banned player cannot get a new assertion, so their resume is refused with E_RESUME_REFUSED once the assertion they hold expires (five minutes). The same goes for a fresh join.

Two things this does not do:

  • It does not evict a player who is already in a room. Their assertion was minted before you wrote the ban and stays valid until it expires, and ban lists are not pushed to boxes. Live eviction is your room’s move: room.ban for code rooms, an eject for relay rooms. The project ban is what stops them coming back tomorrow.
  • It cannot pre-empt an anonymous viewer. Audience credentials carry a random subject minted per request, so there is no name to write a ban against ahead of time. A bridge that verifies who a viewer is, and mints a subject that means something, is what makes ban lists bite for viewers.

A banned player is refused with the same error an invalid credential gets. That is on purpose: a distinct error would let anyone holding a credential ask the platform whether an account is banned from a project.

Designing a crowd game that resists griefing

Bans handle individuals. A stream’s audience is a population, and a population needs the game rules to hold up when a share of it is hostile. Three patterns that work:

Weight votes, do not count them. A raw count is a brigading target: a coordinated group only has to be larger than the honest crowd for a moment. Cap each viewer’s contribution per window, and prefer a share of the total over an absolute number, so a hundred extra accounts move the outcome by a hundred accounts’ worth and not more.

// Per viewer, per round: one vote, and the last one wins.
onMessage(state, from, target, bytes, ctx) {
  const choice = decodeVote(bytes);
  state.votes.set(from, { choice, round: state.round.n });
}

Reading the tally from a per-viewer collection instead of an incrementing counter also makes a double-send free rather than an exploit.

Cap what a viewer can do to the world, not just how often. Rate limits bound message volume; they do not bound damage. If viewers can spawn hazards, cap hazards per viewer per round and cap the total; if they can vote to skip, require a share of the current audience count rather than a fixed number of votes, so the threshold moves with the room.

Charge for churn, not just for messages. Leaving and rejoining is how a viewer resets anything you keyed on their session. Key what matters on the credential subject, which survives a rejoin within the window, and let a rejoining viewer inherit their old state rather than a fresh allowance. Where that is not practical, an in-room cooldown on rejoining under the same subject is one line, and it is the same mechanism room.ban uses.

Behind all three is the same rule as anti-cheat: the server decides outcomes. A viewer’s message is a request.