FAQ

September 7, 2026 · View on GitHub

Blunt questions, honest answers. Where something is a real limitation, it says so, and says who should use something else.

Isn't this just FlatBuffers?

No, and the difference is the one that matters most for gameplay traffic.

FlatBuffers is zero-copy; schema is not. A FlatBuffers buffer is accessed in place through offsets and a vtable — you never parse it. schema does parse: a read decodes bits into your struct. That is a real cost FlatBuffers does not pay, and if random access into a large buffer without decoding is what you need, FlatBuffers is the better tool.

What you get for that cost is size. FlatBuffers is byte-aligned and carries vtables and offsets, because that is what makes in-place access work. schema is bit-packed with no framing at all: a field declared | min = 0, max = 1000 occupies 10 bits, an enum with three variants occupies 2, and a branch that is not taken occupies nothing. There is no vtable, no offset table, no field identifier on the wire — both sides know the layout because the same compiler generated both.

And validation is not a separate artifact that can lag. This is the difference that matters most if the data is untrusted. In FlatBuffers, verifying a buffer is a distinct piece of generated or hand-written code per language — so verifier support varies by port, and a port without one cannot safely accept a packet from the network at all. You are then choosing between "trust the bytes" and "do not use this language".

In schema there is nothing to omit. Refusing an out-of-range value is not a verification pass you run first, it is what the read does: the bound is part of the type, so the generated reader checks it inline in every language, because one compiler emitted all nine. There is no such thing as a schema port that reads but cannot validate.

For a 60 Hz gameplay packet where you decode the whole thing anyway, that trade runs strongly one way. For a memory-mapped asset you want to touch three fields of, it runs the other — and schema answers that case on its other wire: declare a table, and schema cook produces a build-locked region whose Open is a header match and a pointer, with no per-node validation and no fix-up at load, so you mmap the file and read the three fields at their offsets (SPEC-TABLES.md §7). The tolerant table wire stays the format of record beneath it, and the type wire above serves the realtime packet; generated storage on both is relocatable and memcpy-able (see USAGE.md).

Isn't this just Protobuf?

No — and in one specific respect schema is deliberately less capable.

Protobuf's central design is field numbering and evolution. Every field carries a tag, so old readers skip fields they do not know and missing fields fall back to defaults. That is why Protobuf is the right answer for service APIs that version independently over years.

The schema TYPE wire has no field numbers, no tags and no evolution machinery at all. Versioning is a protocol id — a hash of the schema itself, checked once at connect time. Two peers on the same id speak identical bits; two peers on different ids should not talk. That is an intentionally harsher contract, and it buys the thing Protobuf cannot give you: nothing on the wire identifies a field, so nothing on the wire is spent identifying one.

If your client and server ship independently and must interoperate across versions over one connection, the type wire will fight you and you should not use it — but the answer is not another library. That case is what the TABLE wire is for, and the MESSAGE FORM is its stream shape: a peer announces its unit's whole vocabulary once a connection and then carries none of it, so a four-field login is 58 bytes against proto3's 49 rather than the 106 the file form costs (SPEC-TABLES.md §3.3). What schema still declines beside Protobuf is RPC: there is no service definition, no stub generation and no request-response machinery, so a stack that wants those brings its own or brings Protobuf. If client and server ship together, which is the normal case for a game client and its dedicated server, the tags were pure overhead and the protocol id is the honest statement of what was always true.

That narrowness is one of TWO wires, and the other one is the evolution answer: declare a table and its fields are identified by the hash of their NAMES, so a reader takes any data a writer ever wrote — unknown fields skipped and counted, absent fields defaulted, changed kinds skipped rather than misdecoded, a kind that merely grew decoded exactly and counted widened, out-of-range values clamped, and every event in a read report. Nothing is fatal below the whole wire: framing damage stops the damaged nesting level, keeps what it decoded there, and the parent reads on past the field's own length. Two things do stop the file, and both DECODE NOTHING rather than mangle anything: a trailer the reader cannot read whole, and a form byte the reader does not know, which is a refusal by name and moves no counter at all (SPEC-TABLES.md §3, §4). Save games, config, asset archives, tool output and backend messages belong there; packets belong on the type wire. What schema does not offer is Protobuf's model of ONE wire that does both.

