Moderation

Four tools, in order of how far each reaches. Pick the shortest one that covers the problem.

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, they reconnect immediately
room.banRoom codeOne player, for this roomNo, a hibernation clears it
Eject MSGRelay roomsOne peer, for this roomNo
Project banControl APIOne player, every room of the projectYes

The first three are about what a player did. The last two are about whether a player is welcome.

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');
    if (state.round.phase !== 'lobby') 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
rolestringResolved by the same rules onJoin sees
namestringWhat the client asked to be called
ticknumberThe tick this join is being decided at
deny(reason)neverThrows. Nothing below it runs

Three rules to know:

  • A resume does not re-run it. A player who already holds a seat and reconnects is admitted. Refusing a returning player because your policy moved while their train was in a tunnel is worse than being one over for a moment. Use room.kick or room.ban to end a session you want ended.
  • 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.

// 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 window lives in memory in that room, so a hibernation clears it. That is deliberate: this is a fact about a sitting. Exclusion that outlives a sitting is a project ban.

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 participants can send.

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

const room = await joinRelay({ room: 'ABCD', role: 'host' });
room.eject(clientId, 'spamming the vote');

Who may send it: in a room with an audience configured, any participant may eject, and an audience peer may not. A relay room has no host role that the platform can verify. It knows one role string per peer, taken from that peer’s own connection, so a rule written against role: 'host' would be a rule any viewer could satisfy by claiming that role. The audience role is different: it is assigned from a verified room-scoped credential, never requested. So the rule is written against the one thing the box can check. A relay room with no audience configured refuses ejects entirely.

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, without its irt: prefix. A project holds at most 10,000 live bans; expired ones do not count against that.

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.