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
NameClassWhat it carries
PigeonRefsFolderHolds the pool. Created by the server under ReplicatedStorage.
PigeonRef_NRemoteEventThe normal lane for bucket N. Everything travels here by default.
PigeonRefU_NUnreliableRemoteEventThe lossy twin for bucket N. Only used by unreliable sends.
RefCountAttribute on the folderHow 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
TransformersReliable remotesWhy
11The floor. You always get at least one.
16116 / 16 is exactly 1.
172One over the line, so it rounds up.
1007100 / 16 is 6.25, rounded up.
51232512 / 16 is exactly 32, which is also the ceiling.
1000321000 / 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

transformer uuid FNV-1a hash modulo RefCount PigeonRef_N

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.

MachineWhat it does with the count
ServerWorks it out from its own transformer count, then writes it to the RefCount attribute.
ClientReads 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.

TrafficLane
Fire and forget sends on a normal carrierReliable
Fire and forget sends on an unreliable carrierUnreliable
RequestsAlways reliable
Replies to requestsAlways 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.

ServerClient
Creates the folderYesNo, it waits for it
Creates refsYes, eagerlyNever
Writes RefCountYesNo, it reads it
Binds refsOn creationLazily, 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