Carriers

A carrier is a named channel with two ends. You build the same name on the server and on the client and they are talking. This page covers making one, the caching rule, the options table, and the one mistake that catches people out.

What a carrier is

A carrier is the object you send on and listen on. It holds your listeners, your rooms, your middleware and the handshake guard. Almost everything you do with Pigeon is a method on a carrier.

local Pigeon = require(ReplicatedStorage.Pigeon)

local shop = Pigeon.new("Shop")

The name is the channel

The name is the only thing both machines have to agree on. Build Pigeon.new("Shop") on the server and Pigeon.new("Shop") on the client and those two are one channel.

Every event you send travels as the carrier name, a NUL byte, then the event name. A Broadcast("Sold") on the "Shop" carrier goes out as Shop\0Sold. Only carriers called "Shop" listen for that, so a "Quests" carrier never hears it, even if both carriers happen to share a transformer.

Note

The name decides who hears a message. The transformer only decides which pooled RemoteEvent it leaves on. Those are two separate things. See Transformers.

Making a carrier

Pigeon.new(name: string, options: {Unreliable: boolean?, Timeout: number?, Transformer: Transformer?}?) -> PigeonCarrier
ParameterTypeWhat it is
namestringThe channel name, shared by both machines.
options{Unreliable: boolean?, Timeout: number?, Transformer: Transformer?}?Transport options. Passing any table makes the carrier unique and uncached.

Returns a PigeonCarrier: the shared carrier for that name, or a new one.

The name must be a non-empty string. Anything else errors with ERR_NO_NAME. Options are optional, and what happens when you pass them is the important part.

The caching rule

There are two behaviours in one function, and the options table is the switch.

Pigeon.new

just a name look in the cache the same carrier every time
name and options skip the cache a brand new carrier every time

A bare name is a lookup

Called with just a name, Pigeon.new builds the carrier once and hands back that same object for every later call. Any script anywhere in the place can ask for "Shop" and get the one everyone else is using, so you never have to pass a reference around or keep a module just to hold it.

local a = Pigeon.new("Shop")
local b = Pigeon.new("Shop")

print(a == b) --> true

Options always build a fresh one

Called with an options table, Pigeon.new builds a new carrier and does not cache it. Every call gives you a different object.

local a = Pigeon.new("Shop", { Unreliable = true })
local b = Pigeon.new("Shop", { Unreliable = true })

print(a == b) --> false

The reason is that options describe one particular carrier. If Pigeon handed you a cached carrier instead, your Timeout and your Transformer would be silently thrown away and you would be using someone else's settings without knowing. Building a fresh one is the only honest answer.

Careful

The check is only whether you passed a table at all. Even Pigeon.new("Shop", {}) skips the cache and builds a new carrier, because an empty table is still a table. If you want the shared one, pass nothing.

The cache never hands back a dead carrier

When a cached carrier is dropped it removes itself from the cache. Dropping happens when you call carrier:Destroy(), or when the transformer it rides on is destroyed. The next Pigeon.new("Shop") then builds a live replacement.

local shop = Pigeon.new("Shop")
shop:Destroy()

local fresh = Pigeon.new("Shop")

print(fresh == shop) --> false

You never get an inert object back from the cache. See Cleanup for what dropping actually tears down.

The options table

FieldTypeDefaultWhat it does
Unreliable boolean? false Sends fire and forget messages over the lossy lane.
Timeout number? 10 seconds How long any request on this carrier waits for an answer before giving up.
Transformer Transformer? one made from the name The transformer this carrier rides on.

Unreliable

Set this to true and every fire and forget send goes out on an UnreliableRemoteEvent instead: Emit, Broadcast, BroadcastTo, BroadcastExcept and SendToRoom. Packets may arrive out of order or not at all, which is the right trade for something you resend every frame.

local positions = Pigeon.new("Positions", { Unreliable = true })

Only fire and forget sends take that lane. Requests and their replies always go the reliable way, whatever this is set to. Unreliable messages are also never queued for a client that has not started up yet, they are simply dropped. See Unreliable Sending.

Careful

Staged table snapshots and patches are broadcasts too, so they take the lossy lane on an unreliable carrier and a client can end up with a stale mirror. Publish staged tables from a reliable carrier. See Staged Tables.

The value has to be exactly true. Anything else, including a truthy value like 1, leaves the carrier reliable.

Timeout

How many seconds a request waits for the other side to answer. Leave it out and you get 10 seconds. When the time runs out the call returns nil rather than erroring.

