Carrier
A carrier is a named channel. It is the object you send on and listen on, and it is
the only Pigeon object most games ever touch. You make one with
Pigeon.new.
The name is the channel. Build Pigeon.new("Shop") on the server and
Pigeon.new("Shop") on the client and the two are talking. Nothing else
has to match.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Pigeon = require(ReplicatedStorage.Pigeon)
local shop = Pigeon.new("Shop")
Things that are true of every method
- Call them with a colon:
carrier:Emit("Buy", id). -
Server handlers are called with
(player, ...). Client handlers are called with(...), because there is only one sender. That holds forOnandWhenalike. -
Every event name is scoped to the carrier before it goes on the wire. It travels
as
name .. "\0" .. event, so two carriers with different names never hear each other, even if they share a transformer. -
Event names starting with
__pigeon_are reserved. Pigeon runs ping, handshakes and staged tables over them. Do not use that prefix. -
Arguments carry
nilfaithfully.Emit("Hit", 1, nil, 6)reaches the server as1, nil, 6. That holds in any position, in every direction, and for the values a responder returns. Anilinside a table you pass is still a hole in that table, so use explicit keys if you need one there. -
A server-only method called on the client errors with
ERR_NOT_SERVER, and a client-only method called on the server errors withERR_NOT_CLIENT. The error points at your call site, not at Pigeon. Two methods do not follow that rule.Approvednever checks which machine it is on, andInitis a no-op on the server rather than an error, so shared code can call it either way. -
Once a carrier is dropped it goes quiet instead of erroring. Sends do nothing,
Callreturnsnil. See Destroy.
Note
A nil argument crosses the wire as a private marker string and is
turned back into nil on arrival. The marker is full of NUL bytes, so
no ordinary string looks like it, but a client that sends that exact string is
read as nil.
Every method at a glance
| Method | Side | What it does |
|---|---|---|
On | Shared | Adds a listener for fire and forget traffic. As many per event as you like, and they cannot answer anything. |
When | Shared | Sets the one responder that answers Call and CallTo for an event. |
Off | Shared | Takes a listener or the responder off an event, or clears both. |
Emit | Client | Fires an event at the server and does not wait. |
Broadcast | Server | Fires an event at every player. |
BroadcastTo | Server | Fires an event at a list of players. |
BroadcastExcept | Server | Fires an event at everyone but the players you name. |
Call | Client | Asks the server something and waits for the answer. |
CallTo | Server | Asks one client something and waits for the answer. |
Ping | Client | Measures the round trip to the server in seconds. |
Init | Client | Opens the channel so the server may send to you. |
CreateRoom | Server | Makes a room and puts players in it. |
DestroyRoom | Server | Deletes a room. |
JoinRoom | Server | Adds players to a room, making it if needed. |
LeaveRoom | Server | Takes players out of a room. |
SendToRoom | Server | Fires an event at every member of a room. |
UseHandshake | Server | Closes the channel behind a guard you write. |
Handshake | Client | Presents credentials to the guard and waits for a ruling. |
Approved | Server | Narrows a player list to those the guard let in. |
Revoke | Server | Takes admission back off players, empties them out of rooms and releases their staged tables. |
UseIncoming | Shared | Adds middleware to the inbound path. |
UseOutgoing | Shared | Adds middleware to the outbound path. |
CaptureTable | Server | Publishes a staged table under an id. |
ReleaseTable | Server | Drops this carrier's pointer to a staged table. |
ForceTable | Server | Pushes a staged table onto clients without being asked. |
RequestTable | Client | Asks for a staged table and waits for the first copy. |
Destroy | Shared | Drops the carrier and everything it subscribed to. |
Which handler a message reaches
There are two ways to listen and they are not interchangeable. The kind of packet
decides which one runs, not which handlers you happen to have registered. Fire and
forget traffic only ever reaches On. A request only
ever reaches When.
| What was sent | What runs | Answer |
|---|---|---|
Emit |
Every On for that event, on the server |
None |
Broadcast, BroadcastTo, BroadcastExcept, SendToRoom |
Every On for that event, on each client |
None |
Call |
The one When for that event, on the server |
What it returns |
CallTo |
The one When for that event, on that client |
What it returns |
Both can sit on the same event name at once, and that is the point of splitting them. One name can carry any number of fire and forget listeners and a single responder, and each kind of traffic goes to the side that fits it.
-- Fire and forget. Runs for shop:Emit("Buy", id).
shop:On("Buy", function(player, itemId)
logAttempt(player, itemId)
end)
-- The responder. Runs for shop:Call("Buy", id), and for nothing else.
shop:When("Buy", function(player, itemId)
if not canAfford(player, itemId) then
return false, "too expensive"
end
giveItem(player, itemId)
return true
end)
Careful
On listeners cannot answer a Call. They are not even
run for one. A request for an event with listeners but no responder is held for
10 seconds in case a When turns up, and the caller gives up when its
own timeout runs out. If you want an answer, write a When.
Note
Pigeon's own reserved events follow the same rule.
__pigeon_ping, __pigeon_handshake and
__pigeon_table_request are requests, so they are registered with
When. The staged table patch, snapshot, release and revoke events
are fire and forget, so they use On.
Properties
carrier.Unreliable: boolean
false unless you passed Unreliable = true in the options.
When it is true, Emit and every
broadcast take the lossy lane: an UnreliableRemoteEvent, which is cheaper but may
drop a packet and may deliver out of order. Staged table snapshots and patches go
out as broadcasts, so they take that lane too. Do not set this on a carrier that
replicates a staged table.
It is read at the moment of each send, so you can flip it whenever you like.
local pos = Pigeon.new("Positions", { Unreliable = true })
-- Or set it later on a carrier you already have.
pos.Unreliable = false
Careful
Requests always take the reliable lane whatever this is set to, because a reply
that goes missing would strand the thread waiting on it. So
Call,
CallTo,
Ping,
Handshake and
RequestTable ignore it. Server to
client unreliable packets are also never queued for a client that has not called
Init. They are dropped instead.
Read more in Unreliable Sending.
carrier.Timeout: number?
How many seconds a request waits before giving up. nil by default,
which means 10 seconds. Set it to a number to change it for every
Call, CallTo, Ping,
Handshake and RequestTable on this carrier.
local shop = Pigeon.new("Shop")
shop.Timeout = 3 -- give up after three seconds instead of ten
Note
Setting these two through options has a catch.
Pigeon.new(name, options) always builds a fresh, uncached carrier,
so the next Pigeon.new(name) somewhere else in your code gets a
different object. If you only want to change these fields, take the cached
carrier and assign them, as above.
Listening
On Shared
carrier:On(event: string, Callback: (...any) -> ()) -> ()
Adds a listener for fire and forget traffic. It runs every time an
Emit, a broadcast or a room send with that name
arrives on this channel. Requests never come here.
Call and CallTo
go to When instead.
| Parameter | Type | What it is |
|---|---|---|
event | string | The event name to listen for. |
Callback | (...any) -> () | Runs for each matching message. Anything it returns is thrown away. |
Returns nothing.
local shop = Pigeon.new("Shop")
-- The player who sent it comes first.
shop:On("Browse", function(player, category)
recordInterest(player, category)
end)
local shop = Pigeon.new("Shop")
-- No player argument here.
shop:On("StockChanged", function(stock)
updateShopUi(stock)
end)
-- Nothing arrives until the channel is open.
shop:Init()
As many listeners as you like
Adding a second listener does not replace the first. Every listener on the event runs for every message.
Each one is started with task.spawn, so it gets its own thread. They
start in the order you added them, but one that yields does not hold up the ones
behind it, and the order they finish in is not fixed. Write them so that no listener
depends on another having run.
A listener cannot answer anything
Because listeners are spawned, nothing is waiting on the thread they run on and a
return value has nowhere to go. return true at the end of an
On callback does nothing at all. On is for traffic you do
not reply to. Use When to answer a
Call or a CallTo.
A listener that errors
Nothing catches it. An error inside an On callback is an ordinary
Roblox error, with a full traceback in the output window, raised on the thread that
listener was spawned on. There is no ERR_CALLBACK_ERROR warning for
this, and the other listeners for the event are unaffected because each has a thread
of its own.
Listening slightly too late
A fire and forget message that arrives when nothing is listening for that event is
held for 10 seconds, up to 64 messages per event, and replayed to the first
On that registers. That covers the gap between the channel opening and
your handler being set up. It is not a substitute for registering handlers before
Init.
A held fire and forget only ever replays to an On. Registering a
When for the same name does not collect it.
Only the first listener gets the backlog. A second one registered afterwards was not there for it either way. A held message has already run the incoming middleware chain, when it arrived, and does not run it again on the way to your handler. See Middleware.
On a destroyed carrier
It does nothing, and does not error. A destroyed carrier registers no listeners and holds on to none, so the callback you pass is dropped rather than kept alive. Build a fresh carrier when you need the channel again. See Cleanup.
When Shared
carrier:When(event: string, Callback: ((...any) -> ...any)?) -> ()
Sets the responder for an event. It runs when a Call
or a CallTo for that name arrives, and whatever
it returns is sent back as the answer. Fire and forget traffic never reaches it.
| Parameter | Type | What it is |
|---|---|---|
event | string | The event name to answer requests for. |
Callback | ((...any) -> ...any)? | Runs for each request. Its return values are the reply. Omit it, or pass nil, to clear the responder for that event. |
Returns nothing.
local shop = Pigeon.new("Shop")
-- The player who asked comes first.
shop:When("Buy", function(player, itemId)
if not canAfford(player, itemId) then
return false, "too expensive"
end
giveItem(player, itemId)
return true
end)
local ui = Pigeon.new("Ui")
-- Answers the server's ui:CallTo(player, "GetSettings").
ui:When("GetSettings", function()
return readLocalSettings()
end)
ui:Init()
One per event
There is a single responder per event name, not a list. Calling When
again for the same event replaces the callback without a word, and the one you set
last is the one that answers. Off takes it back off,
and so does calling When with no callback at all.
shop:When("Price", priceOf)
shop:When("Price") -- the responder is cleared, same as Off("Price", priceOf)
It runs on the packet's own thread
Unlike a listener, a responder is not spawned. It runs inline on the thread handling the packet, which is what lets its return values be collected and sent back.
It may yield. Read a DataStore in there if you need to. The caller keeps waiting
while you do, up to their Timeout, and nothing else on the carrier is
held up, because that thread exists only for this one packet.
What goes back
Every value the responder returns is sent, in order, with nil kept
faithfully in any position. Returning nothing gives the caller a single
nil, which looks exactly like a timeout from their side, so return an
explicit value if they need to tell the two apart.
A responder that errors
Errors here are caught, not raised. Pigeon warns with
ERR_CALLBACK_ERROR | Error in responder callback for event '...' and no
reply is sent at all, so the waiting side sits there until it times out and gets
nil.
Answering slightly too late
A request that arrives when the event has no responder is held for 10 seconds, up to
64 messages per event, and replayed to the first When that registers for
it. A held request only ever replays to a When. Do not lean on this: the
caller's own timeout is counting down the whole time, and the default is the same 10
seconds, so a late responder usually answers a caller who has already given up.
Note
On the server, a reply to a player who has left the game between asking and being answered is dropped quietly. Pigeon checks the player is still in the game before it sends, and the send itself is wrapped, so a client that leaves in that gap cannot make your responder's thread error.
Taking the responder off
Pass nil instead of a callback and the responder is cleared. Any
On listeners on that event are left alone.
shop:When("Buy", function(player, itemId)
return giveItem(player, itemId)
end)
shop:When("Buy", nil)
This is the only way to remove a responder you wrote inline, since there is no
reference to hand to Off.
Off("Buy") would clear the listeners as well.
On a destroyed carrier
Same as On. It does nothing, does not error, and does
not keep hold of your callback.
Off Shared
carrier:Off(event: string, Callback: ((...any) -> ...any)?) -> ()
Takes handlers off an event. One call covers both kinds: it looks at the
On list and at the
When responder.
| Parameter | Type | What it is |
|---|---|---|
event | string | The event name to stop handling. |
Callback | ((...any) -> ...any)? | The exact function to remove. Omit to remove everything on the event. |
| What you pass | What comes off |
|---|---|
| A callback | It is taken out of the On list for that event if it is in it, and it is cleared as the responder if it is the responder. Both are checked in the one call, so you do not have to remember which way you registered it. |
| No callback | Every On listener for that event and the responder. All of it. |
Returns nothing. Removing something that was never added does nothing.
local function onSold(id)
print("sold", id)
end
shop:On("Sold", onSold)
shop:Off("Sold", onSold) -- takes that one listener off
shop:When("Price", priceOf)
shop:Off("Price", priceOf) -- clears the responder
shop:Off("Sold") -- everything left on "Sold", listeners and responder
The underlying subscription for the event is only dropped once both sides are empty. Clear the responder on an event that still has listeners and the channel stays open for them, and the other way round.
Careful
Matching is by function identity. A callback you wrote inline as
shop:On("Sold", function(id) ... end) cannot be removed on its own,
because you have no reference to it. Keep it in a local if you plan to remove it,
or leave the callback out and clear the whole event.
Sending from the client
Emit Client
carrier:Emit(event: string, ...: any) -> ()
Fires an event at the server and moves on. It never waits and it never tells you whether anyone heard.
| Parameter | Type | What it is |
|---|---|---|
event | string | The event to raise on the server. |
... | any | The payload. Anything a RemoteEvent can carry. |
Returns nothing.
local shop = Pigeon.new("Shop")
shop:Emit("Browse", "swords")
Sending never needs Init. You can emit on the
first frame of your script. If the RemoteEvent pool has not replicated yet, the
message is held on your machine and sent the moment it does, so nothing yields. That
queue is shared by everything this client sends. It holds 64 messages for 30 seconds
and drops the oldest to make room.
Errors with ERR_NOT_CLIENT if you call it on the server.
Sending from the server
Broadcast Server
carrier:Broadcast(event: string, ...: any) -> ()
Fires an event at every player in the game.
| Parameter | Type | What it is |
|---|---|---|
event | string | The event to raise on each client. |
... | any | The payload. |
Returns nothing.
local shop = Pigeon.new("Shop")
shop:Broadcast("StockChanged", getStock())
This is BroadcastTo with the current
player list, so everything that method does applies here too: the handshake guard
filters the recipients, and a reliable send to someone who has not called
Init is queued rather than dropped.
Errors with ERR_NOT_SERVER if you call it on the client.
BroadcastTo Server
carrier:BroadcastTo(Targets: {Player}, event: string, ...: any) -> ()
Fires an event at the players you list. Every other fire-and-forget send from the
server ends up here, so this is the one place those recipients are filtered.
CallTo is the exception. It checks its one
player itself and does not come through here.
| Parameter | Type | What it is |
|---|---|---|
Targets | {Player} | The recipients. Must be a list. |
event | string | The event to raise on each client. |
... | any | The payload. |
Returns nothing. If nobody in the list is allowed to receive, nothing is sent at all.
local party = { playerA, playerB }
raid:BroadcastTo(party, "BossSpawned", bossId)
-- One player still needs a list.
raid:BroadcastTo({ playerA }, "Whisper", "you are next")
Careful
This one does not accept a bare Player. Pass
{ player }, not player, or you will get a Luau error
when Pigeon tries to treat that Instance as a list.
BroadcastExcept,
JoinRoom,
LeaveRoom,
ForceTable and
Revoke do accept one player on
its own.
Errors with ERR_NOT_SERVER if you call it on the client.
BroadcastExcept Server
carrier:BroadcastExcept(Skip: {Player}, event: string, ...: any) -> ()
Fires an event at everyone except the players you name. Useful for telling the rest of the server about something the player who caused it already knows.
| Parameter | Type | What it is |
|---|---|---|
Skip | {Player} | Who to leave out. A single Player works too. |
event | string | The event to raise on each remaining client. |
... | any | The payload. |
Returns nothing.
chat:On("Say", function(player, text)
-- Everyone but the speaker.
chat:BroadcastExcept(player, "Message", player.Name, text)
end)
Errors with ERR_NOT_SERVER if you call it on the client.
Requests
A request is a send that waits for an answer. The
When responder on the far side answers it by
returning a value. Listeners added with On are not
run for a request at all. Read Requests and
Replies for the whole picture.
One request
Call Client Yields
carrier:Call(event: string, ...: any) -> ...any
Asks the server something and waits. Returns whatever the server's
When responder returned, all of it, in order.
| Parameter | Type | What it is |
|---|---|---|
event | string | The event to raise on the server. |
... | any | The request payload. |
Returns the server responder's return values, or a single
nil if the call timed out or the carrier has been dropped.
local ok, reason = shop:Call("Buy", "sword")
if not ok then
print("could not buy:", reason)
end
Careful
Three things look exactly the same from here, and all three give you
nil: a timeout, a responder that returned nothing, and an event with
no When on the server at all. If you need to tell
them apart, return an explicit value from the server, such as true or
a status string.
What the wait covers
The clock starts when you call, not when the packet leaves. So the wait for the
RemoteEvent pool to replicate on a fresh join is inside your timeout, and you never
wait longer than you asked for. The default is 10 seconds. Change it with
carrier.Timeout.
Call does not need Init. Replies are never queued, so a
call works before the channel has been opened for inbound traffic.
Errors with ERR_NOT_CLIENT if you call it on the server.
CallTo Server Yields
carrier:CallTo(Target: Player, Event: string, ...: any) -> ...any
Asks one client something and waits. It reaches that client's
When for the event, not its listeners. Note the
capital letters on these parameter names, and that the player comes first.
| Parameter | Type | What it is |
|---|---|---|
Target | Player | The one client to ask. |
Event | string | The event to raise on that client. |
... | any | The request payload. |
Returns the client responder's return values, or nil if
the call timed out, the player left, the carrier has been dropped, or a handshake
guard is installed and that player has not passed it.
local settings = ui:CallTo(player, "GetSettings")
if settings then
saveSettings(player, settings)
end
Careful
Never trust what comes back. A client answers a CallTo with whatever
it likes, including a value shaped to fool you. Treat it as player input.
Careful
If that client has not called Init on this
channel yet, the request sits in the server's queue for them and your thread waits
anyway. Unless they open the channel inside your timeout, the call comes back
nil.
Errors with ERR_NOT_SERVER if you call it on the client.
Ping Client Yields
carrier:Ping() -> number
Measures the round trip to the server in seconds. It takes no arguments. The server side of every carrier answers this on its own, so there is nothing to set up.
Returns a number of seconds.
local seconds = shop:Ping()
print(string.format("%d ms", seconds * 1000))
Careful
This measures the whole trip, not just the network. Your outgoing middleware here, your incoming middleware on the server, the time the request spends queued waiting for the remote pool on a fresh join, and the server's own scheduling are all in the number. The first ping of a session is usually the odd one out.
It also never fails loudly. A ping that times out returns roughly the timeout, and a ping on a dropped carrier returns a number close to zero.
Errors with ERR_NOT_CLIENT if you call it on the server.
Startup
Init Client
carrier:Init() -> PigeonCarrier
Tells the server this client is ready to receive on this channel. Until you call it,
the server holds everything it sends you here. Call it once, after your
On and When handlers are in place.
Returns the carrier, so you can chain from it. It never yields.
local shop = Pigeon.new("Shop")
shop:On("StockChanged", updateShopUi)
shop:On("Sold", flashItem)
-- Handlers first, then open the channel. The backlog arrives in order.
shop:Init()
| Thing | What actually happens |
|---|---|
| Called twice | The second call does nothing. |
| Called on the server | Does nothing and returns the carrier, so shared code can call it either way. |
| Called on a dropped carrier | Does nothing and returns the carrier. |
| Never called | You can still send. You just never receive. |
What the server was holding
The queue is per channel and per player. It holds 64 messages for 30 seconds, oldest dropped first once it is full. Opening one channel releases only that channel's backlog, so bringing your channels up one at a time is safe. Unreliable sends are never queued.
Careful
Do not chain Pigeon.new("Shop"):Init() on one line and register
handlers below it. That opens the channel before anything is listening, and the
backlog lands in the 10 second replay window instead of going straight to your
handlers.
Read more in Startup and Buffering.
Rooms
A room is a named list of players kept on this carrier, on the server. There is no client side to it. Players who leave the game are taken out of every room for you. Read more in Rooms.
On a carrier with a handshake guard, only players who
have passed it can be put in a room. CreateRoom
and JoinRoom raise
ERR_NOT_APPROVED for anyone who has not.
The reason is that a room on a guarded channel is a list of people allowed to hear something. Somebody who cannot pass the guard is filtered out of every send anyway, so their entry in the room could never do anything. Failing at the call says so, instead of leaving you a room that quietly reaches fewer people than it lists.
So the order is: let the client handshake first, then put it in rooms. A carrier with no guard takes anybody, exactly as it always did.
CreateRoom Server
carrier:CreateRoom(RoomID: string, Members: {Player}) -> ()
Makes a room and puts the listed players in it.
| Parameter | Type | What it is |
|---|---|---|
RoomID | string | The room name. |
Members | {Player} | The starting members. A list, or nil for an empty room. |
Returns nothing.
local raid = Pigeon.new("Raid")
raid:CreateRoom("Party1", { playerA, playerB })
raid:CreateRoom("Lobby", {}) -- empty, and stays alive until you destroy it
Careful
This replaces the room if one already has that name, and it does not remove
duplicates. The same player listed twice is added twice and will get every room
message twice. JoinRoom does check, so
use that if you are not sure.
Members the guard has not admitted
If this carrier has a handshake guard, every player in
Members must have passed it. If one has not, the call raises
ERR_NOT_APPROVED, which names both the player and the room. With no
guard installed the check does not run and any player is fine.
The whole list is read before anything is written, so a refused call changes nothing at all. No empty room is left behind under that name, and a room that already had that name is still there with its old members.
-- Kestrel has not handshaked yet, so neither player is added
-- and "Party1" is not created.
raid:CreateRoom("Party1", { rune, kestrel })
--> ERR_NOT_APPROVED | Kestrel has not passed the handshake, so cannot be put in room 'Party1'.
Errors with ERR_NOT_SERVER if you call it on the client.
DestroyRoom Server
carrier:DestroyRoom(RoomID: string) -> ()
Deletes the room. The players are not told and nothing else about them changes. They are simply not reachable through that name any more.
| Parameter | Type | What it is |
|---|---|---|
RoomID | string | The room name. |
Returns nothing. Destroying a room that does not exist does nothing.
raid:DestroyRoom("Party1")
Errors with ERR_NOT_SERVER if you call it on the client.
JoinRoom Server
carrier:JoinRoom(RoomID: string, Members: {Player} | Player) -> ()
Adds players to a room. If the room does not exist it is made first, so you can skip
CreateRoom entirely.
| Parameter | Type | What it is |
|---|---|---|
RoomID | string | The room name. |
Members | {Player} | Player | One player or a list of them. |
Returns nothing. A player already in the room is not added again.
Players.PlayerAdded:Connect(function(player)
raid:JoinRoom("Lobby", player)
end)
raid:JoinRoom("Party1", { playerA, playerB })
Members the guard has not admitted
Same rule as CreateRoom. On a carrier with a
handshake guard, naming a player who has not passed it
raises ERR_NOT_APPROVED with that player's name and the room's. On a
carrier with no guard the check does not run.
Everyone named is checked before anyone is added, so a refused call adds nobody. Name one admitted player and one who is not and neither goes in. If the room did not exist, it still does not.
local raid = Pigeon.new("Raid")
raid:UseHandshake(function(player, ticket)
return holdsTicket(player, ticket)
end)
-- Wrong order. Nobody has handshaked this early, so this throws
-- and the player is in no room.
Players.PlayerAdded:Connect(function(player)
raid:JoinRoom("Lobby", player)
end)
-- Right order. They are through the door by the time this runs,
-- because an unapproved client's messages never reach a listener.
raid:On("Ready", function(player)
raid:JoinRoom("Lobby", player)
end)
local raid = Pigeon.new("Raid")
if raid:Handshake(myTicket) then
raid:On("BossSpawned", showBoss)
raid:Init()
raid:Emit("Ready")
end
Careful
Do not call this from inside the guard itself. A player is only recorded as
admitted after your guard has returned true, so a
JoinRoom in there always throws
ERR_NOT_APPROVED. Put them in a room from a listener that runs
afterwards, as above.
Errors with ERR_NOT_SERVER if you call it on the client.
LeaveRoom Server
carrier:LeaveRoom(RoomID: string, Members: {Player} | Player) -> ()
Takes players out of a room. The room stays, even if it is now empty.
| Parameter | Type | What it is |
|---|---|---|
RoomID | string | The room name. |
Members | {Player} | Player | One player or a list of them. |
Returns nothing. Leaving a room that does not exist, or removing a player who was never in it, does nothing.
raid:LeaveRoom("Party1", playerB)
Errors with ERR_NOT_SERVER if you call it on the client.
SendToRoom Server
carrier:SendToRoom(Room: string, event: string, ...: any) -> ()
Fires an event at every member of a room. Note the parameter here is called
Room, not RoomID.
| Parameter | Type | What it is |
|---|---|---|
Room | string | The room name. |
event | string | The event to raise on each member. |
... | any | The payload. |
Returns nothing.
raid:SendToRoom("Party1", "BossSpawned", bossId)
Note
Sending to a room that does not exist is not an error. Pigeon warns with
ERR_NO_ROOM | Room '...' does not exist. and drops the message. Watch
the output window if a room send seems to go nowhere.
Errors with ERR_NOT_SERVER if you call it on the client.
Access control
A handshake closes a channel. Install a guard on the server and clients must pass it before this carrier will carry anything for them, in either direction. Read more in Handshakes.
UseHandshake Server
carrier:UseHandshake(Callback: (player: Player, ...any) -> boolean) -> ()
Closes the channel and puts your guard on the door. The guard gets the player and
whatever they passed to Handshake,
and must return true to let them in. Anything else is a refusal.
| Parameter | Type | What it is |
|---|---|---|
Callback | (player: Player, ...any) -> boolean | Runs on every handshake attempt. |
Returns nothing.
local mods = Pigeon.new("Moderation")
mods:UseHandshake(function(player, token)
return isModerator(player) and token == tokenFor(player)
end)
-- Only players the guard admitted see this.
mods:Broadcast("Alert", "someone was reported")
What being closed means, exactly:
| Direction | What happens to a client who has not passed |
|---|---|
| Client to server | Everything they send on this carrier is dropped at the door, before any handler or incoming middleware runs. The handshake itself is the one packet that gets through. |
| Server to client | They are filtered out of every broadcast and every room send, and CallTo for them returns nil without sending. |
Rules to know
- The channel is closed the moment you install the guard. Nobody is in yet.
- Only one guard exists at a time. Installing another replaces it and closes the channel again, because the clients already inside were judged by the old rules. They are not told, so they have to handshake again. This clears admission only. Rooms and staged table holders are left as they were, so a room can hold a player the new guard has not yet ruled on.
- The guard runs on every attempt and the latest answer wins. A later refusal takes access away from a client that had it. That is how you revoke.
-
A client that loses access this way is cleared out of the carrier exactly as
Revokeclears it: out of every room, and off every staged table it held, with its copies released on its machine. A client that was never admitted is left alone, because there is nothing to take. -
A guard that errors is caught. Pigeon warns with
ERR_CALLBACK_ERRORand the client is told no. An error counts as a refusal, so a client that was in is put out, the same as if you had returnedfalse. -
A player must be through the guard before you can put them in a room.
CreateRoomandJoinRoomraiseERR_NOT_APPROVEDotherwise.
Careful
A client can call Handshake as often as it likes, and your guard runs
every single time. Keep it cheap, and do not put a DataStore read in it without a
rate limit of your own.
Errors with ERR_NOT_SERVER if you call it on the client.
Handshake Client Yields
carrier:Handshake(...: any) -> boolean
Presents whatever the guard needs and waits for the ruling. On a carrier with no
guard on the server this returns true.
| Parameter | Type | What it is |
|---|---|---|
... | any | Passed straight to the server's guard, after the player. |
Returns true if you may now use this carrier. Returns
false on a refusal, on a timeout, or if the carrier has been dropped.
local mods = Pigeon.new("Moderation")
if mods:Handshake(myToken) then
mods:On("Alert", showAlert)
mods:Init()
end
Retrying is fine and is the only way back in. Nothing tells you when the server swaps its guard or takes your access away, so if a closed channel goes quiet, hand your credentials over again.
Errors with ERR_NOT_CLIENT if you call it on the server.
Approved Server
carrier:Approved(Targets: {Player}?) -> {Player}
Narrows a list of players down to the ones this carrier will talk to. With no guard installed that is everybody. With a guard it is the players it admitted.
| Parameter | Type | What it is |
|---|---|---|
Targets | {Player}? | The players to filter. Leave it out for everyone in the game. |
Returns a list of players.
local watching = mods:Approved()
print(#watching, "moderators are listening")
-- Or check one player.
local allowed = #mods:Approved({ player }) == 1
You rarely need this. Every broadcast from the server already runs its recipients
through it, and CallTo makes the same check for its one player. This is
for when you want the answer without sending anything.
Note
Two small things the source does that may surprise you. When no guard is installed, it hands back the very table you passed in rather than a copy, so do not sort or edit the result in place unless you meant to. And unlike the other server methods it does not check which machine it is on, so calling it on the client returns the list unfiltered instead of erroring. Guards only exist on the server, so the client answer means nothing.
Revoke Server
carrier:Revoke(Targets: {Player} | Player) -> number
Takes admission away again. Each player you name is cleared from the carrier's admission list, so the guard no longer counts them as let in. They are also taken out of every room on this carrier and dropped from the subscriber list of every staged table it holds.
If a player was subscribed to at least one staged table, Pigeon tells them which
ids they lost over a reserved __pigeon_table_revoke message. That one
goes straight down the transport instead of through
BroadcastTo, because the player has just
stopped being approved and a broadcast would filter them out. On their machine each
named mirror is dropped from the cache, its release hook is cleared so it does not
send the server a release for something the server already did, and it is marked
released.
| Parameter | Type | What it is |
|---|---|---|
Targets | {Player} | Player | One player or a list of them. |
Returns how many of the players you named had actually been
admitted. A player the guard never let in, and a player you revoked already, both
count as zero. On a dropped carrier it returns 0 and does nothing.
local mods = Pigeon.new("Moderation")
mods:UseHandshake(function(player, token)
return isModerator(player) and token == tokenFor(player)
end)
-- They lost the role, so shut the channel on them.
print(mods:Revoke(player)) --> 1
-- Nothing left to take the second time.
print(mods:Revoke(player)) --> 0
local match = Pigeon.new("Match")
-- A list works the same way, and the count tells you how many were still in.
local removed = match:Revoke(round.eliminated)
print(removed .. " of " .. #round.eliminated .. " were still admitted")
What it changes
| For a revoked player | What is true afterwards |
|---|---|
| Server to them | Approved leaves them out, so Broadcast, BroadcastTo, BroadcastExcept and SendToRoom all skip them, and CallTo refuses them. |
| Them to the server | Anything they send on this carrier is dropped at the door, before any handler or incoming middleware runs. |
| Staged tables | Every mirror they held from this carrier stops updating and is marked released. What they already received stays in their memory. |
| Rooms | They are taken out of every room on this carrier. Rooms do not come back on their own, so once they are admitted again, call JoinRoom to put them back. |
| Getting back in | They can call Handshake again and be judged afresh, then ask for the staged table again, which re-fetches it. |
Warning
Revoking only blocks sending when a guard is installed, because
Approved only filters when there is one. On a carrier with no
UseHandshake, Revoke still
empties them out of every room and releases their staged tables, but broadcasts
keep reaching them and their messages keep arriving. Install a guard first, or
this call does half of what you think.
Careful
Revoking cuts off the future. It cannot undo the past. Every message and every
table snapshot they already received is on their machine and stays there, so do
not treat Revoke as a way to make sent data secret again.
Note
This is not a ban and nothing about it is permanent. The guard is what decides, every time, and it runs again the moment the client hands its credentials over. If you want them out for good, make the guard say no.
Errors with ERR_NOT_SERVER if you call it on the client.
Middleware
Middleware sees the messages on this carrier and may rewrite the event name and the arguments. Entries run in the order you add them. Read more in Middleware.
type MiddlewareFunction = (direction: string, event: string, args: {any}, player: Player?) -> (string?, {any}?)
| Argument | What it is |
|---|---|
direction | The string "incoming" or "outgoing". |
event | The event name as it stands after the entries before you. |
args | A list of the arguments. It is already a copy, so editing it in place is safe. |
player | The other side, on the server. See the table below. |
Return nothing to leave the message alone. Return a string to rename the event.
Return a table as the second value to replace the arguments. You can do just one:
return nil, args replaces only the arguments.
| Where | What player is |
|---|---|
| Incoming, on the server | The player who sent it. |
| Incoming, on the client | nil. |
Outgoing, from CallTo | The player being asked. |
Outgoing, from Revoke or a refused handshake | The player losing access, on the reserved __pigeon_table_revoke message it sends them. |
| Outgoing, from anything else | nil, including server broadcasts. |
Careful
Middleware cannot cancel a message. There is no return value that means "stop".
Incoming middleware can drop one in practice by renaming the event to something
nothing listens for: a renamed packet is thrown away rather than held, and a
request renamed that way is answered with nil at once instead of
waiting out its timeout. A packet nobody renamed is still held for 10 seconds.
Outgoing middleware cannot drop anything: the packet still leaves this machine,
it just arrives under a different name.
Note
Middleware also sees the reserved __pigeon_ events, so a logger will
print pings, handshakes and staged table traffic alongside your own. Filter on the
event name if you do not want them.
One more detail on args. The number of arguments delivered is the
larger of the count that came in and the length of your list, so appending works
as you expect but removing entries does not shorten the call. Anything you took
off the end arrives as nil.
UseIncoming Shared
carrier:UseIncoming(Callback: MiddlewareFunction) -> ()
Adds middleware to the inbound path. It runs after the handshake check and before
any handler. It runs for every message addressed to this carrier, including events
nothing has an On or a When for, which is why renaming one
onto an event you do handle works.
| Parameter | Type | What it is |
|---|---|---|
Callback | MiddlewareFunction | Runs for every message arriving on this carrier. |
Returns nothing.
shop:UseIncoming(function(direction, event, args, player)
print(player.Name, "sent", event)
if event == "Buy" and type(args[1]) ~= "string" then
-- Nobody listens to this, so the message stops here.
return "__dropped"
end
end)
UseOutgoing Shared
carrier:UseOutgoing(Callback: MiddlewareFunction) -> ()
Adds middleware to the outbound path. It runs once per send, just before the message leaves this machine, not once per recipient.
| Parameter | Type | What it is |
|---|---|---|
Callback | MiddlewareFunction | Runs for every message leaving this carrier. |
Returns nothing.
shop:UseOutgoing(function(direction, event, args, player)
args[#args + 1] = os.clock() -- stamp everything we send
return nil, args
end)
Note
If a middleware function errors, Pigeon warns and skips that one entry. The message carries on down the chain unchanged by it.
Staged tables
A staged table is a plain data table whose writes replicate to the clients holding
it. The carrier is how one is published and requested. Make one with
Pigeon.StagedTable and read
StagedTable for what you can do with the object
itself.
CaptureTable Server
carrier:CaptureTable(id: string, Table: StageTable) -> ()
Publishes a staged table under an id so clients may ask for it, and starts sending its changes to whoever holds it.
| Parameter | Type | What it is |
|---|---|---|
id | string | The id clients will request. |
Table | StageTable | The staged table to publish. |
Returns nothing.
local world = Pigeon.new("World")
local state = Pigeon.StagedTable({ round = 1, phase = "warmup" })
world:CaptureTable("WorldState", state)
-- Both writes land in one patch, not two.
local t = state:GetTable()
t.round = 2
t.phase = "fighting"
Writes made in the same frame are batched into a single message. Filling a table field by field costs one send, not one per field.
Capturing publishes the table but sends nothing yet. Clients get it by calling
RequestTable, or you push it at
them with ForceTable.
Careful
Give a staged table one id. Capturing the same table under a second id replaces its write hook, so patches then go out under the newer id only and anyone holding the older one stops seeing changes.
Errors with ERR_NOT_SERVER if you call it on the client.
ReleaseTable Server
carrier:ReleaseTable(id: string) -> ()
Drops this carrier's pointer to a staged table.
| Parameter | Type | What it is |
|---|---|---|
id | string | The published id to stop pointing at. |
Returns nothing.
world:ReleaseTable("WorldState")
-- A client asking for it now is refused.
Careful
This does not stop replication. Clients already holding the table keep getting
every write. All you have done is make the id unrequestable through this carrier.
Three other calls do stop a client receiving: Release on the mirror
from that client, Revoke on the server for one
player, and Destroy on the carrier for
everybody.
Errors with ERR_NOT_SERVER if you call it on the client.
ForceTable Server
carrier:ForceTable(id: string, Table: StageTable, Targets: ({Player} | Player)?) -> ()
Pushes a staged table onto clients without waiting for them to ask. If the id is not published yet, this captures it first.
| Parameter | Type | What it is |
|---|---|---|
id | string | The id to publish it under. |
Table | StageTable | The staged table to send. |
Targets | ({Player} | Player)? | Who to send it to. Leave it out for everyone. |
Returns nothing.
Players.PlayerAdded:Connect(function(player)
local profile = Pigeon.StagedTable(loadProfile(player))
world:ForceTable("Profile_" .. player.UserId, profile, player)
end)
The recipients get it cached under that id, so a later
RequestTable on their side hands it back with no round trip.
Note
Capture guards are not consulted here. They exist to vet clients asking for a table, and this is the server deciding. A handshake guard still applies, though: a player who has not passed it is recorded as holding the table but never receives the copy or the patches.
Errors with ERR_NOT_SERVER if you call it on the client.
RequestTable Client Yields
carrier:RequestTable(id: string) -> StageTable
Asks the server for a staged table and waits for the first copy. After that it is kept up to date for you, as long as the channel is open.
The first copy comes back as a reply, so this works without
Init. The patches that follow are ordinary server
sends, and those are held until the channel is open. Call Init or the
mirror never changes again.
| Parameter | Type | What it is |
|---|---|---|
id | string | The published id to ask for. |
Returns the live mirror. Ask twice and you get the same object back, with no second round trip and no yield.
local world = Pigeon.new("World")
world:Init()
local state = world:RequestTable("WorldState")
print(state:GetTable().round)
Careful
This one throws. If the server has nothing under that id, or a capture guard said
no, or the request timed out, you get
ERR_CAPTURE_DENIED | Capture of table '...' was denied. A timeout
looks the same as a refusal, so wrap it in pcall if the table may not
be there yet.
local ok, state = pcall(function()
return world:RequestTable("WorldState")
end)
if not ok then
warn("no world state yet")
end
The mirror belongs to the server. Writing to it locally changes your copy and is
never sent back. Call Release on it to stop receiving updates and to
drop it from the cache, after which a later request fetches it again.
Errors with ERR_NOT_CLIENT if you call it on the server.
Lifecycle
Destroy Shared
carrier:Destroy() -> ()
Drops the carrier. Every subscription it made is disconnected, and its listeners, responders, rooms, middleware, handshake guard and table pointers are cleared.
Returns nothing. Calling it twice does nothing the second time.
local minigame = Pigeon.new("Minigame")
-- ... the round ends ...
minigame:Destroy()
What a dropped carrier does
| Call | What you get |
|---|---|
Emit, Broadcast and friends | Nothing happens. No error. |
Call, CallTo | nil, straight away. |
Handshake | false. |
Revoke | 0, and nothing is changed. |
On, When | Nothing happens. No handler is registered, and your callback is not kept. |
Init | Nothing happens. |
| Anything on the wrong side | Still errors. The side check runs first. |
Going quiet rather than erroring is on purpose. Teardown often races with work already in flight, and throwing there would spread the problem.
Who else is affected
- If the carrier made its own transformer, which it does unless you passed one in, that transformer is destroyed too.
- If you passed a transformer in, it is left alone. It is yours.
- Destroying a transformer runs this on every carrier riding on it, so a carrier never outlives its transformer.
-
The cached carrier for this name is evicted, so a later
Pigeon.new("Minigame")builds a fresh one rather than handing back a dead object. - Staged tables survive. Only this carrier's hold on them is released, and on the server replication from them stops. The same table can be captured again by another carrier.
Note
A dropped carrier stays dropped. On and When return
without registering anything, whether or not the transformer you passed in is
still alive, so a handler added after Destroy never runs. There is no
way to bring the object back. Build a new carrier when you need the channel again.
Read more in Cleanup.
Errors and warnings
Errors from a carrier method are raised at your call site. The two constructor
errors are the exception: ERR_NO_NAME and
ERR_NO_TRANSFORMER come out of Pigeon.new, so the
traceback lands on Pigeon's own line rather than on yours. The error name is what
tells you which one it was. See Pigeon.new.
Warnings go to the output window and the message is dropped or the callback
skipped.
| Message | Kind | When |
|---|---|---|
ERR_NOT_SERVER | This function is only available on the server. |
Error |
Broadcast, BroadcastTo,
BroadcastExcept, CallTo, CreateRoom,
DestroyRoom, JoinRoom, LeaveRoom,
SendToRoom, UseHandshake,
Revoke, CaptureTable,
ReleaseTable or ForceTable called on the
client.
|
ERR_NOT_CLIENT | This function is only available on the client. |
Error |
Emit, Call, Ping,
Handshake or RequestTable called on the server.
|
ERR_NOT_APPROVED | ... has not passed the handshake, so cannot be put in room '...'. |
Error |
CreateRoom or JoinRoom named a player who has
not passed this carrier's handshake guard. Only on a carrier that has
one. Nothing was added.
|
ERR_CAPTURE_DENIED | Capture of table '...' was denied. |
Error | RequestTable was refused, found nothing, or timed out. |
ERR_NO_NAME | A carrier must be given a name. |
Error | The carrier was created with no name, or an empty string. |
ERR_NO_TRANSFORMER | options.Transformer must be a transformer. |
Error | The carrier was created with an options.Transformer that is not one. |
ERR_NO_ROOM | Room '...' does not exist. |
Warning | SendToRoom was given a room name nothing was created under. |
ERR_NO_TABLE | No staged table captured under id '...'. |
Warning | On the server, when a client requests an id that is not published. |
ERR_CALLBACK_ERROR | Error in ... callback for event '...': ... |
Warning |
A When responder, a middleware function or a handshake guard
errored. The word before callback tells you which:
responder, incoming middleware,
outgoing middleware or handshake. An
On listener is not in this list. Its errors are raised
normally instead.
|
See also
- Pigeon module for how a carrier is created and cached.
- Transformer for sharing one remote between channels.
- StagedTable for the replicated table object.
- Types for the exported Luau types.
- Carriers for the guided version of this page.