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.

KindWhat the buffer holdsRaised by
pushkind, event, argument count, nil bitsPush, Broadcast
callthe same, plus a request idCall, BroadcastCall
respondkind, request id, argument count, nil bitsRespond
authkind, channel nameReady

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

Network.Call ref ready, or held FireServer
routed by event name your Receive callback
Network.Respond Call returns

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.

SideWhere 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.

ParameterTypeWhat it is
transformerTransformerThe 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.

ParameterTypeWhat it is
transformerTransformerThe 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.

ParameterTypeWhat it is
transformerTransformerIgnored. 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.

ParameterTypeWhat it is
eventstringThe 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:

SideArguments
Serverplayer, bucket, data, promiseId?
Clientbucket, data, promiseId?

promiseId is only there when the packet was a request. Pass it to Respond to answer.

Server
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 pcall around 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.

ParameterTypeWhat it is
channelstringThe 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.

Server
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

ReceiveReceiveChannel
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 Receive callback 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 Receive does 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.spawn with no pcall around it, and registering the same function twice means disconnecting twice. Exactly like Receive.
  • 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.

ParameterTypeWhat it is
transformerTransformerDecides which ref the packet leaves on.
eventstringThe name to raise on the server.
dataanyOne payload value. Pack it yourself if you need several.
unreliableboolean?true takes the lossy twin. Defaults to reliable.

Returns nothing.

Client
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.

ParameterTypeWhat it is
players{Player}The recipients. No filtering is done for you.
transformerTransformerDecides which ref the packet leaves on.
eventstringThe name to raise on each client.
dataanyOne payload value.
unreliableboolean?true takes the lossy twin.

Returns nothing.

Server
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.

ParameterTypeWhat it is
transformerTransformerDecides which ref the request leaves on.
eventstringThe name to raise on the server.
dataanyOne payload value.
timeoutnumber?Seconds to wait. Defaults to 10.

Returns two values: true and the response data, or false and the string "timeout".

Client
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.

ParameterTypeWhat it is
bucketnumberThe bucket your Receive callback was handed. It says which ref the request arrived on.
promiseIdnumberThe request id from the same callback.
dataanyThe response payload.
playerPlayer?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.

SideWhat 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.
Server
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:

Client
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.

ParameterTypeWhat it is
players{Player}Who to ask. An empty list returns straight away without yielding.
transformerTransformerDecides which ref the requests leave on.
eventstringThe name to raise on each client.
dataanyOne payload value.
timeoutnumber?Seconds to wait. Defaults to 10.

Returns two values:

FirstSecondWhen
trueA map of player to responseEvery 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.

Server
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.

ParameterTypeWhat it is
transformerTransformerDecides which ref the readiness packet leaves on.
channelstringThe channel to open. For a carrier this is its name.

Returns nothing.

Client
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

FieldSideWhat 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

FieldHealthyA 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.

NameValueWhat it controls
LOAD_PER_REF16Transformers per RemoteEvent in the pool.
MAX_REFS32The most RemoteEvents the pool will ever have.
DEFAULT_TIMEOUT10Seconds a Call or BroadcastCall waits when you do not pass a timeout.
ORPHAN_TTL10Seconds a packet is held when nothing is listening for its name yet.
ORPHAN_MAX64How many such packets are kept per name. The oldest goes first.
BUFFER_TTL30Seconds the server holds a packet for a client that has not readied the channel.
BUFFER_MAX64How many packets the server holds per channel, per client.
OUTBOX_TTL30Seconds the client holds a packet whose ref has not replicated. Readiness packets are exempt.
OUTBOX_MAX64How 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