Optimizations
What Pigeon does to keep traffic small, and what it deliberately does not touch. None of this needs turning on. It is how the library already works.
One rule runs through all of it: your data is never rewritten.
shop:Emit("Sold", id) puts id on the wire exactly as you
passed it. Everything below is about the bookkeeping Pigeon wraps around it.
Metadata travels as a buffer
Every message carries some bookkeeping: what kind of message it is, which event it
is for, and which request it answers. Sending that as separate strings is wasteful,
so Pigeon packs it into one buffer and sends that instead.
A packet on the wire is two things:
One message
What is in the buffer
| Field | Size | What it is |
|---|---|---|
| flags | 1 byte | The kind of message in the low two bits, then a bit for whether a request id follows and a bit for whether there are arguments. |
| event length | 2 bytes | How long the event name is. |
| request id | 4 bytes | Only on a request or a reply. |
| argument count | 1 byte | Only when there are arguments. At most 255. |
| nil bits | 1 bit each | One bit per argument, saying whether it was nil. |
| event name | the rest | The channel name, a zero byte, then your event name. |
What it saves
Measured as bytes of wire data for the bookkeeping alone. Your arguments are the same either way.
| Message | Before | Now |
|---|---|---|
Emit("Sold", id) on a channel called Shop | 51 bytes | 14 bytes |
Call("Request") on a channel called PlayerData | 96 bytes | 27 bytes |
| The reply to that call | 81 bytes | 9 bytes |
Init() on a channel called Shop | 46 bytes | 7 bytes |
Three things account for almost all of it.
The transformer id is not sent at all
It used to ride on every single packet, 38 characters of it, and the receiving side used it for exactly one thing: working out which remote to send the reply back on. But the receiver already knows that, because it knows which remote the message just arrived on. So the id is gone and the reply goes back the way the request came.
That is also more correct than it was. Recomputing the remote from an id could pick a different one if the pool had resized in between. Answering on the remote the request came in on cannot drift. See The Ref Pool.
Request ids are numbers
A request id used to be a 36 character string from GenerateGUID. It is
now a 4 byte counter. An id only has to be unique on the machine that made it,
because a reply carries back the id the caller generated, so a counter is enough.
Nil is one bit
Roblox drops a nil out of the middle of an argument list, so a library
that wants to carry one has to say so some other way. Pigeon used to substitute a
14 byte marker string. Now it writes one bit per argument into the meta buffer and
the far side puts the nil back.
Note
That also removed a sharp edge. With a marker string, a player who sent that exact
string would have had it read back as nil. Bits cannot collide with
anything, so no string value is reserved any more.
One pool of remotes
Pigeon does not make a RemoteEvent per channel. It keeps a small pool and hashes each channel onto one, so 200 channels fit in 13 remotes rather than 200 Instances. Full detail, including why hashing rather than assigning, is in The Ref Pool.
Table writes are batched
Writing five fields of a staged table in one frame sends one message, not five. The writes queue and flush together, so filling a table field by field costs one send.
local view = stats:GetTable()
view.coins = 120
view.level = 4
view.combat.health = 100
-- One patch goes out, carrying all three.
Nothing is sent to someone who cannot use it
A guarded channel filters the recipient list before the packet is built, so a client that has not passed the guard costs nothing to skip. If a broadcast has nobody left after that filter, no packet is built at all. See Handshakes.
Messages for a client that has not called Init yet are held rather than
sent and lost, and unreliable messages for that client are dropped instead of held,
because replaying something that was allowed to be dropped is pointless. See
Startup and Buffering.
The lossy lane
A carrier made with { Unreliable = true } sends on an
UnreliableRemoteEvent, which is cheaper and may drop. Use it for things
you send every frame and would not miss. Requests and replies always take the
reliable lane whatever the carrier says. See
Unreliable Sending.
What Pigeon does not do
Worth being clear about, so you know where the remaining cost is.
| Not done | Why |
|---|---|
| Compress your arguments | They go across exactly as you passed them. If you send a large table every frame, that is the cost, and only you can make it smaller. |
| Diff arbitrary tables | Only a staged table sends changes rather than the whole thing. A plain table passed to Emit is sent whole. |
| Merge separate sends | Two Emit calls in one frame are two messages. Only staged table writes coalesce. |
| Shorten your event names | The channel name and event name are on the wire so the far side can route without a shared registry. Long names cost bytes on every packet. |
Careful
The one place your own choices show up in the numbers above is naming. A channel
called "PlayerInventoryManagement" puts 25 bytes on every packet it
carries. Short names are worth having on a channel you send on constantly.
What to read next
- The Ref Pool for how channels share remotes.
- Staged Tables for replicating state instead of resending it.
- Unreliable Sending for the lossy lane.
- Network for the transport underneath.