Anti-cheat

A cheating client is your own client with the checks taken out. It can send any value the schema’s types allow, for the instances it owns, as fast as the connection lets it. Everything else is already closed. This page closes the rest.

What a modified client can do

It canIt cannot
Write any type-valid value to an instance it ownsWrite a serverOwned collection or any singleton
Write as often as the frame limit allows, 240 frames/s per connection with a burst of twice thatWrite an instance another client owns
Call any server RPC you declared, with any type-valid parametersCall an RPC that is not in the schema
Read every byte the server sent it, including values your UI hidesReceive a collection its role may not see
Reconnect, rejoin, and open several sessionsPick its own role when the join carried a JWT role claim

Everything in the left column is your problem. The value that arrives is bounded by its declared type and by nothing else: f32 knows nothing about your 1000-unit world, and u16 knows nothing about a score of 40.

The rules that close it

Four decisions, in this order.

1. Anything a lie would win is serverOwned. Score, turn order, hit results, inventory, what is in the deck, who won. A serverOwned collection reads as DeepReadonly on the client, so writing one is a compile error, and a hand-written frame is refused with E_NOT_OWNER. Clients ask for changes through RPCs, and your handler decides what happened.

2. Every collection that stays client-owned gets a validate. Without one, anything that fits the declared types is accepted.

// irtio/room.ts
const WIDTH = 800;
const HEIGHT = 500;
const MAX_STEP = 60;

validate: {
  players(prev, next, ctx) {
    // Impossible: no legal input crosses MAX_STEP in one write. Reject the whole write.
    if (Math.hypot(next.x - prev.x, next.y - prev.y) > MAX_STEP) return prev;
    // Assigned at join, so the owner may not edit it.
    if (next.name !== prev.name) return prev;
    // Merely out of bounds: clamp and accept.
    return {
      ...next,
      x: Math.min(WIDTH, Math.max(0, next.x)),
      y: Math.min(HEIGHT, Math.max(0, next.y)),
    };
  },
},

3. RPC handlers check who is asking. ctx.clientId and ctx.role are there for it, and a throw is how a room says no. The caller’s promise rejects with your message and the room carries on.

start(state, _params, ctx) {
  if (ctx.role !== 'host') throw new Error('start: host only');
  state.match.phase = 'question';
},

4. A value a player must not have is a value you must not send. See hiding state below.

Clamping and rejecting

A validator returns one of three things, and the choice is not cosmetic.

ReturnMeaningUse it for
nextAccept as sentA move that is fine
prevReject. The owner snaps back to the previous valueA move that could not have happened
A new objectAccept a corrected version. The owner snaps to what you returnedA move that ran past a boundary

Returning anything other than next sends the owner a correction, which overwrites their local copy of those fields. So the cost of a rule that is too strict is paid by honest players on bad connections, as a snap they did not deserve. Clamp what is merely out of bounds. Reject only what could not have happened.

Two more things about validate. It runs on owner writes only, so a server write to the same collection never passes through it. And it sees whole instances, not single fields, so prev and next are the full record.

Speed limits

A speed limit is the most common rule and the easiest one to put in the wrong place.

Right for a character, a vehicle, or a dragged token. The game defines how fast the thing moves, so a jump past that is not a move. Position wins map knowledge and reach, so bounding the step is worth a rule.

Wrong for a mouse cursor. A pointer legitimately teleports when it moves fast, or leaves the window and comes back. A player gains nothing by putting their cursor somewhere. Clamp it to the canvas and stop.

Ask what a cheat would actually win. If the answer is nothing, do not write a rule that punishes a laggy player for free.

Hiding state in the UI is not hiding it

A client that receives a value has it. A card drawn face down, an answer key, another team’s position: none of them are hidden by not drawing them, because the value is sitting in the browser’s memory and the browser belongs to the player.

Two ways to actually hide it, both in visibility:

  1. Give the secret its own collection and scope it. visibility: 'role' with roles: ['host'] means every other role never learns the collection exists. Adds and removes are filtered too, so a hidden record’s arrival is not announced.
  2. Do not write the value into state until it should be visible. A quiz room can keep the correct answer in a constant in the room file and write it into state at the reveal. There is nothing to leak until then.

visibility: 'spatial-grid' is a bandwidth tool, not a secret keeper. An anchor that walks close enough sees everything.

Prove it with an adversarial run

irtio simulate --cheat makes the bots write type-valid, play-illegal values: the far end of a declared integer range, and a jump of 100,000 either way on a float. On a physics collection an honest bot writes intent fields only, while a cheating bot writes body fields too. Every one of those writes should draw a CORRECT from your room.

Cheating is per bot, which is the situation most rooms actually meet.

npx irtio dev                                                  # in one terminal
npx irtio simulate --bots 5 --cheat-bot 0 --corrections-max 25 # in another

The verdict is one line in the adversarial section:

  adversarial
  HOLE bot 0 cheated and drew 0 corrections: the room accepted every illegal write it sent.
       Add rules to `validate` in irtio/room.ts: reject by returning `prev`, or clamp the value.

A room with rules prints the other line instead:

  ok   bot 0 cheated and drew 36 correction(s): the room refused the illegal writes.

Three things to know before you read a result.

  • A cheat run that works fails by default. Every refused write is a correction, and the built-in correction-storm invariant fails above 5 corrections/s per bot. A deliberate cheater passes that immediately. --corrections-max 25 raises it for the run. Read the HOLE line, not the exit code, as the verdict on validate.
  • The honest bots should draw zero corrections. A validator that corrects legal play is a different bug, and the report separates the two.
  • The built-in invariants alone prove nothing about validate. They check the protocol: frames decoded, nobody saw what they should not, bandwidth inside budget, no tick overruns. A room that accepts every illegal write there is passes all of them.

Keep it in the repeatable form. A scenario takes cheat as a value or a function of the bot index, alongside conditions, so one hostile laggy client against four honest ones is two lines:

// irtio/scenario.ts
import { defineScenario } from '@irtio/bots';
import type { schema } from './schema.js';

export default defineScenario<typeof schema>({
  bots: 5,
  seconds: 6,
  seed: 41,
  cheat: (index) => index === 0,
  conditions: (index) => (index === 0 ? { rttMs: 200 } : undefined),
  script: async (bot) => {
    await bot.wait(500);
  },
  assert: (timeline) => {
    timeline.check('nobody left the arena', () => {
      for (const tick of timeline.ticks) {
        for (const id of timeline.at(tick).players!.ids()) {
          const p = timeline.at(tick).players!.get(id)!;
          if (p.x < 0 || p.x > 800) throw new Error(`player ${id} at x=${p.x} on tick ${tick}`);
        }
      }
    });
  },
});

The assertion reads the server’s own recorded timeline, so it proves the illegal value never reached authoritative state, not just that a correction was sent.

What irtio does not do for you

  • It does not judge behaviour. Aimbots, macros, scripted play and multi-boxing all send legal values. Nothing here detects them.
  • It does not verify the client. There is no attestation and no anti-tamper. Assume the client is hostile and put the decision on the server.
  • There is no lag compensation and no rewind. A write is judged at the tick it arrives. irtio simulate prints hit-registration rows showing where the server actually had a target when a shot landed, so you can see the size of the problem before deciding it is one.
  • The connection limits are abuse limits, not game rules. They stop flooding. They do not stop a player sending one very good lie.

Next steps