Sending and Receiving
This is the fire and forget half of Pigeon. You send a message and carry on. Nothing
waits and nothing comes back. On is the listening side of it. If you want
an answer, you want When, over in
Requests and Replies.
The shape of it
On listens on both sides. One method sends from the client. Three send
from the server, and they differ only in who ends up on the recipient list. Rooms add
a fourth server send, SendToRoom, which is covered in
Rooms.
Those five sends are the whole of the traffic On hears. Nothing else
reaches it. A Call or a CallTo goes to the event's one
When responder instead, and never to a listener, whatever you have
registered.
Who calls what
Listening
On Shared
carrier:On(event: string, Callback: (...any) -> ()) -> ()
Adds a fire and forget listener for an event. Works on the server and on the client.
The channel itself is wired up when the carrier is built, not by On: a
carrier subscribes to its whole channel, so a packet for an event you never bound
still reaches it and still runs your incoming
middleware. What On adds is the listener,
so until you call it nothing runs for that event.
| Parameter | Type | What it is |
|---|---|---|
event | string | The event name to listen for. |
Callback | (...any) -> () | The listener. On the server it receives the sending player first. Anything it returns is discarded. |
Returns nothing. Keep the function in a local if you want to remove
it later with Off. On a carrier that has been
dropped, On does nothing at all: the listener
is not stored and no error is raised.
local shop = Pigeon.new("Shop")
shop:On("Buy", function(player, itemId, quantity)
print(player.Name, "wants", quantity, "of", itemId)
end)
local shop = Pigeon.new("Shop")
shop:On("StockChanged", function(stock)
updateShopUi(stock)
end)
shop:Init()
The server gets the player first
On the server, Pigeon puts the sending player in front of your arguments. On the client it does not, because there is only one sender and it is always the server.
| Sent as | Server handler receives | Client handler receives |
|---|---|---|
("Buy", "sword", 2) |
(player, "sword", 2) |
("sword", 2) |
Many listeners for one event
On adds a listener, it does not replace the last one. Call it five times
for the same event and all five run. Adding the same function twice runs it twice,
there is no check for that.
shop:On("Sold", playSound)
shop:On("Sold", updateUi)
shop:On("Sold", logIt)
-- One message, three listeners, all of them run.
Each one is started with task.spawn, so it gets a thread of its own. A
listener that yields does not hold up the next one, and none of them hold up the
packet. The flip side is that the order they finish in is not fixed, so do not write
two listeners where one depends on the other having already run.
A listener that errors
Nothing catches it. A listener is spawned rather than wrapped, so an error inside one is an ordinary Roblox error with a full traceback in the output. The other listeners on that event are on their own threads and carry on regardless.
There is no ERR_CALLBACK_ERROR warning for a fire and forget listener.
That message belongs to responders and to
middleware, which are the two things Pigeon does pcall.
Returning a value goes nowhere
An On listener cannot answer anything. It is spawned, so by the time it
returns there is nothing left holding on to the result and Pigeon drops it. Writing
return true at the end of one is harmless and pointless.
Careful
To answer a Call or a CallTo you need When,
not On. A request for an event that has listeners but no responder is
not handed to those listeners at all. It is held for 10 seconds in case a
When turns up, and if none does the caller waits out its own timeout
and gets nil. See Requests and Replies.
Messages that arrive before you listen
A packet that turns up when nothing is listening for it is held rather than thrown away, and replayed to the first matching handler that registers. Pigeon keeps up to 64 of them per event for 10 seconds. That is a safety net for the small gap during startup, not a mailbox. For the real answer see Startup and Buffering.
Matching means the same kind, not just the same name. A held fire and forget message
only ever replays to an On, and a held request only ever replays to a
When. Registering the wrong one leaves the packet sitting where it was
until it goes stale.
Off Shared
carrier:Off(event: string, Callback: ((...any) -> ...any)?) -> ()
Takes handlers off an event. It is the one method that touches both kinds. With a
callback it removes that function from the On list, and clears the
responder slot too if that same function is the event's When. With no
callback it clears every listener and the responder in one go.
| Parameter | Type | What it is |
|---|---|---|
event | string | The event name to unbind. |
Callback | ((...any) -> ...any)? | The one handler to drop, listener or responder. Leave it out to drop them all. |
Returns nothing.
local function onSold(itemId)
print("sold", itemId)
end
shop:On("Sold", onSold)
shop:Off("Sold", onSold) -- drops just this listener
shop:Off("Sold") -- drops every listener and the responder
To remove one handler you need the same function value you passed in, so keep it in
a local. An inline function() end written straight into
On can only be removed by dropping the whole event.
Pigeon unhooks the binding underneath only once both sides are empty. An event with
no listeners left but a responder still on it stays bound, and so does the other way
round. Calling Off for an event with nothing on it does nothing.
Note
Turning a listener off and back on again quickly can replay what you missed. Fire
and forget messages that arrive while nothing is listening are held for 10
seconds, so a later On for that event receives them. A dropped
carrier cannot bind again, so nothing is replayed to it.
Sending from the client
Emit Client
carrier:Emit(event: string, ...: any) -> ()
Sends to the server and returns straight away. This is the only fire and forget
method on the client. Calling it on the server errors with
ERR_NOT_CLIENT.
| Parameter | Type | What it is |
|---|---|---|
event | string | The event to raise on the server. |
... | any | The payload. |
Returns nothing, and never yields.
local shop = Pigeon.new("Shop")
shop:Emit("Buy", "sword", 2)
local shop = Pigeon.new("Shop")
shop:On("Buy", function(player, itemId, quantity)
grantItem(player, itemId, quantity)
end)
Emit never needs Init. A client can send from its first
frame. It is only the inbound direction that waits.
Sending from the server
All three of these end up in the same place. Broadcast and
BroadcastExcept build a player list and hand it to
BroadcastTo. Each errors with ERR_NOT_SERVER on the client.
Broadcast Server
carrier:Broadcast(event: string, ...: any) -> ()
Sends to every player in the server.
| 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())
The player list is read when you call it, so someone who joins a moment later does not get this message.
BroadcastTo Server
carrier:BroadcastTo(Targets: {Player}, event: string, ...: any) -> ()
Sends to the players you list. Note the order of the arguments: the list comes first, then the event name.
| Parameter | Type | What it is |
|---|---|---|
Targets | {Player} | The recipients. |
event | string | The event to raise on each client. |
... | any | The payload. |
Returns nothing.
local party = Pigeon.new("Party")
party:BroadcastTo({ alice, bob }, "Invite", "dungeon-3")
-- One player still goes in a table.
party:BroadcastTo({ alice }, "Invite", "dungeon-3")
Careful
BroadcastTo wants a table of players. Passing a bare
Player errors. BroadcastExcept, JoinRoom,
LeaveRoom and ForceTable normalise a single player into
a list first, so it is easy to assume this one does too.
BroadcastExcept Server
carrier:BroadcastExcept(Skip: {Player}, event: string, ...: any) -> ()
Sends to everyone in the server apart from the players you name. This is what you want when a player's own action should be shown to everyone else.
| Parameter | Type | What it is |
|---|---|---|
Skip | {Player} | The players to leave out. A bare Player works too: it goes through the same normaliser as JoinRoom, even though the type says a list. |
event | string | The event to raise on each remaining client. |
... | any | The payload. |
Returns nothing.
local chat = Pigeon.new("Chat")
chat:On("Say", function(player, text)
-- Everyone else sees it. The sender already drew it locally.
chat:BroadcastExcept(player, "Said", player.Name, text)
end)
A list works too, and so does an empty list, which then behaves like a plain Broadcast.
chat:BroadcastExcept({ alice, bob }, "Said", "system", "server restarting")
Arguments
Everything after the event name is your payload. Pigeon packs it together with the number of values you passed, and unpacks that same count on the other side, so your handler is called with the same arguments in the same order.
local shop = Pigeon.new("Shop")
shop:Broadcast("Sold", "sword", 2, { rarity = "rare" })
local shop = Pigeon.new("Shop")
shop:On("Sold", function(itemId, quantity, meta)
print(itemId, quantity, meta.rarity)
end)
shop:Init()
The values themselves have to be things a RemoteEvent can carry, so no functions and no threads. Middleware sits in this path on both machines and can rewrite the event name and the arguments before anything else sees them. See Middleware.
Sending nil
A nil argument is carried through exactly as you sent it. It does not
cut the list short and the values after it do not shift up. Pigeon swaps every
nil for a private marker before the send and turns it back into
nil on arrival, so the list that crosses the wire has no gaps and its
length is exact.
local echo = Pigeon.new("Echo")
echo:Emit("Values", 1, nil, 6)
local echo = Pigeon.new("Echo")
echo:On("Values", function(player, a, b, c)
print(a, b, c) --> 1 nil 6
end)
The position does not matter. A leading nil, a middle one, a trailing
one and a list that is nothing but nils all arrive with the same count you sent. It
works the same in every direction: Emit, Broadcast,
BroadcastTo, BroadcastExcept, SendToRoom,
Call, CallTo, and the values a responder
returns as a reply.
Careful
This covers the arguments themselves, not what is inside them. A
nil in a table you pass is still a hole in that table, and Roblox
handles it the usual way. If you need a missing value inside a table, use an
explicit key and leave it out.
The marker is a private string that contains NUL bytes, so no ordinary string
collides with it. If a player sends that exact string, you read it back as
nil.
Reserved event names
Pigeon runs its own protocol over the same channel you do. Those events all start
with __pigeon_, and they follow the same rule as yours. The three that
need an answer are registered with When. The four that do not use
On.
| Name | Registered with | Used for |
|---|---|---|
__pigeon_ping | When | Ping |
__pigeon_handshake | When | Handshakes |
__pigeon_table_request | When | Staged Tables |
__pigeon_table_snapshot | On | |
__pigeon_table_patch | On | |
__pigeon_table_release | On | |
__pigeon_table_revoke | On |
Warning
Do not name your own events with the __pigeon_ prefix, and do not
call Off on one. Sending on them or unhooking them breaks pings,
handshakes and staged tables on that carrier.
Which method do I want
| What you want to do | Method | Side |
|---|---|---|
| Hear a fire and forget event | On | Both |
| Answer a request | When, see Requests | Both |
| Stop hearing an event | Off | Both |
| Tell the server something | Emit | Client |
| Tell every player something | Broadcast | Server |
| Tell some players something | BroadcastTo | Server |
| Tell one player something | BroadcastTo({ player }, ...) | Server |
| Tell everyone but one player | BroadcastExcept | Server |
| Tell a group you set up earlier | SendToRoom, see Rooms | Server |
| Ask the server and wait | Call, see Requests | Client |
| Ask one client and wait | CallTo, see Requests | Server |
When a send quietly does nothing
None of these error. That is deliberate, but it does mean a message can vanish without a warning, so it is worth knowing the four cases.
An empty recipient list
BroadcastTo with no one to send to returns immediately. Your outgoing
middleware does not even run, because the list is checked first.
party:BroadcastTo({}, "Invite", "dungeon-3") -- nothing happens
This bites when the list is built from a filter. An empty result and a full one look the same from the call site.
A guarded channel filters the list
If the carrier has a handshake guard, every send runs the recipient list past it
first and drops anyone who has not been let in. A global Broadcast on a
guarded carrier reaches only approved players, and if nobody has passed yet, it
reaches nobody.
local mods = Pigeon.new("Moderation")
mods:UseHandshake(function(player, token)
return isModerator(player, token)
end)
-- Only the moderators who passed the guard hear this.
mods:Broadcast("Alert", "someone is spawn camping")
The same door works inbound. An Emit from a client that has not passed
the guard is dropped before it reaches any handler. See
Handshakes.
The client has not called Init
The server cannot push to a channel on a client until that client calls
Init on it. Until then the messages queue up, up to 64 per channel for
30 seconds, and arrive in order once the client is ready. Sending from the client
never needs Init. See Startup and Buffering.
The carrier is dropped
A carrier that has been destroyed, or whose transformer has been destroyed, goes quiet instead of erroring. Sends return without doing anything. See Cleanup.
Next
- Requests and Replies when you need an answer back.
- Rooms to send to a named group of players.
- Carrier API for the exact signatures.