The Ref Pool
Pigeon does not make a RemoteEvent per channel. It keeps a small pool of them and hashes each transformer onto one. This page explains what that pool looks like, how big it gets, and how both machines agree on it.
You never have to touch any of this to use Pigeon. Read it when you want to know
what those Instances in ReplicatedStorage are, or when you are counting
remotes for a performance budget.
What ends up in ReplicatedStorage
One folder, called PigeonRefs. Inside it are numbered RemoteEvents.
Pigeon calls each one a ref, and each ref serves a bucket, which is
just its slot number in the pool.
ReplicatedStorage
└── PigeonRefs -- attribute: RefCount = 3
├── PigeonRef_1 -- RemoteEvent
├── PigeonRefU_1 -- UnreliableRemoteEvent
├── PigeonRef_2 -- RemoteEvent
├── PigeonRefU_2 -- UnreliableRemoteEvent
├── PigeonRef_3 -- RemoteEvent
└── PigeonRefU_3 -- UnreliableRemoteEvent
| Name | Class | What it carries |
|---|---|---|
PigeonRefs | Folder | Holds the pool. Created by the server under ReplicatedStorage. |
PigeonRef_N | RemoteEvent | The normal lane for bucket N. Everything travels here by default. |
PigeonRefU_N | UnreliableRemoteEvent | The lossy twin for bucket N. Only used by unreliable sends. |
RefCount | Attribute on the folder | How many buckets the pool currently has. The server writes it, clients read it. |
Buckets are numbered from 1. The count never drops below 1, so bucket 1 is always in the pool.
How many remotes you get
The pool is sized from the number of transformers, not the number of channels. One ref per 16 transformers, never fewer than 1, never more than 32.
-- The rule Pigeon applies internally, written out so you can follow it.
local function refCountFor(transformerCount: number): number
return math.clamp(math.ceil(transformerCount / 16), 1, 32)
end
| Transformers | Reliable remotes | Why |
|---|---|---|
| 1 | 1 | The floor. You always get at least one. |
| 16 | 1 | 16 / 16 is exactly 1. |
| 17 | 2 | One over the line, so it rounds up. |
| 100 | 7 | 100 / 16 is 6.25, rounded up. |
| 512 | 32 | 512 / 16 is exactly 32, which is also the ceiling. |
| 1000 | 32 | 1000 / 16 is 62.5, but the pool stops at 32 however many you make. |
By default one channel means one carrier means one transformer, so in practice you can read that first column as "channels". If you share a transformer between several carriers it only counts once. See Transformers.
200 channels still fit in 13 remotes
That is the whole point of the pool. 200 transformers divided by 16 is 12.5, rounded up to 13. With one RemoteEvent per feature you would have 200 Instances to create, name, parent and wait for. Here you have 13 buckets, and you never named any of them.
Note
Each bucket also has an unreliable twin, so 13 buckets is 26 Instances in the folder. The server makes both up front. Still 26 instead of 200, and a twin carries nothing until something sends unreliably.
Which bucket a transformer lands on
The transformer's uuid is hashed, and the hash is folded into the pool size.
Picking a remote
FNV-1a is a small, fast hash function that turns a string into a number. Pigeon uses the 32-bit version. It is not for security, it is for spreading strings evenly and always giving the same answer for the same string.
-- The hash Pigeon uses internally, written out so you can follow it.
local function hashString(s: string): number
local hash = 2166136261
for i = 1, #s do
hash = bit32.bxor(hash, string.byte(s, i))
hash = (hash * 16777619) % 4294967296
end
return hash
end
local function bucketOf(uuid: string, refCount: number): number
return (hashString(uuid) % refCount) + 1
end
print(bucketOf("Shop", 3)) --> the same bucket every run, on both machines
Two things follow from this. The same uuid always lands on the same bucket for a
given pool size, so a channel does not drift from remote to remote while you play.
And a carrier called "Shop" on the server and one called
"Shop" on the client hash the same string, so they meet on the same
remote without ever being told about each other.
Why the count is published as an attribute
The bucket depends on the pool size, so both machines have to use the same number. They cannot each work it out locally, because they do not know the same things. A client that made three transformers of its own would compute a pool of 1, while a server with 100 transformers is using 7.
So the server is the authority. It writes RefCount onto the
PigeonRefs folder, and clients read that attribute instead of counting
anything themselves.
| Machine | What it does with the count |
|---|---|
| Server | Works it out from its own transformer count, then writes it to the RefCount attribute. |
| Client | Reads that attribute. Falls back to 1 while it has not replicated yet. |
The fallback of 1 is the safe answer, because bucket 1 is always in the pool. Without
a shared count a client could work out that it needs PigeonRef_9 when the
server only ever made seven, and the wait for that Instance would never finish.
Reading the count yourself
One function answers on both machines, so you do not have to care which side you are on.
Pigeon.Network.GetRefCount() -> number
Takes no parameters. Returns the pool size this machine is bucketing against: the computed count on the server, the published attribute on the client, and 1 on a client that has not received the attribute yet.
print(Pigeon.Network.GetRefCount()) --> 3
The reliable and unreliable twin
Every bucket has two remotes: a RemoteEvent and an
UnreliableRemoteEvent with the same number. They are two lanes for the
same bucket.
| Traffic | Lane |
|---|---|
| Fire and forget sends on a normal carrier | Reliable |
| Fire and forget sends on an unreliable carrier | Unreliable |
| Requests | Always reliable |
| Replies to requests | Always reliable |
Requests and replies never take the lossy lane, because a dropped reply would leave the caller waiting for nothing until it times out. See Unreliable Sending for when the lossy lane is a good trade.
The two twins replicate separately, so a client can hold one and not the other. When only the reliable ref has arrived, an unreliable send takes it rather than waiting. Going the other way, the server drops an unreliable packet outright if the client has not opened that channel yet, because a packet that may be lost in transit has no business being replayed later.
The server creates, the client discovers
Only the server ever makes a ref. When a transformer is created on the server,
Pigeon works out the new pool size, creates every ref up to it, republishes
RefCount, and resolves the ref for that transformer.
The client does none of that. Creating a transformer on the client does not make an
Instance. Instead, when Pigeon loads, the client waits for the
PigeonRefs folder, binds every ref already in it, and then listens for
ChildAdded so it picks up any the server makes later. Binding a ref is
just connecting OnClientEvent so packets arriving on it get routed.
| Server | Client | |
|---|---|---|
| Creates the folder | Yes | No, it waits for it |
| Creates refs | Yes, eagerly | Never |
Writes RefCount | Yes | No, it reads it |
| Binds refs | On creation | Lazily, as they show up |
Note
Because the client only discovers refs, a very early send can happen before the ref it needs has replicated. Nothing yields and nothing is lost. The packet is held, and the moment the ref binds Pigeon replays everything it was holding.
The replay is ordered. Every held Init goes out first, then the
traffic that queued behind it, in the order you sent it. So a carrier built on
the first frame opens exactly as it would have had the pool been there all
along, and none of it blocks the code that called it.
Binding a ref is not the same as being ready for traffic. The server still holds anything addressed to a channel the client has not opened yet. That is a separate mechanism, covered in Startup and Buffering.
The pool can shrink
Destroying a transformer lowers the count the server sizes from, and it republishes
RefCount straight away. If that pushes the pool down a size, every uuid
is folded into a smaller number and some channels move to a different bucket.
Careful
Both sides read the count fresh every time they pick a bucket, and the attribute takes a moment to replicate, so for a brief window the two machines can disagree about the pool size. Packets still arrive: the receiving side is bound to every ref it has seen, and routing is by channel name rather than by which remote the packet came in on. Nothing is lost, it just goes out on a different lane for a moment.
Refs that are already made are never deleted. A pool that grew to 7 and then shrank to 5 still has seven RemoteEvents in the folder. The last two are simply not chosen any more until the count climbs back.
What to read next
- Transformers for the object whose uuid drives all of this.
- Startup and Buffering for what happens before a client is ready.
- Unreliable Sending for the twin remotes.
- Network API for the low level transport.