It covers every request on the carrier, not just Call and CallTo. Ping, Handshake and RequestTable are each a Call underneath, so they read the same field.

local slow = Pigeon.new("Leaderboards", { Timeout = 30 })

See Requests and Replies.

Transformer

Hand in a transformer and this carrier rides on it instead of making its own. This is how you put several channels on one remote, and how you turn several channels off in one call.

local combat = Pigeon.Transformer("Combat")

local damage = Pigeon.new("Damage", { Transformer = combat })
local status = Pigeon.new("Status", { Transformer = combat })

-- Drops both carriers and every listener on them.
combat:Destroy()

It must be a real transformer. Passing anything else errors with ERR_NO_TRANSFORMER.

Ownership follows who made it. A transformer the carrier made from its own name goes away with the carrier. One you passed in is yours, so carrier:Destroy() leaves it alone.

Reading the options later

Unreliable and Timeout stay on the carrier as plain fields and are read at send time, so you can change your mind afterwards.

local chat = Pigeon.new("Chat")

chat.Timeout = 3      -- calls now give up after 3 seconds
chat.Unreliable = true -- later sends take the lossy lane

One name, two carriers

Because options build a fresh carrier every time, you can have several carriers with the same name on one machine. That is on purpose, and it is the sharpest edge in Pigeon.

Warning

Incoming messages are routed by name alone, so two carriers called "Shop" on the same machine share the channel. A packet goes to every carrier that bound its event with On or When, and every listener those carriers registered for it runs. Register the same handler on each and it runs twice.

local a = Pigeon.new("Shop")
local b = Pigeon.new("Shop", { Unreliable = true })

a:On("Sold", print)
b:On("Sold", print)

-- One broadcast from the server prints twice.

A carrier that did not bind an event is skipped entirely once another carrier on the name has bound it. The listener claims the packet, so the second carrier's incoming middleware never sees that event either. A packet nothing has bound still reaches all of them. See Known Issues.

The rule that keeps you out of trouble:

  • Keep the receiving side to a single carrier per name.
  • Use extra carriers on that name for sending only, when you want different transport settings for different sends.

Give each of them its own transformer if you also want their sends spread over different remotes.

What runs where

A carrier is one object, but most of its methods only work on one side. Calling a server method on the client errors with ERR_NOT_SERVER, and a client method on the server errors with ERR_NOT_CLIENT.

Server only

MethodWhat it does
BroadcastFire and forget to every player.
BroadcastToFire and forget to a list of players.
BroadcastExceptFire and forget to everyone but the listed players.
CallToAsk one client something and wait for the answer.
CreateRoom, DestroyRoom, JoinRoom, LeaveRoom, SendToRoomGroup players and send to the group. See Rooms.
UseHandshakeClose the channel behind a guard. See Handshakes.
ApprovedNarrow a player list to the ones the guard let in.
RevokeTake admission back off players, empty them out of every room, and release the staged tables they were mirroring. See Handshakes.
CaptureTable, ReleaseTable, ForceTablePublish staged tables. See Staged Tables.

Approved is the odd one out. It does not error on the client, but a client never has a guard installed, so it just hands your list straight back.

Client only

MethodWhat it does
EmitFire and forget to the server.
CallAsk the server something and wait for the answer.
HandshakePresent credentials to the server's guard.
PingMeasure the round trip to the server in seconds.
RequestTableAsk for a staged table by id.

Both sides

MethodWhat it does
OnAdd a fire and forget listener for an event. See Sending and Receiving.
WhenSet the one responder that answers Call and CallTo for an event. See Requests and Replies.
OffTake a listener or the responder off an event, or clear both.
UseIncoming, UseOutgoingAdd middleware. See Middleware.
DestroyDrop the carrier.
InitOpen the channel for traffic from the server.

Init is really a client thing, but it does nothing at all on the server and returns the carrier. That way shared code can call it without checking which machine it is on. See Startup and Buffering.

Client
local shop = Pigeon.new("Shop")

shop:On("StockChanged", updateShopUi)
shop:Init()  -- returns the carrier, so this can be chained

A dropped carrier goes quiet

Once a carrier is dropped it stops sending and stops receiving. It does not error. Teardown often races with work already in flight, and throwing there would spread the problem instead of containing it.

Client
local shop = Pigeon.new("Shop")
shop:Destroy()

shop:Emit("Buy", "sword")  -- does nothing, does not error

The side check comes first, though. Emit is client only, so calling it on the server still errors with ERR_NOT_CLIENT whether the carrier is dropped or not.

Next