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 can | It cannot |
|---|---|
| Write any type-valid value to an instance it owns | Write a serverOwned collection or any singleton |
| Write as often as the frame limit allows, 240 frames/s per connection with a burst of twice that | Write an instance another client owns |
| Call any server RPC you declared, with any type-valid parameters | Call an RPC that is not in the schema |
| Read every byte the server sent it, including values your UI hides | Receive a collection its role may not see |
| Reconnect, rejoin, and open several sessions | Pick 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.
| Return | Meaning | Use it for |
|---|---|---|
next | Accept as sent | A move that is fine |
prev | Reject. The owner snaps back to the previous value | A move that could not have happened |
| A new object | Accept a corrected version. The owner snaps to what you returned | A 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:
- Give the secret its own collection and scope it.
visibility: 'role'withroles: ['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. - 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-storminvariant fails above 5 corrections/s per bot. A deliberate cheater passes that immediately.--corrections-max 25raises it for the run. Read theHOLEline, not the exit code, as the verdict onvalidate. - 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 simulateprints 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
- Ownership for who may write what.
- Server authority and validation for
validateand the tick loop. - Visibility for keeping a value off the wire.
- Invariants and load runs for the rest of
irtio simulate. - Debugging corrections when a rule turns out to be too strict.