Isn't this just Cap'n Proto?

No, and for much the same reason as FlatBuffers. Cap'n Proto's premise is that the in-memory layout is the wire layout, so there is no encode/decode step. schema encodes.

Two further differences worth being precise about. Cap'n Proto is byte- and word-aligned by design — alignment is what makes the in-place trick sound — where schema packs to the bit. And Cap'n Proto brings a large surface: RPC, promise pipelining, capabilities, an ecosystem. schema brings a language and nine code generators, and nothing else.

On size: on the gameplay packet in COMPARISON.md, Cap'n Proto is 96 bytes unpacked and 52 packed, against schema's 28. Packed is the closest of the three general-purpose formats — its zero-suppression pass is genuinely good, and on a mostly-zero packet it would beat schema's fixed bit-widths outright. It costs a compression pass that schema's writer does not run, and it cannot know that your health field stops at 1000.

If you want the in-place model or the RPC system, use Cap'n Proto. schema is the narrower tool.

Do you have numbers against Protobuf, FlatBuffers or Cap'n Proto?

Yes — COMPARISON.md. The same gameplay packet:

schemaCap'n Proto (packed)ProtobufFlatBuffers
28525672

Every number is produced by running the real encoder — protoc, capnp, flatc and schema's own writer — and the schemas, values and script are committed in comparison/ so you can re-run it rather than trust it.

It also says what those extra bytes buy, because they are not waste: Protobuf's overhead is field-number evolution, FlatBuffers' and Cap'n Proto's is zero-copy access. If you need either, that is a fair price and schema does not offer it. The values encoded are deliberately large and non-zero — a mostly-zero packet would favour Cap'n Proto's packing far more than it favours schema.

So what is actually novel here?

Honestly: not the idea of generating serializers from a schema. That is old. Three things in combination are unusual:

  1. Bit-level bounds as part of the type. | min, max is not validation bolted on — it determines the wire width. Most formats give you a uint32 and store 32 bits.
  2. Fixed point as a first-class type. fixed(48, 16) — and its unsigned sibling ufixed(48, 16) — is declared like any other field, and the compiler owns storage and wire. Floating point is not bit-identical across compilers and architectures, so lockstep simulation, rollback and deterministic replay cannot be built on floats. No mainstream format offers a fixed-point type; you store an int and remember the scale yourself, in every language, forever.
  3. Nine languages proven identical mechanically. Every CI run generates the corpus in C, C++, C#, Dart, Elixir, Go, Java, JavaScript and Rust and compares the emitted wire against pinned goldens. Cross-language agreement is checked, not asserted.

Is bit-packing worth the CPU? Bandwidth is cheap.

Sometimes it is not, and you should know which case you are in.

Bit-packing costs shifts and masks and saves bytes. If your bottleneck is CPU and you have bandwidth to spare, that is the wrong trade — and if you are sending a few large packets rather than many small ones, the saving is small anyway.

It pays when you are sending small, highly-constrained values at high frequency to many peers, which is what gameplay state is: a health that cannot exceed 1000 is 10 bits, not 32, and at 60 Hz × N players that difference is the bandwidth bill. It also pays where bandwidth is genuinely scarce — mobile, console certification limits, egress pricing.

The honest framing: schema makes the trade available and cheap to express. Declaring a bound is a few characters, and the compiler does the rest. Whether your workload wants it is your call.

Your own benchmarks say Go is 3× slower than C++. Why would I use this in Go?

Because the alternative in Go is not C++ — it is hand-written Go, or reflection.

The table in PERFORMANCE.md is relative to C++, and C++ is the fastest thing in the comparison. Generated Go is straight-line code over a reused buffer with no reflection and no allocation, which is materially faster than encoding/gob, encoding/json, or reflect-based Protobuf paths — and crucially it is identical on the wire to the C++ your client runs.

If raw serialization throughput is your server's bottleneck, the honest advice is that language choice matters more than serializer choice.

