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
| Parameter | Type | What it is |
|---|---|---|
name | string | The 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
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
| Field | Type | Default | What 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
| Method | What it does |
|---|---|
Broadcast | Fire and forget to every player. |
BroadcastTo | Fire and forget to a list of players. |
BroadcastExcept | Fire and forget to everyone but the listed players. |
CallTo | Ask one client something and wait for the answer. |
CreateRoom, DestroyRoom, JoinRoom, LeaveRoom, SendToRoom | Group players and send to the group. See Rooms. |
UseHandshake | Close the channel behind a guard. See Handshakes. |
Approved | Narrow a player list to the ones the guard let in. |
Revoke | Take admission back off players, empty them out of every room, and release the staged tables they were mirroring. See Handshakes. |
CaptureTable, ReleaseTable, ForceTable | Publish 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
| Method | What it does |
|---|---|
Emit | Fire and forget to the server. |
Call | Ask the server something and wait for the answer. |
Handshake | Present credentials to the server's guard. |
Ping | Measure the round trip to the server in seconds. |
RequestTable | Ask for a staged table by id. |
Both sides
| Method | What it does |
|---|---|
On | Add a fire and forget listener for an event. See Sending and Receiving. |
When | Set the one responder that answers Call and CallTo for an event. See Requests and Replies. |
Off | Take a listener or the responder off an event, or clear both. |
UseIncoming, UseOutgoing | Add middleware. See Middleware. |
Destroy | Drop the carrier. |
Init | Open 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.
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.
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
- Sending and Receiving for the fire and forget methods.
- Requests and Replies for asking a question and waiting.
- Carrier API for the full method list with signatures.