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

client Emit server On
server Broadcast client On

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.

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

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

shop:On("Buy", function(player, itemId, quantity)
	print(player.Name, "wants", quantity, "of", itemId)
end)
Client
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 asServer handler receivesClient 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.

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

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

Returns nothing, and never yields.

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

shop:Emit("Buy", "sword", 2)
Server
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.

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

Returns nothing.

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

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

Returns nothing.

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

ParameterTypeWhat 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.
eventstringThe event to raise on each remaining client.
...anyThe payload.

Returns nothing.

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

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

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

shop:Broadcast("Sold", "sword", 2, { rarity = "rare" })
Client
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.

Client
local echo = Pigeon.new("Echo")

echo:Emit("Values", 1, nil, 6)
Server
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.

NameRegistered withUsed for
__pigeon_pingWhenPing
__pigeon_handshakeWhenHandshakes
__pigeon_table_requestWhenStaged Tables
__pigeon_table_snapshotOn
__pigeon_table_patchOn
__pigeon_table_releaseOn
__pigeon_table_revokeOn

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 doMethodSide
Hear a fire and forget eventOnBoth
Answer a requestWhen, see RequestsBoth
Stop hearing an eventOffBoth
Tell the server somethingEmitClient
Tell every player somethingBroadcastServer
Tell some players somethingBroadcastToServer
Tell one player somethingBroadcastTo({ player }, ...)Server
Tell everyone but one playerBroadcastExceptServer
Tell a group you set up earlierSendToRoom, see RoomsServer
Ask the server and waitCall, see RequestsClient
Ask one client and waitCallTo, see RequestsServer

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.

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

Server
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