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

TypeBytes on the wire
bool, u8, enumOf(...)1
u162
u32, i32, f324
f648
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 field1 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.

PartBytes
An update op1 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 op1 tag, the id, and the owner id, then every field in full. No mask
A remove op1 tag and the id
Each frame1 envelope byte, 12 fixed header bytes, and 1 byte for the collection count
Each collection changed in a frame2 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.

WhatSize
One player’s update op1 tag + 9 id + 1 mask + 8 fields = 19 bytes
20 players in one frame380 bytes, plus 15 bytes of header = 395 bytes
At tickRate: 20about 7.9 kB/s to each client
At tickRate: 60about 23.7 kB/s to each client

Three levers come out of that, in the order worth trying:

  1. Lower the tick rate for what does not need the higher one. This scales everything at once, framing included.
  2. Change fewer entities per tick. An entity that did not move costs nothing at all, because a flush that changes nothing sends nothing.
  3. 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 name is written once. Even a str(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. A u16 holds 0 to 65535 in 2 bytes where f32 takes 4. An enumOf is always 1 byte, where the same state written as a str is 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