Designing a schema that fits
Your schema is a per-tick bill. Every field type has a byte width, every changed record carries framing, and every tick multiplies both. This page gives you the widths so you can add up a room before you build it, and the workflow for checking the real number afterwards.
The short version: only fields that changed are sent, framing is often bigger than the data, and a declared bound never costs you anything until you fill it.
Field widths
| Type | Bytes on the wire |
|---|---|
bool, u8, enumOf(...) | 1 |
u16 | 2 |
u32, i32, f32 | 4 |
f64 | 8 |
str(max) | 1 length byte plus the value’s UTF-8 bytes, up to 127. 2 length bytes above that |
ref('players') | 1 length byte plus the id’s bytes. Ids are capped at 32 bytes |
list(T, max) | 1 count byte plus every element in full |
struct({...}) | 1 mask byte per 8 fields, plus each field that changed |
any .opt field | 1 extra byte, and 1 byte total when the value is undefined |
Two of those rows decide most schemas.
max is a bound, not a reservation. A str(64) holding "ok" costs 3 bytes. The declared
maximum is validation only, so widening a bound to be safe costs nothing until a value fills it.
What costs you is a field that really carries 64 bytes on every tick.
A list is whole-replace. Writing one element re-sends the whole list, count byte and all. Keep lists short and fixed in size, such as a hand of cards or a four-option question, and model anything unbounded as an entity collection.
Fields inside a struct are tracked and sent individually. A three-field struct where one f32 changed costs 1 mask byte plus 4, not the whole 12.
Framing per record and per frame
Framing is the price of saying which record changed rather than what it changed to.
| Part | Bytes |
|---|---|
An update op | 1 tag, plus 1 length byte and the id’s bytes, plus 1 mask byte per 8 fields (the mask carries an extra owner bit, so a 7-field collection still fits in 1) |
An add op | 1 tag, the id, and the owner id, then every field in full. No mask |
A remove op | 1 tag and the id |
| Each frame | 1 envelope byte, 12 fixed header bytes, and 1 byte for the collection count |
| Each collection changed in a frame | 2 bytes |
A whole DELTA frame updating x and y on one entity with a 3-character id, in a collection with
5 fields:
envelope 1
tick + hash 12
collection count 1
collection index 1
op count 1
op tag 1
id "p12" 4 (1 length byte + 3)
dirty mask 1
x (f32) 4
y (f32) 4
--
total 30 bytes, of which 22 is framing That ratio is the whole design problem. Small updates to many entities pay for addressing, not for data.
Tick rate against payload
Multiply the per-frame size by tickRate, which defaults to 20 and takes an integer from 1 to 240
in irtio/room.ts.
Take a 20-player arena. Every player moves every tick, ids are 8 characters, and the collection has
5 fields, of which x and y change.
| What | Size |
|---|---|
One player’s update op | 1 tag + 9 id + 1 mask + 8 fields = 19 bytes |
| 20 players in one frame | 380 bytes, plus 15 bytes of header = 395 bytes |
At tickRate: 20 | about 7.9 kB/s to each client |
At tickRate: 60 | about 23.7 kB/s to each client |
Three levers come out of that, in the order worth trying:
- Lower the tick rate for what does not need the higher one. This scales everything at once, framing included.
- Change fewer entities per tick. An entity that did not move costs nothing at all, because a flush that changes nothing sends nothing.
- Narrow the fields. This is last because it only touches the 8 bytes in that op, not the 11 around them.
What a wide field costs
A wide field is only expensive when it is both full and changing. Check it against these three questions:
- Does it change? A player’s
nameis written once. Even astr(64)name costs nothing per tick, because it is never dirty again. - Is it as wide as it looks? A
str(24)holding a short id is 1 byte plus the id. - Could it be narrower? A position in a 4096-unit world does not need
f64. Au16holds 0 to 65535 in 2 bytes wheref32takes 4. AnenumOfis always 1 byte, where the same state written as astris the string.
The expensive case is a field that carries real bytes on every tick: a stringified id, a serialised blob, a list that is rewritten each frame.
When to split a collection
Split when the halves are wanted by different people or at different times.
Split for visibility. Visibility is per collection and never per field. A secret needs its own collection, and so does anything only one role should receive. In a 200-player arena, a spatially filtered player receives about 2.7 kB/s where a full-view spectator receives about 68 kB/s.
Split hot from cold. Records that change every tick and records that never change are cheaper apart, because a spatial collection re-sends a whole record every time it crosses a client’s boundary. A narrow moving record crosses more cheaply than a wide one.
Do not split just to narrow the dirty mask. A collection stays at 1 mask byte up to 7 fields, and 2 up to 15. Splitting adds 2 bytes per frame for the extra collection, which usually costs more than it saves.
Keep the anchor rule in mind. A spatial-grid collection filters against a record in the same
collection, so players and pellets that must filter together belong in one collection with a kind field.
Finding the real answer
Adding up widths tells you the shape of the bill. The bandwidth profiler tells you the bill.
npx irtio dev --profile It breaks the traffic down per field, per correction, per RPC, and, importantly, per framing row.
Read the overhead rows first. A large op share means many small updates, which the tick rate
and the number of changing entities fix. A large delta-header share means too many frames.
Read the profiler before you optimise anything. A total tells you whether you have a problem. Only the breakdown tells you which of the three levers above to pull.
Setting a budget
irtio simulate checks a bandwidth invariant on every run and fails the build over it. The
default budget is 128,000 bytes per second inbound per client.
npx irtio simulate --bots 20 --seconds 30 That default is a ceiling, not a target. Pick your own number from what your players are on, then assert against it in a test:
// irtio/simulate.test.ts
import { spawnBots } from '@irtio/bots';
import { schema } from './schema.js';
import { sweep } from './bot.js';
const runner = await spawnBots(20, {
url: process.env.IRTIO_URL ?? '',
schema,
script: sweep,
durationMs: 30_000,
budgetBytesPerSec: 32_000, // your budget, not the default ceiling
});
await runner.done();
const report = await runner.stop(); A run that passes at 128,000 while filtering silently does nothing is a run that told you nothing. Set the budget where a regression will actually trip it.
Next steps
- Bandwidth profiler for reading a breakdown.
- State and schema for the field types and their limits.
- Visibility for role and spatial filtering.
- Invariants and load runs for the
bandwidthinvariant and its flags. - Limits and metering for the ceilings you are budgeting against.