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 for On and When alike.
  • 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 nil faithfully. Emit("Hit", 1, nil, 6) reaches the server as 1, nil, 6. That holds in any position, in every direction, and for the values a responder returns. A nil inside 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 with ERR_NOT_CLIENT. The error points at your call site, not at Pigeon. Two methods do not follow that rule. Approved never checks which machine it is on, and Init is 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, Call returns nil. 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

MethodSideWhat it does
OnSharedAdds a listener for fire and forget traffic. As many per event as you like, and they cannot answer anything.
WhenSharedSets the one responder that answers Call and CallTo for an event.
OffSharedTakes a listener or the responder off an event, or clears both.
EmitClientFires an event at the server and does not wait.
BroadcastServerFires an event at every player.
BroadcastToServerFires an event at a list of players.
BroadcastExceptServerFires an event at everyone but the players you name.
CallClientAsks the server something and waits for the answer.
CallToServerAsks one client something and waits for the answer.
PingClientMeasures the round trip to the server in seconds.
InitClientOpens the channel so the server may send to you.
CreateRoomServerMakes a room and puts players in it.
DestroyRoomServerDeletes a room.
JoinRoomServerAdds players to a room, making it if needed.
LeaveRoomServerTakes players out of a room.
SendToRoomServerFires an event at every member of a room.
UseHandshakeServerCloses the channel behind a guard you write.
HandshakeClientPresents credentials to the guard and waits for a ruling.
ApprovedServerNarrows a player list to those the guard let in.
RevokeServerTakes admission back off players, empties them out of rooms and releases their staged tables.
UseIncomingSharedAdds middleware to the inbound path.
UseOutgoingSharedAdds middleware to the outbound path.
CaptureTableServerPublishes a staged table under an id.
ReleaseTableServerDrops this carrier's pointer to a staged table.
ForceTableServerPushes a staged table onto clients without being asked.
RequestTableClientAsks for a staged table and waits for the first copy.
DestroySharedDrops 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 sentWhat runsAnswer
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.

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

ParameterTypeWhat it is
eventstringThe event name to listen for.
Callback(...any) -> ()Runs for each matching message. Anything it returns is thrown away.

Returns nothing.

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

-- The player who sent it comes first.
shop:On("Browse", function(player, category)
	recordInterest(player, category)
end)
Client
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.

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

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

ParameterTypeWhat it is
eventstringThe event name to stop handling.
Callback((...any) -> ...any)?The exact function to remove. Omit to remove everything on the event.
What you passWhat 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.

ParameterTypeWhat it is
eventstringThe event to raise on the server.
...anyThe payload. Anything a RemoteEvent can carry.

Returns nothing.

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

ParameterTypeWhat it is
eventstringThe event to raise on each client.
...anyThe payload.

Returns nothing.

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

ParameterTypeWhat it is
Targets{Player}The recipients. Must be a list.
eventstringThe event to raise on each client.
...anyThe payload.

Returns nothing. If nobody in the list is allowed to receive, nothing is sent at all.

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

ParameterTypeWhat it is
Skip{Player}Who to leave out. A single Player works too.
eventstringThe event to raise on each remaining client.
...anyThe payload.

Returns nothing.

Server
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("Buy", id) When("Buy") runs its return value goes back Call returns

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.

ParameterTypeWhat it is
eventstringThe event to raise on the server.
...anyThe request payload.

Returns the server responder's return values, or a single nil if the call timed out or the carrier has been dropped.

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

ParameterTypeWhat it is
TargetPlayerThe one client to ask.
EventstringThe event to raise on that client.
...anyThe 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.

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

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

Client
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()
ThingWhat actually happens
Called twiceThe second call does nothing.
Called on the serverDoes nothing and returns the carrier, so shared code can call it either way.
Called on a dropped carrierDoes nothing and returns the carrier.
Never calledYou 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.

ParameterTypeWhat it is
RoomIDstringThe room name.
Members{Player}The starting members. A list, or nil for an empty room.

Returns nothing.

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

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

ParameterTypeWhat it is
RoomIDstringThe room name.

Returns nothing. Destroying a room that does not exist does nothing.

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

ParameterTypeWhat it is
RoomIDstringThe room name.
Members{Player} | PlayerOne player or a list of them.

Returns nothing. A player already in the room is not added again.

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

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

ParameterTypeWhat it is
RoomIDstringThe room name.
Members{Player} | PlayerOne 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.

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

ParameterTypeWhat it is
RoomstringThe room name.
eventstringThe event to raise on each member.
...anyThe payload.

Returns nothing.

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

ParameterTypeWhat it is
Callback(player: Player, ...any) -> booleanRuns on every handshake attempt.

Returns nothing.

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

DirectionWhat happens to a client who has not passed
Client to serverEverything 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 clientThey 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 Revoke clears 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_ERROR and 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 returned false.
  • A player must be through the guard before you can put them in a room. CreateRoom and JoinRoom raise ERR_NOT_APPROVED otherwise.

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.

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

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

ParameterTypeWhat it is
Targets{Player}?The players to filter. Leave it out for everyone in the game.

Returns a list of players.

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

ParameterTypeWhat it is
Targets{Player} | PlayerOne 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.

Server
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
Server
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 playerWhat 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}?)
ArgumentWhat it is
directionThe string "incoming" or "outgoing".
eventThe event name as it stands after the entries before you.
argsA list of the arguments. It is already a copy, so editing it in place is safe.
playerThe 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.

WhereWhat player is
Incoming, on the serverThe player who sent it.
Incoming, on the clientnil.
Outgoing, from CallToThe player being asked.
Outgoing, from Revoke or a refused handshakeThe player losing access, on the reserved __pigeon_table_revoke message it sends them.
Outgoing, from anything elsenil, 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.

ParameterTypeWhat it is
CallbackMiddlewareFunctionRuns for every message arriving on this carrier.

Returns nothing.

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

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

ParameterTypeWhat it is
idstringThe id clients will request.
TableStageTableThe staged table to publish.

Returns nothing.

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

ParameterTypeWhat it is
idstringThe published id to stop pointing at.

Returns nothing.

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

ParameterTypeWhat it is
idstringThe id to publish it under.
TableStageTableThe staged table to send.
Targets({Player} | Player)?Who to send it to. Leave it out for everyone.

Returns nothing.

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

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

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

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

Server
local minigame = Pigeon.new("Minigame")

-- ... the round ends ...

minigame:Destroy()

What a dropped carrier does

CallWhat you get
Emit, Broadcast and friendsNothing happens. No error.
Call, CallTonil, straight away.
Handshakefalse.
Revoke0, and nothing is changed.
On, WhenNothing happens. No handler is registered, and your callback is not kept.
InitNothing happens.
Anything on the wrong sideStill 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.

MessageKindWhen
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