What happens when I change a schema?

The protocol id changes, and peers on the old id will refuse the new one. That is the design: the id is a hash of the schema, so a changed format is a changed identity.

Practically this means client and server deploy together for type-wire changes. Data that cannot deploy together goes in a table instead: editing a table moves the build version and never the protocol id, so a save game or an asset written by an older build still loads, with the differences counted in a read report (SPEC-TABLES.md). If your PACKETS have to interoperate across independently deployed versions, that is the case this design does not serve, and Protobuf does.

The id hashes a wire shape projection, not the source text, so an edit that moves no bytes does not move the id: a comment, a blank line, a renamed file, a renamed const. Renaming an enum variant, a flags variant or a union arm is not in that list: the ordinal is the wire and the ordered names are the only record of which ordinal means what, so a reorder — and therefore a rename — moves the id.

Run schema projection to see exactly what it depends on — it is deliberately printable, because a wire-affecting fact missing from that text would be a fact the id ignores.

Why AGPL? My lawyer will hate this.

The compiler is AGPL-3.0. The code it generates is explicitly not, and that carve-out is intentional and permanent — running the compiler over schemas you own does not make your generated serializers derivative works, and they are yours under whatever terms you ship.

The plain reading: use it in a closed-source game freely; modify the compiler itself and run it as a service, and the AGPL applies to those modifications.

That carve-out is not a README paragraph — it is an ADDITIONAL PERMISSION at the top of LICENSE itself, which is where it has legal force. Point your legal team at the file rather than at this answer. It is modelled on the long-standing practice for compiler-like tools whose output is not covered by the tool's own licence: the Bison parser exception and the GCC Runtime Library Exception.

If you need something beyond that — a commercial licence, or a written assurance for a procurement process — open an issue.

Who maintains this? What if you stop?

It is a small team's library, used in a real game rather than written as a demo. That is worth exactly as much as you think it is.

Two things that reduce the risk if it were abandoned: the output is ordinary source code in your repo with no runtime dependency on the compiler, and the generated C, C++, C#, Dart, Go, Java, JavaScript and Rust reads like the code you would have written. If the project stopped tomorrow, you would still have working serializers and could maintain them by hand. That is a materially different exposure from depending on a runtime library.

Only nine languages. What about Python, Swift?

Not supported today. The nine exist because they are what the authors ship in: C++ engine, C# for Unity, Go for backend services, Rust for tooling, JavaScript for the browser client, Dart for Flutter clients, Elixir for the BEAM realtime services, Java for the JVM side of the estate.

A new backend is a Go package that walks the same IR the existing nine consume, and the cross-language test harness would tell you immediately whether it agrees with the others bit for bit. That is the mechanism, but it is real work and nobody should pretend otherwise.

Is it safe to put on an internet-facing packet path?

That is what it is designed for, and the specific guarantee is: a read refuses out-of-range input rather than clamping or trusting it. Ranged values, array counts past their bound, string and bytes lengths past their maximum, enum values that are not variants, and reads that run past the end of the buffer all fail the read, and the same rules hold in all nine languages because one compiler emitted all nine.

What it does not do: it is not a transport, so it does nothing about replay, amplification, rate limiting or authentication. Those belong to the layer below.

The compiler is fuzzed — a native Go fuzz harness drives parse → check → generate across the backends, and every crasher ever found is committed as a permanent regression input. Generated readers are exercised against hand-crafted hostile bytes in the cross-language test corpus.

Do writes validate like reads do?

No. The guarantee is on reads — that is where untrusted input arrives, and it holds in all nine languages.

On the write side each language uses its own correctness idiom: C++ and C have assert/NDEBUG, a check that disappears in release, so that is what they use; Go has no assert idiom, so it returns ErrValueOutOfRange, and the BEAM has no dormant assert at all, so Elixir raises ArgumentError in every build. Those two are the only every-build write side of the nine. The other seven are debug-only: C# through Debug.Assert, Rust through debug_assert!, Dart and Java through the language's own assert, and JavaScript through the checked/production fork its flat writers take at load. A language should verify correctness the way that language verifies correctness — which means the write side is not uniform across targets, and you should not build on it. Keep values inside their declared bounds when you write them — your code already knows they are, and in a game shipping at 60 Hz re-checking every field on the write path is a cost with no buyer. See USAGE.md.

