Protocol
The server and its generated client talk over one WebSocket in a binary protocol. Every message is a single bitstream, each value taking exactly the bits its range needs. The generated client and the front-end integration speak it for you; this page is for the curious and for anyone writing a client in another language.
Three rules shape the downward stream. Updates go out at the
send rate. Every update is
per viewer, holding only the objects relevant to that
player and only the fields they may see. And each update carries only what changed: the first
sight of an object brings its full public state, and after that it is mentioned only on the ticks a field of it
actually changes.
Messages
There are four message types, two up and two down. A frame's place in the stream gives its type: the client sends a hello, then commands; the server sends a welcome, then updates.
| Message | Direction | Description |
|---|---|---|
hello | client → server | The client's first frame. Carries the join fields the game collects. The game is the socket's path, so the server already knows it. |
cmd | client → server | Every client frame after the hello: one of the player's commands, the command index and its params. The only input that goes up. |
welcome | server → client | The server's first frame. The schema hash and your instance id. |
update | server → client | Every server frame after the welcome: the per-viewer state delta, built fresh each send tick from gone, appear and changed. |
hello
The client's opening frame, sent once when the socket connects. It carries the protocol version, the join fields, and a hash of the schema the client was built against, so the server can confirm the two share one definition.
| Name | Type | Length | Description |
|---|---|---|---|
| version | bits | 8 bits | The revision of the wire format. |
| schema hash | bits | 32 bits | A hash of the game's schema tables. |
| join fields | values | n bits | The join request, packed by the join schema. |
version
An 8-bit number marking the revision of the wire format. It is also folded into the schema hash, so a client built against a different protocol revision fails the hash check and the socket closes.
schema hash
A 32-bit hash of the game's field tables, computed the same way on both sides. It lets the server be sure the client packs values against the same schema it does; a mismatch closes the socket.
join fields
The join fields the game collects, packed in schema order by the rules in Encoding. Every join field is present.
cmd
One command from the player, the only input that goes up. It names the command and carries its params; the server applies it on the next tick of the instance and confirms it through an update's ack.
| Name | Type | Length | Description |
|---|---|---|---|
| command | bits | n bits | Which command this is, an index in ceil(log2(count)) bits. |
| params | values | n bits | The command's params, packed by the command schema. |
The command number is implicit: the client numbers its commands from zero and the server counts what it receives, so both sides hold it.
command
The command's index in the game's command list, the same order on both sides, so a few bits name it: ceil(log2(count)) for the number of commands.
params
The command's params packed in schema order by the rules in Encoding. A command sends all of its params.
welcome
The server's first frame back, sent once. It confirms the schema hash and names the instance you landed in. The tick rate, send rate and every other constant are baked into your generated client, so the frame carries only the hash and the id. The instance's live state arrives on netId 0's first update.
| Name | Type | Length | Description |
|---|---|---|---|
| schema hash | bits | 32 bits | The server's schema hash, for the client to check against its own. |
| instance | bits | 32 bits | The instance's full id, stable across players. |
Your own player is always netId 1, so both sides already hold your id.
schema hash
The server's copy of the schema hash. The client checks it against the hash baked into its own build; a mismatch means the two were built on different definitions and the client stops.
instance
The full id of the instance you were seated in. It is stable across players, so it names one instance the same way for everyone. In an update the instance's own state rides on the reserved netId 0, and its first update brings the instance's live state.
update
The per-viewer state delta, built fresh for each player every send tick. After a short header it carries three lists in order, objects that are gone, objects that just appeared in view, and fields that changed. The whole frame is one bitstream: gone and appear each open with a count then their entries, changed runs to the end of the frame, and the bits that route an entry sit inline with it. Gone comes first, so a departed id reads against the old count and its slot frees; changed comes last, so its ids read against the count that gone and appear leave.
| Name | Type | Length | Description |
|---|---|---|---|
| tick | bits | 8 bits | Ticks passed since your last update. |
| ack | bits | 8 bits | How many of your commands the server has applied. |
| gone | short + netIds | var | A count, then the netId of each object that left, in the width of the pre-update count. |
| appear | short + entries | var | A count, then the full state of each object newly in view. |
| changed | entries | var | The changed fields of each still-visible object, read to the end of the frame. |
tick
Eight bits: how many instance ticks have passed since the last update you were sent. Each client runs its own render clock from zero and adds this delta on every update. A tick that rides inside a field (a spawn tick, say) is sent relative to your own clock, the server offsetting it by the tick you arrived on. The client drives its render a fixed buffer behind its latest tick, so streamed objects and formula-driven ones share one clock.
ack
Eight bits: how many of your commands the server has applied, a count that wraps at 256. The client numbers its commands from zero (see Implicit) and uses this to retire an optimistic guess: once the ack passes a command's number, the authoritative state has caught up and the guess is dropped. An update is sent whenever the ack advances, so a guess is retired promptly whatever the command changed.
gone
A count, then one netId per object that left your view or was destroyed, each in the same width, set by the count of objects you hold as the update opens. Gone comes first so these ids read against that old count. The client drops each, then packs the higher ids down to close the gap (see Implicit). An entry is the id alone.
| Name | Type | Length | Description |
|---|---|---|---|
| netId | netId | k bits | The object to drop, in ceil(log2(count)) bits for the count before this update. |
appear
A count, then one entry per object newly created or newly in your view, in the order the server brings them in. Each entry opens with a bit for player or entity, then the entity type where a game has more than one, then the object's full public state packed by its schema. Both sides give a new object the next netId above the packed run, so its id is agreed (see Implicit). Your own player is netId 1, and its owner-only fields follow its public ones.
| Name | Type | Length | Description |
|---|---|---|---|
| kind | bits | 1 bit | Player or entity. |
| type | bits | t bits | For an entity in a game with more than one type, the type index in ceil(log2(types)) bits. |
| state | values | n bits | Every public field of the object in schema order, packed by Encoding. All fields are present. For your own player the owner-only fields follow the public ones. |
changed
One entry per still-visible object whose public state changed since it was last sent to you, read to the end
of the frame. An entry names the object by its netId, then
a bit per field of its kind saying which changed, then the values of just those fields. Changed comes last, so its ids
read against the count that gone and appear leave, and take ceil(log2(count)) bits for that final
count. A value that holds steady costs nothing. The instance's own state rides here too, on
netId 0.
| Name | Type | Length | Description |
|---|---|---|---|
| netId | netId | k bits | Which object changed, in ceil(log2(count)) bits for the count after gone and appear. netId 0 is the instance. |
| field mask | bits | N bits | One bit per field of the object's kind, set when that field is in this entry. |
| values | values | n bits | The set fields only, in field order, packed by Encoding. |
Implicit
Some of what the protocol runs on is held by both sides already, derived the same way on each, so it rides in no frame. These are the values the server and the client agree on without a wire byte between them.
| Value | How both sides hold it |
|---|---|
| instance netId | Always 0. The instance's own state rides on netId 0 in every update. |
| your player netId | Always 1. The appearing object that takes netId 1 is your own player, and its owner-only fields are yours to read. |
| other netIds | The ids in view stay packed as a run from 2 up. A new object takes the next id; when one leaves, the higher ids shift down to close the gap, the same step on each side. So the run has a known length, and a netId reference fits in ceil(log2(count)) bits. An update settles the count in order: gone reads against the count it opened with, then appear grows it, then changed reads against the count that leaves. |
| command number | The client numbers its commands from zero and the server counts what it receives. The update ack reports how many the server has applied; the client keeps up to 255 commands ahead of the ack, then waits. |
Encoding
Every message is one bitstream, read most significant bit first. Each value takes exactly the bits its range needs, known to both sides from the schema, so a value shorter than a byte stays shorter than a byte. The header fields have a fixed width, a netId is the log of how many objects are in view, and schema field values pack by the rules below.
bits
A value of known, fixed width. The protocol version, the tick delta and the ack are 8 bits each; the schema hash and
the instance id are 32; a command index is ceil(log2(count)) bits. The width is fixed, so both
sides read the same run of bits.
short
A count or a length, in seven-bit groups packed into the bitstream, each group led by a bit that says whether another follows. A value under 128 costs eight bits and a larger one grows a group at a time. It carries a list count and a string length.
number
A number with a min, a max and a decimals count is quantised to a whole
number of steps and written in exactly the bits those steps need. The step is 10−decimals;
the count of steps is round((max − min) / step) + 1; the value is stored as
round((value − min) / step) in ceil(log2(steps)) bits, and read back as
min + stored × step. Declaring a tight range and only the decimals you need is what makes a value
cheap. A plain number is a 32-bit float.
int
An int with a min and a max packs to ceil(log2(max − min + 1)) bits,
the same as a number at zero decimals. A plain int is 32 bits.
bool
One bit.
string
A short byte count, then that many UTF-8 bytes read as
eight-bit groups of the bitstream. A string field with a fixed set of values is written as the value's index
in ceil(log2(count)) bits, so a colour or a phase name costs a few bits.
netId
An id for one object, in ceil(log2(count)) bits for the objects in view. The ids stay packed as a
run (see Implicit), so the width is the log of how many
you can see, a few bits when a few are on screen. The count moves within an update:
gone ids take the width of the count as the update
opens, then changed ids take the width of the count
that gone and appear leave, so each id is as narrow as its moment allows. An appearing object takes the next
id, agreed the same way on both sides.
| Field | Range | Steps | Bits |
|---|---|---|---|
x | 0 to 3600, 1 decimal | 36 001 | 16 |
y | 0 to 2400, 1 decimal | 24 001 | 15 |
angle | 0 to 6.2832, 3 decimals | 6 284 | 13 |
A moving ship's three changed fields pack into 44 bits, just over five bytes. The netId that names the ship and the one-bit-per-field mask that marks which changed add a few bits more, so a full position sample rides in well under ten bytes.
Errors
On a schema mismatch, an unknown game, a disallowed origin, or a malformed frame, the server closes the socket, carrying a WebSocket close code where one fits the reason. The front end sees the close and reconnects. Most faults are caught at deploy, so running play rarely reaches this.