Size comparison
September 4, 2026 · View on GitHub
The README claims general-purpose formats pay for their generality on the wire. This is that claim, measured, on one representative packet.
Every number here is produced by running the real encoder. Nothing is
computed by hand, and nothing is read out of a compiler constant. The schemas,
the input values and the script are all in comparison/ — run
./comparison/measure.sh and you should get this table.
One packet is not a benchmark suite. It is a worked example you can check line by line, which is worth more than a table of numbers you cannot.
The packet
A ship-spawn packet: type, quantized position, quantized rotation, quantized
velocity, some flags, a team, health and thrust. It is the corpus's
ShipCreate from examples/Types.schema, unchanged.
type ShipCreate
{
ship_type ShipType // 6 wire values incl. None
position QuantizedPosition // 3 x int32, each ±8388608
rotation QuantizedRotation // 4 x int16, each ±1024
linear_velocity QuantizedVelocity // 3 x int32, each ±2097152
has_flags bool
if has_flags
{
flags ShipFlags // 4 flag bits
}
team Team // 3 wire values incl. None
health int16 | min = 0, max = MaxHealth // MaxHealth = 1000
thrust int8 | min = 0, max = 100
}
The exact values encoded are in comparison/VALUES.md.
Two choices there deliberately work against schema: the has_flags branch is
taken (the longest wire path), and the values are large and non-zero (varints
and Cap'n Proto packing both shrink on small or zero values, so encoding zeroes
would have flattered schema considerably).
Results
| format | bytes | vs schema |
|---|---|---|
| schema | 28 | — |
| Cap'n Proto (packed) | 52 | 1.9× |
| Protobuf (proto3) | 56 | 2.0× |
| FlatBuffers | 72 | 2.6× |
| Cap'n Proto (unpacked) | 96 | 3.4× |
Measured with protoc 35.1, capnp 1.5.0, flatc 25.12.19 and the schema
compiler at this commit.
Where schema's 28 bytes go
The measurement program encodes the packet, round-trips it back, and reports
the writer's own byte count — 219 bits, which is exactly the compiler's
ShipCreateMaxBits. Those bits:
| field | bits | why |
|---|---|---|
ship_type | 3 | 6 wire values |
| `position$ (3 \times 25) | 75 | 16777217 \text{values} \text{per} \text{axis} |
| (4 \times 12) | 48 | 2049 \text{values} \text{per} \text{component} |
| (3 \times 23) | 69 | 4194305 \text{values} \text{per} \text{axis} |
| $has_flags` | 1 | |
flags | 4 | one bit per declared flag |
team | 2 | 3 wire values |
health | 10 | [0, 1000] is 1001 values |
thrust | 7 | [0, 100] is 101 values |
| total | 219 bits = 28 bytes |
Nothing is spent identifying a field, because both sides were generated from
the same schema. health is 10 bits because it was declared | min = 0, max = 1000, not because anyone hand-packed it.
What the others are buying
The gap is not waste. Each format is spending those bytes on something schema does not offer, and if you need that thing the price is fair.
Protobuf — 56 bytes — buys field-number evolution. Every field carries a tag, so old readers skip unknown fields and missing fields take defaults. That is why Protobuf is right for service APIs that version independently over years. This wire has no tags at all; it has a protocol id and the rule that both peers deploy together. schema's answer for the independently-versioned case is its OTHER wire, where a table's fields ride under the hash of their names and a message stream announces its vocabulary once a connection — a different measurement, on different values, in SPEC-TABLES.md §3.3.
FlatBuffers — 72 bytes — buys zero-copy access. The root offset, vtable and
alignment padding are what make reading a field without parsing the buffer
possible. If you mmap a large asset and touch three fields, FlatBuffers wins
outright and this comparison is the wrong one to be reading.
Cap'n Proto — 52 packed, 96 unpacked — buys the same in-place model, plus an RPC system schema has no answer to. Its packed encoding is a general-purpose zero-suppression pass over the unpacked layout; it does well here (52) and would do dramatically better on a mostly-zero packet. Note that packing costs a compression pass, so the 52 is not free the way the 96 is.
schema wins this table by knowing the bounds. health cannot exceed 1000, so
it is 10 bits — no general-purpose format can make that inference, because you
never told it. That is the whole trick, and it stops working the moment your
values are unbounded.
Reproducing this
./comparison/measure.sh
Needs protoc, capnp and flatc on PATH, a C++ compiler, the
serialize runtime as a sibling
checkout, and make run once at the repository root to emit the generated C++.
What this does and does not show
It shows the wire cost of one gameplay packet, which is the case schema is built for: small, highly-constrained values sent at high frequency.
It does not show throughput — see PERFORMANCE.md for that — and it does not generalise. A packet of strings and unconstrained 64-bit integers would narrow the gap sharply, and a mostly-zero packet would favour Cap'n Proto's packing far more than it favours schema. Bit-packing wins where values are bounded, and bounds are what a game's data has and a general-purpose format cannot assume.