Is everything supported in all nine languages?

Yes, on the PACKET wire. It is generated for C, C++, C#, Dart, Elixir, Go, Java, JavaScript and Rust from one IR, and checked against each other in CI on every push. Every target's output is held to the same pinned goldens.

The table wire is not there yet, and the state is one sentence. All nine carry the FIXED class on the wire, the text form, the cook's open, the block's read and the descriptors; the variable class is the C++ reference's and the tool's; and the id-table wire itself is the reference's and the tool's, with the eight ports still writing its previous form (#511 to #518). The parity gate is #366.

Parity means everything: fixed point, 128-bit integers, unions and their generated tag surface, in every target. Rust's #[repr(C)] storage is what makes relocatability actually true there rather than incidental.

One type is carried by ONE target rather than nine, so it is named rather than counted as parity: wstring(N), the wide-text type, whose reader rules, storage and goldens are SPEC.md §4.12. C++ carries it on both wires — the packet wire's 32-bit groups and kind 33 on the table wire (SPEC-TABLES.md §3) — and the other eight refuse a unit that declares one, by name.

What does the language NOT have?

Worth knowing before you adopt rather than after:

  • No maps on the packet wire. Use a counted array of key/value pairs. Tables have them — map[K]V (SPEC-TABLES.md §2.8), a lookup over entries the wire carries as a sorted array, in the C++ reference and the tool.
  • No unbounded collections on the packet wire. Everything the type wire carries has a declared bound, and a type body refuses the unbounded spellings by name. A table body takes an unbounded map and an unbounded []T, because a table reader owns its own allocation (SPEC-TABLES.md §2.8, §2.9).
  • No recursive types. type Node { children [..4]Node } is rejected as a composition cycle — generated storage is by value, with no pointers, which is what makes it relocatable and memcpy-able. A table may point at a table, itself included, which is what a scene graph or a linked list wants (SPEC-TABLES.md §2.1).
  • No optional / nullable on the packet wire, except the honest version: a bool and a branch, or a union whose first variant is the absence. In a TABLE body it is spelled ?T — the value plus a generated presence bool, fixed size, no allocation — and a type body refuses one by name.
  • No schema evolution on the packet wire. No field numbers, no unknown-field skipping, no cross-version bridging — one protocol id, same-or-refuse, on purpose. Evolution is the table wire's whole subject.
  • No zero-copy access on the packet wire. Reads decode into your struct. A cooked table is the zero-copy form: Open matches a header and points (SPEC-TABLES.md §7).

Most of these are the PACKET wire's scope — a 60 Hz packet needs none of it, and the table wire is where the same questions get a yes. Recursion in a type is the one that is a consequence of relocatable storage rather than a choice. The list is here so you can tell which of your requirements are unmet before you find out the hard way.

Do I need the serialize runtimes?

In six of the nine languages, yes; in three, no.

C, C++, C#, Go, JavaScript and Rust target a small runtime per language (serialize, serialize.c, serialize.cs, serialize.go, serialize.js, serialize.rs). They are small, open source, and doing the bit-level stream work the generated code calls into. Dart, Elixir and Java need nothing but their own toolchain — those three carry the bit reader and writer in the generated code itself, and are held to the same goldens as the other six.

JavaScript is the one that goes both ways: generated JS never IMPORTS a runtime — every wire call in the runtime tier is a method on the stream you pass in, and that stream comes from serialize.js — while the flat tier beside it (<Base>Flat.js) inlines the bitpacker and needs nothing at all.

How do I get out if I regret it?

Delete the compiler and keep the generated files. They are plain source in your repo with no dependency on the compiler at build time or run time; from that point they are ordinary hand-maintained serializers. The runtime dependency stays, or you inline the parts you use.


Something not answered here? Open an issue — questions that recur belong in this file.