Network
Pigeon.Network is the transport under everything else. It owns the pool
of RemoteEvents, routes packets by name, holds anything that arrives too early, and
times out requests.
Note
This is the layer carriers are built on, and most games never touch it. It is documented because it is public, and because it is the right place to look when something is not arriving. If you are writing game code, use a Carrier instead.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Pigeon = require(ReplicatedStorage.Pigeon)
local Network = Pigeon.Network
Careful
Nothing in Network checks which side you are on. There is no
ERR_NOT_SERVER here, and no matching guard for the client. Used on
the wrong side you get whatever Roblox does with a RemoteEvent used the wrong way
round, or a packet that quietly goes nowhere. Push,
Call and Ready reach FireServer, which only
works from a client. The badges below say where each call belongs.
What a packet looks like
Every fire puts two values on the wire: a buffer holding the bookkeeping,
and a plain array holding your arguments. The buffer says which of four kinds the
packet is.
| Kind | What the buffer holds | Raised by |
|---|---|---|
| push | kind, event, argument count, nil bits | Push, Broadcast |
| call | the same, plus a request id | Call, BroadcastCall |
| respond | kind, request id, argument count, nil bits | Respond |
| auth | kind, channel name | Ready |
The transformer uuid is not on the wire. A reply goes back on the ref the request arrived on, which the receiver already knows, so there is nothing to send. Sizes and the reasoning are in Optimizations.
Event names and channels
Routing is by event name alone. A carrier scopes its events by putting its own name, a
NUL byte and then the event name, so event Buy on carrier
Shop travels as "Shop\0Buy".
The channel is whatever comes before that NUL byte, or the whole name when
there is no NUL. The channel is what buffering is keyed on. A client only receives on
a channel it has readied, so if you send with a bare name like "Update",
the client has to call Ready(transformer, "Update") to hear it.
What data is
data is a small record holding the arguments and how many there are:
{ args = {...}, n = 3 }. A carrier builds it, and
Network reads only those two fields. Your values inside
args are never inspected or rewritten.
The count is carried in the meta buffer rather than alongside the list, and an
argument that was nil is recorded as a bit there too. That is why
Emit("Hit", 1, nil, 6) arrives as three arguments rather than stopping at
the gap. Return values from a responder are packed the same way. See
Optimizations for the format and
Arguments for what it means when you
send.
A request at this level
Properties
KnownTransformers
Network.KnownTransformers: number
How many transformers this machine has made and not destroyed.
Register adds one, Unregister takes one away, and it never
drops below zero.
On the server this is the number the pool size is worked out from. On the client it is
counted but never used for sizing, because the client takes the pool size from the
RefCount attribute the server publishes.
print(Network.KnownTransformers) --> 12
Careful
It is a plain field, so nothing stops you writing to it. Do not. On the server it decides how many RemoteEvents exist and which bucket every uuid lands on.
The ref pool
A ref is one pooled RemoteEvent, and a bucket is its slot number in the pool. The Ref Pool guide explains the whole scheme. These four calls are the parts of it you can reach.
GetRefCount Shared
Network.GetRefCount() -> number
How many buckets the pool should have right now.
| Side | Where the number comes from |
|---|---|
| Server | math.clamp(math.ceil(KnownTransformers / 16), 1, 32). One ref per 16 transformers, at least 1 and at most 32. |
| Client | The RefCount attribute on the PigeonRefs folder. If the folder or the attribute is not there yet, the answer is 1. |
Returns the count. It never yields and it takes no arguments.
print(Network.GetRefCount()) --> 3
The client's fallback of 1 is the safe answer, because bucket 1 always exists. Both sides read this fresh every time they pick a bucket, so during the moment the attribute is replicating the two machines can briefly disagree. Nothing is lost when that happens. Packets still route by name, whichever ref they came in on.
GetRef Shared Yields
Network.GetRef(transformer: Transformer) -> RemoteEvent
Returns the reliable RemoteEvent a transformer's traffic rides on. The uuid is hashed and folded into the current pool size to pick the bucket.
| Parameter | Type | What it is |
|---|---|---|
transformer | Transformer | The transformer to resolve. Only its uuid is read. |
Returns the RemoteEvent for that bucket.
local transformer = Pigeon.Transformer("Combat")
local ref = Network.GetRef(transformer)
print(ref.Name) --> PigeonRef_2
On the server it creates the ref if it does not exist yet. On the client it yields
until the PigeonRefs folder and that ref have replicated. There is no
timeout on the wait.
Note
Only the reliable ref comes back. The unreliable twin is not returned. It sits
beside it in the same folder under the same number, named
PigeonRefU_2 for the example above.
Do not cache what you get. The bucket is worked out from the pool size at the moment
you ask, so it can move when the pool grows or shrinks.
Pigeon.GetRef(transformer) is the same call under a shorter name.
Register Shared
Network.Register(transformer: Transformer) -> ()
Counts a transformer as known. Pigeon calls it in the transformer constructor, so you never have to.
| Parameter | Type | What it is |
|---|---|---|
transformer | Transformer | The transformer entering the count. |
Returns nothing.
On the server it adds one to KnownTransformers, works out the new pool
size, creates every ref up to it, writes the RefCount attribute, then
resolves the ref for this transformer. On the client it only adds one to the count.
Clients never create refs.
local before = Network.KnownTransformers
-- Pigeon.Transformer calls Register for you.
local transformer = Pigeon.Transformer("Combat")
print(Network.KnownTransformers) --> before + 1
-- Calling it yourself counts the same transformer a second time.
Network.Register(transformer)
print(Network.KnownTransformers) --> before + 2
Careful
Calling this yourself counts the same transformer twice. Sixteen extra counts push
the server's pool one ref bigger than it needs to be, and only a matching
Unregister brings the count back down.
Unregister Shared
Network.Unregister(transformer: Transformer) -> ()
Takes one off the count and, on the server, republishes the pool size.
transformer:Destroy() calls it for you.
| Parameter | Type | What it is |
|---|---|---|
transformer | Transformer | Ignored. Only the count changes. |
Returns nothing.
-- Undo the extra count from the Register example.
Network.Unregister(transformer)
print(Network.KnownTransformers) --> before + 1
-- Destroy calls Unregister for you, so the count lands back where it started.
transformer:Destroy()
print(Network.KnownTransformers) --> before
The argument really is ignored, so passing the wrong transformer, or the same one twice, has exactly the same effect as passing the right one. Nothing is verified. The count is floored at zero.
Refs that already exist are never deleted. A pool that grew to 7 and then dropped back to 5 still has seven RemoteEvents in the folder. The last two simply stop being chosen until the count climbs again.
Listening
Receive Shared
Network.Receive(event: string, callback: (...any) -> ()) -> () -> ()
Hooks a callback onto a wire event name. Returns a function that disconnects it.
| Parameter | Type | What it is |
|---|---|---|
event | string | The name as it travels, including the carrier prefix if there is one. |
callback | (...any) -> () | Runs for every packet with that name. |
Returns a disconnect function. Calling it twice is harmless.
What the callback is handed depends on the side:
| Side | Arguments |
|---|---|
| Server | player, bucket, data, promiseId? |
| Client | bucket, data, promiseId? |
promiseId is only there when the packet was a request. Pass it to
Respond to answer.
local stop = Network.Receive("Shop\0Buy", function(player, bucket, data, promiseId)
print(player.Name, "sent Buy")
end)
-- Later.
stop()
Details worth knowing
- Routing is by name only. Every packet with that name reaches you, whichever ref it came in on and whatever transformer sent it.
- Anything that arrived for this name while nothing was listening is replayed to your callback, in order, the moment you subscribe. Those held packets expire after 10 seconds.
- Each callback runs in its own
task.spawn, so yielding inside one does not hold up the others. - There is no
pcallaround it. An error in your callback shows up in the output as an unhandled error and does not reach the sender. - Register the same function twice and you have to disconnect twice. Each call removes one entry.
Note
data is the raw payload, the { args = {...}, n = 3 } record
a carrier builds. Listening to a carrier's traffic here hands you that record
rather than the arguments themselves, and the carrier's middleware does not run.
An argument that was nil is already back in place in
args, because the bits saying so were read out of the meta buffer
before you were called.
ReceiveChannel Shared
Network.ReceiveChannel(channel: string, callback: (event: string, ...any) -> ()) -> () -> ()
Watches a whole channel instead of one name on it. The callback is handed every packet
on that channel that no Receive listener claimed, with the wire event name
in front of the usual arguments. Returns a function that disconnects it.
| Parameter | Type | What it is |
|---|---|---|
channel | string | The channel name, which is the part of a wire name before the separator. |
callback | (event, ...any) -> () | Runs for each unclaimed packet. event is the full wire name. |
Returns a disconnect function. Calling it twice is harmless.
After the event name the callback gets exactly what a Receive callback
would have got for that packet: player, bucket, data, promiseId? on
the server, and the same list without the player on the client.
local stop = Network.ReceiveChannel("Shop", function(event, player, bucket, data)
print(player.Name, "sent", event, "which nothing is bound to")
end)
-- Later.
stop()
How it differs from Receive
Receive | ReceiveChannel | |
|---|---|---|
| Bound to | One wire event name. | A whole channel, so every name on it at once. |
| What it sees | Every packet with that name. | Only packets no Receive listener claimed. |
| Callback gets | player, bucket, data, promiseId? on the server, one less on the client. |
The wire event name, then that same list. |
| Replay on subscribe | What was held for that one name. | What was held for every name on the channel. |
| After the callback | The packet was delivered. | The packet is taken, so it stops being held. |
Details worth knowing
- A listener wins. If any
Receivecallback is bound to the exact wire name, watchers do not run for that packet, so nothing is ever delivered twice. - Anything already held for this channel is replayed to your callback the moment you subscribe, the same as
Receivedoes per name. - A packet a watcher takes is no longer orphaned. Holding it or dropping it is now your problem, and this is exactly what a carrier does with it.
- Watchers do not compete. Every watcher on the channel gets the packet, so adding one to a channel a carrier already owns does not take anything away from that carrier.
- Each callback runs in its own
task.spawnwith nopcallaround it, and registering the same function twice means disconnecting twice. Exactly likeReceive. - This is how a carrier runs its incoming middleware over events nothing has bound. See Middleware.
Sending
Push Client
Network.Push(transformer: Transformer, event: string, data: any, unreliable: boolean?) -> ()
Client to server, fire and forget. Never yields.
| Parameter | Type | What it is |
|---|---|---|
transformer | Transformer | Decides which ref the packet leaves on. |
event | string | The name to raise on the server. |
data | any | One payload value. Pack it yourself if you need several. |
unreliable | boolean? | true takes the lossy twin. Defaults to reliable. |
Returns nothing.
Network.Push(transformer, "Shop\0Buy", { "sword" })
If the ref has not replicated yet, the packet is held in the client's outbox and goes out the moment that ref binds. Up to 64 packets are held, oldest dropped first, and each is thrown away after 30 seconds.
An unreliable push falls back to the reliable lane when only the reliable ref has replicated. The twin arrives separately, and dropping the packet over a timing detail would be worse than sending it reliably.
Broadcast Server
Network.Broadcast(players: {Player}, transformer: Transformer, event: string, data: any, unreliable: boolean?) -> ()
Server to the listed players, fire and forget.
| Parameter | Type | What it is |
|---|---|---|
players | {Player} | The recipients. No filtering is done for you. |
transformer | Transformer | Decides which ref the packet leaves on. |
event | string | The name to raise on each client. |
data | any | One payload value. |
unreliable | boolean? | true takes the lossy twin. |
Returns nothing.
Network.Broadcast(Players:GetPlayers(), transformer, "Shop\0StockChanged", stock)
A client that has not readied the channel does not get the packet now. It is buffered
and released when that client calls Ready on that channel. The server
holds up to 64 packets per client per channel, for 30 seconds, oldest dropped first.
Players who have left are skipped, and their queues are dropped.
Careful
Unreliable sends are never buffered. If the client has not readied the channel, an unreliable packet is dropped on the spot. A packet that may be lost in transit has no business being replayed a minute later.
Call Client Yields
Network.Call(transformer: Transformer, event: string, data: any, timeout: number?) -> (boolean, any)
Client to server request. Yields the calling thread until the server responds or the timeout runs out. Always takes the reliable lane.
| Parameter | Type | What it is |
|---|---|---|
transformer | Transformer | Decides which ref the request leaves on. |
event | string | The name to raise on the server. |
data | any | One payload value. |
timeout | number? | Seconds to wait. Defaults to 10. |
Returns two values: true and the response data, or
false and the string "timeout".
local ok, answer = Network.Call(transformer, "Shop\0Buy", "sword", 5)
if not ok then
warn("no answer in 5 seconds")
return
end
print(answer)
The clock starts when you call it, not when the packet leaves. Any wait for the ref pool to replicate counts against your timeout, so you never wait longer than you asked for. A held request whose deadline has already passed is thrown away instead of being sent, since there is nobody left to answer it.
Respond Shared
Network.Respond(bucket: number, promiseId: number, data: any, player: Player?) -> ()
Answers a Call or a BroadcastCall, and resumes the thread
waiting on the other side.
| Parameter | Type | What it is |
|---|---|---|
bucket | number | The bucket your Receive callback was handed. It says which ref the request arrived on. |
promiseId | number | The request id from the same callback. |
data | any | The response payload. |
player | Player? | Who to answer. Needed on the server, where leaving it out silently drops the reply. Ignored on the client. |
Returns nothing.
This is the one call that takes an id string rather than a transformer object. That is on purpose. A reply goes back on whichever ref the request arrived on, so the round trip still works when the two sides are using different transformers.
| Side | What it does |
|---|---|
| Server | Fires straight at that player. Never buffered, because the caller is already waiting on it. The target has to be an Instance, must not be marked as departed, and must still have a parent, or the reply is dropped without a word. Leaving player out fails that check like anything else, so the call returns quietly rather than erroring. |
| Client | Sends to the server, through the outbox if the ref is not there yet. This is how a client answers a BroadcastCall. |
Network.Receive("Shop\0Buy", function(player, bucket, data, promiseId)
if not promiseId then
return -- A Push, not a Call. Nobody is waiting.
end
Network.Respond(bucket, promiseId, "sold", player)
end)
Answering a BroadcastCall looks the same, minus the player:
Network.Receive("Quiz\0Answer", function(bucket, data, promiseId)
Network.Respond(bucket, promiseId, "42")
end)
-- Nothing arrives on this channel until the client asks for it.
Network.Ready(transformer, "Quiz")
Replies always take the reliable lane. If the request already timed out, the reply arrives and is thrown away, because that promise is gone.
BroadcastCall Server Yields
Network.BroadcastCall(players: {Player}, transformer: Transformer, event: string, data: any, timeout: number?) -> (boolean, any)
Server to clients request. Yields until every listed player has answered or timed out, or until one of them leaves. Each player gets their own promise, and all of them share one deadline.
| Parameter | Type | What it is |
|---|---|---|
players | {Player} | Who to ask. An empty list returns straight away without yielding. |
transformer | Transformer | Decides which ref the requests leave on. |
event | string | The name to raise on each client. |
data | any | One payload value. |
timeout | number? | Seconds to wait. Defaults to 10. |
Returns two values:
| First | Second | When |
|---|---|---|
true | A map of player to response | Every player's slot has been filled. |
true | {} | You passed an empty player list. |
false | "player_left" | One of the listed players left the game first. |
Careful
A timeout does not make this return false. When a player's request
times out, that player's slot is filled with the string "timeout" and
it counts as answered. Once every slot is filled the call returns
true, with "timeout" sitting in the map where a response
should be. Check each entry rather than trusting the first return on its own. If
your clients can genuinely answer with the string "timeout", you
cannot tell the two apart.
local ok, responses = Network.BroadcastCall(Players:GetPlayers(), transformer, "Quiz\0Answer", question, 5)
if not ok then
print("stopped early:", responses) --> player_left
return
end
for player, response in responses do
if response == "timeout" then
print(player.Name, "never answered")
else
print(player.Name, "said", response)
end
end
Requests go out through the same ready and buffer path as Broadcast, so a
client that has not readied the channel gets the request when it does, if that happens
inside the buffer window.
Ready Client
Network.Ready(transformer: Transformer, channel: string) -> ()
Tells the server this client is listening on a channel, which releases whatever the server has been holding for it there, oldest first. Never yields.
| Parameter | Type | What it is |
|---|---|---|
transformer | Transformer | Decides which ref the readiness packet leaves on. |
channel | string | The channel to open. For a carrier this is its name. |
Returns nothing.
Network.Receive("PlayerData\0Changed", applyPayload)
-- Handlers are in place, so let the backlog through.
Network.Ready(transformer, "PlayerData")
carrier:Init() is this call with the carrier's name. Readying one channel
says nothing about any other, so a client can bring its channels up one at a time and
each backlog arrives exactly when it is asked for.
How it behaves before the pool exists
If the ref has not replicated yet, the packet is held and then sent ahead of everything else the client queued behind it. That is the order the channel would have come up in anyway: readiness first, then whatever you sent next.
- Duplicates are ignored while held. The same uuid and channel is only queued once.
- Readiness packets never expire in the outbox. Ordinary traffic does. Dropping one would leave that channel shut for the rest of the session.
- Readying twice is harmless. The second one flushes an empty queue.
Checking what is held
Diagnostics Shared
Network.Diagnostics() -> {[string]: number}
Counts what the transport is holding right now, on this machine. It is meant for leak checks and tests.
Returns a fresh table of nine numbers. It takes no arguments.
local d = Network.Diagnostics()
print(d.listeners, d.pending, d.bufferedPackets)
What each field counts
| Field | Side | What it counts |
|---|---|---|
listeners |
Both | Distinct event names with at least one Receive callback. Two listeners on one name count as one. |
watchers |
Both | ReceiveChannel callbacks, added up across every channel. One per live carrier, so this is a carrier count in practice. |
orphaned |
Both | Packets held for event names nothing is listening to yet, added up across every name. A channel with a watcher on it does not orphan. |
pending |
Both | Requests waiting for an answer. A BroadcastCall counts one per player it asked. |
readySeats |
Server | Channel and player pairs that have declared readiness. Always 0 on a client. |
bufferedPackets |
Server | Packets held for clients that have not readied a channel. Always 0 on a client. |
readyChannels |
Server | Channels with at least one ready player. |
bufferedChannels |
Server | Channels with at least one packet held for somebody. |
outbound |
Client | Packets in the client's outbox waiting for their ref to replicate, readiness packets included. Always 0 on the server, which never queues a send. |
Reading the numbers
| Field | Healthy | A number that keeps climbing means |
|---|---|---|
listeners |
Flat once the game is running. Roughly the number of events you bound. | Carriers or listeners are being made and never dropped. This is the field a leak shows up in first. |
watchers |
Flat, at one per carrier you built and did not destroy. | Carriers are being made and never destroyed. Unlike listeners this climbs even for a carrier that binds nothing. |
orphaned |
0, or a small blip during startup. | Traffic is arriving on a channel with no carrier at all. Once a carrier exists it takes that channel's unclaimed packets itself. |
pending |
Back to 0 whenever nothing is mid request. | You are issuing requests faster than they settle. It cannot leak, since every entry is settled at its deadline. |
readySeats |
About the number of channels a client opens times the players online. It falls as players leave. | New channel names are being readied over and over. Usually carriers built with a name that includes a counter or a user id. |
bufferedPackets |
0 shortly after each client starts up. | You are sending to a channel clients never open. It cannot grow without bound, but every packet in there is a wasted send. |
readyChannels |
Settles at the number of channels your game has, once the first client is up. | Channel names are being generated rather than fixed. Same cause as readySeats, counted once per name instead of once per name and player. |
bufferedChannels |
0, alongside bufferedPackets. A channel leaves the list as soon as its last held packet does. |
The same channels are being sent to and never opened. Read it next to bufferedPackets: many packets on one channel is a client that never readied, one packet on many channels is a naming problem. |
outbound |
0 once the PigeonRefs folder has replicated in. |
The pool has not arrived, or readiness packets are stuck. Those never expire, so they sit there until a ref binds. |
Checking for a leak
Take the numbers, do the thing you suspect, tear it down, then take them again. Everything should come back to where it started.
local Network = Pigeon.Network
local before = Network.Diagnostics()
for index = 1, 100 do
local carrier = Pigeon.new("Temp" .. index, {})
carrier:On("Tick", function() end)
carrier:Destroy()
end
task.wait(1) -- give the heartbeat sweep a chance to run
for field, value in Network.Diagnostics() do
if value ~= before[field] then
warn(`{field}: {before[field]} -> {value}`)
end
end
print("transformers:", Network.KnownTransformers)
That loop makes 100 uncached carriers, since passing options always builds a fresh one, and each makes and destroys its own transformer. If anything prints, something held on to a listener it should have let go of.
Note
Diagnostics walks every queue it counts, so the cost grows with what is being held. Call it now and then, not every frame.
The numbers behind all this
These are fixed in the module. You cannot read them or change them from outside, so they are listed here to save you counting.
| Name | Value | What it controls |
|---|---|---|
LOAD_PER_REF | 16 | Transformers per RemoteEvent in the pool. |
MAX_REFS | 32 | The most RemoteEvents the pool will ever have. |
DEFAULT_TIMEOUT | 10 | Seconds a Call or BroadcastCall waits when you do not pass a timeout. |
ORPHAN_TTL | 10 | Seconds a packet is held when nothing is listening for its name yet. |
ORPHAN_MAX | 64 | How many such packets are kept per name. The oldest goes first. |
BUFFER_TTL | 30 | Seconds the server holds a packet for a client that has not readied the channel. |
BUFFER_MAX | 64 | How many packets the server holds per channel, per client. |
OUTBOX_TTL | 30 | Seconds the client holds a packet whose ref has not replicated. Readiness packets are exempt. |
OUTBOX_MAX | 64 | How many ordinary packets the client holds. Readiness packets are not counted or dropped. |
Every limit here is a cap plus an expiry. That pairing is the whole design. A queue with only a cap keeps stale data forever, and a queue with only an expiry can still be flooded in a second.
What to read next
- The Ref Pool for what the pool looks like in ReplicatedStorage.
- Startup and Buffering for the readiness and buffering rules in plain terms.
- Carrier for the layer you should normally be using.
- Transformer for the object that picks the ref.