Requests and Replies

Sometimes you do not want to tell the other machine something. You want to ask it something and wait for the answer. You ask with Call on the client or CallTo on the server, and the other side answers with When. Ping is a small latency check built on the same path.

A first example

The reply is whatever your responder returns. There is no reply function to call and no promise object to hold on to.

Server
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Pigeon = require(ReplicatedStorage.Pigeon)

local shop = Pigeon.new("Shop")

shop:When("Buy", function(player, itemId)
	if not canAfford(player, itemId) then
		return false, "too expensive"
	end
	giveItem(player, itemId)
	return true, itemId
end)
Client
local shop = Pigeon.new("Shop")

local ok, detail = shop:Call("Buy", "sword")

if ok then
	print("bought", detail)
else
	print("could not buy:", detail)
end

The client thread stops on the Call line and starts again when the server's responder returns. Everything after that line waits.

Careful

It has to be When. A handler registered with On is never run for a request and cannot answer one, whatever it returns. That is the whole difference between the two, and it is decided by the kind of packet, not by which handlers you happen to have.

When Shared

carrier:When(event: string, Callback: ((...any) -> ...any)?) -> ()

Sets the responder for an event. It is the only thing that answers a Call or a CallTo. Works on the server and on the client: the server answers what clients Call, and a client answers what the server asks with CallTo.

ParameterTypeWhat it is
eventstringThe event name to answer.
Callback((...any) -> ...any)?The responder. On the server it receives the asking player first. Everything it returns goes back to the caller. Leave it out, or pass nil, to clear the responder for that event.

Returns nothing. On a carrier that has been dropped, When does nothing at all: the responder is not stored and no error is raised.

One responder per event

There is a single slot, not a list. Call When again on the same event and the new callback replaces the old one. Nothing warns you, and the one you replaced is gone.

shop:When("PriceOf", firstVersion)
shop:When("PriceOf", secondVersion)

-- Only secondVersion answers. firstVersion is no longer registered.

That is on purpose. A request has one answer, so there is one place to write it. To take a responder off, pass it to Off, or call Off with no callback to clear the listeners and the responder for that event together. When with no callback does the same job on its own, and leaves the listeners alone.

shop:When("PriceOf")   -- clears the responder, keeps every On("PriceOf") listener

It runs on the packet's own thread

A responder is not spawned. It runs on the thread that is already handling the packet, which is why it can return a value at all: that thread is still there to take the value and send it back. An On listener is spawned instead, and that is exactly why its return value has nowhere to go.

Each arriving packet gets its own thread, so a responder never queues behind itself. Two players asking the same question at the same moment are answered independently.

It may yield, and the caller waits

Yielding inside a responder is allowed. task.wait, a DataStore read, a WaitForChild: all of them work. The reply goes out when the responder finally returns, and the caller sits on its Call line for the whole of that time.

Careful

The caller is not patient forever. If the responder runs past the caller's timeout, the caller has already given up and moved on with nil, and the answer that turns up later is thrown away. See Timeouts.

An error means no answer at all

A responder is pcalled, so an error inside it does not escape and does not reach the caller. Pigeon warns on the machine that ran it and sends nothing:

ERR_CALLBACK_ERROR | Error in responder callback for event 'Buy': Workspace.Server.Shop:14: attempt to index nil

The word in the message is responder, which tells it apart from the same warning raised for middleware or for a handshake guard. The caller hears nothing, so it waits out its full timeout and then gets nil, exactly as if there had been no responder at all.

That is the opposite of how a fire and forget listener behaves. An On listener is spawned and not wrapped, so its errors land in the output as ordinary Roblox errors with a traceback. Only responders and middleware are caught and turned into warnings.

The server gets the player first

On the server, Pigeon puts the asking player in front of your arguments. On the client it does not, because there is only one machine asking.

Asked asServer responder receivesClient responder receives
("Buy", "sword", 2) (player, "sword", 2) ("sword", 2)

One event name, both kinds

On and When can sit on the same event name at once, and that is the point of having two of them. The kind of packet picks the side. A fire and forget send reaches every listener and never the responder. A request reaches the responder and never the listeners.

So one name can carry both halves of a feature: listeners that watch it go past, and one responder that decides the answer.

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

-- Fire and forget. Runs for Emit, never for Call.
shop:On("Buy", function(player, itemId)
	logAttempt(player.UserId, itemId)
end)

-- The responder. Runs for Call, never for Emit.
shop:When("Buy", function(player, itemId)
	if not canAfford(player, itemId) then
		return false, "too expensive"
	end
	giveItem(player, itemId)
	return true, itemId
end)
Client
local shop = Pigeon.new("Shop")

-- Logged by the listener. Nothing comes back, and the responder is not run.
shop:Emit("Buy", "sword")

-- Answered by the responder. The listener is not run, so nothing is logged.
local ok, detail = shop:Call("Buy", "sword")
What was sentWhat runsWhat comes back
Emit, Broadcast, BroadcastTo, BroadcastExcept, SendToRoomEvery On for that eventNothing
Call, CallToThe one When for that eventWhat it returns

If you want the logging to happen for both, log it in the responder as well. The two sides never chain into each other, so a listener is not run as a side effect of a request.

Call Client Yields

carrier:Call(event: string, ...: any) -> ...any

The client asks the server something and waits. The server's responder gets the sending player first, and whatever it returns comes back to the caller. Calling it on the server errors with ERR_NOT_CLIENT.

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

Returns the server responder's return values, in order, or nil if the call timed out or the carrier is dead.

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

local price = shop:Call("PriceOf", "sword")
print(price)

The call yields, so it has to run on a thread that is allowed to yield. Do not put one where Roblox forbids yielding.

CallTo Server Yields

carrier:CallTo(Target: Player, Event: string, ...: any) -> ...any

The server asks exactly one client and waits. There is no built in way to ask everyone at once. Calling it on the client errors with ERR_NOT_SERVER. Note the capital letters on the first two parameter names, and that the player comes first.

ParameterTypeWhat it is
TargetPlayerThe one client to ask.
EventstringThe event to ask on that client.
...anyThe request payload.

Returns the client responder's return values, in order, or nil if the call timed out, the player left, the carrier is dead, or a handshake guard is installed and that player has not passed it.

Client
local ui = Pigeon.new("Ui")

-- No player argument on this side. There is only one machine asking.
ui:When("WhatResolution", function()
	local size = workspace.CurrentCamera.ViewportSize
	return size.X, size.Y
end)

ui:Init()
Server
local ui = Pigeon.new("Ui")

local function logResolution(player: Player)
	local width, height = ui:CallTo(player, "WhatResolution")
	print(player.Name, width, height)
end

Careful

CallTo is held behind the client's Init. If that client has not opened the channel yet, your request sits in the queue for them and the server thread waits anyway. Unless they open it inside your timeout, the call comes back nil. See Startup and Buffering. Client to server Call has no such wait.

A client that leaves in the middle

Nothing on the server errors when the far end walks out. Every send to a client checks first that the player is still a real Instance, is not marked as having left, and still has a parent, and the send itself is wrapped so that losing the race between the check and the send is harmless too. That covers the reply to a Call, every broadcast, and the release of a client's buffered backlog.

A CallTo waiting on someone who leaves does not sit out its timeout. It is given up as soon as the player goes, and you get nil.

The reply is whatever the responder returns

Return several values and you get several values back, in the same order.

Server
shop:When("Stats", function(player)
	return 12, "gold", true
end)
Client
local level, currency, unlocked = shop:Call("Stats")

Returning nil

nil is allowed anywhere in that list. Return 1, nil, 6 and the caller receives 1, nil, 6. The list is not cut short and the values after the nil do not shift up.

Server
shop:When("Stats", function(player)
	return 1, nil, 6
end)
Client
local a, b, c = shop:Call("Stats")

print(a, b, c)  --> 1 nil 6

Replies get the same treatment as arguments: each nil is swapped for a private marker on the way out and restored on arrival, so the count is exact. Leading, middle and trailing nils all survive, and so does a responder that returns nothing but nils.

Careful

This covers the returned values themselves, not what is inside them. A nil in a table you return is still a hole in that table. If you need a missing value inside a table, use an explicit key and leave it out.

What a round trip looks like

One Call, start to finish

shop:Call("Buy", id) outgoing middleware pick the remote
server guard check incoming middleware your When("Buy") responder
it returns true, id reply on the same remote Call returns true, id

The reply travels back on whichever remote the request arrived on, not on the one the answering side would have picked. That is why a round trip still works when the two halves were built on different transformers.

Note where the On listeners are in that picture: nowhere. They are not a step that is skipped, they are simply not on this path.

Calls always use the reliable remote

Even on a carrier made with { Unreliable = true }, requests and replies take the reliable lane. Only fire and forget sends can be lossy. A reply that could vanish would leave the caller stuck until its timeout, so Pigeon never sends one that way. See Unreliable Sending.

Timeouts

Every request gives up after 10 seconds by default and hands you nil. Set your own with the Timeout option, in seconds.

local shop = Pigeon.new("Shop", { Timeout = 3 })

Careful

Passing options to Pigeon.new builds a fresh carrier that is not cached, even if the table is empty. Ask for Pigeon.new("Shop") somewhere else in your code and you get a different object with the default timeout, and with none of your responders on it. Keep one carrier per channel per machine and pass it around. See Carriers.

To change the timeout on a carrier you already share, write the field instead. It is read fresh on every call.

local shop = Pigeon.new("Shop")
shop.Timeout = 3

The timeout is read at the moment of the call, and the same number is used by Call, CallTo, and by everything built on them: Ping, Handshake and RequestTable.

When the clock runs out the caller is handed nil and stops waiting. If the answer turns up after that it is thrown away.

Slow responders hold the caller

The thread that called Call is parked until your responder returns. If the responder yields, the caller yields with it.

Careful

A DataStore read, a WaitForChild or a task.wait(15) inside a responder does not just slow that responder down. It holds the machine on the other side of the wire, and if it runs past the timeout the caller has already given up and moved on with nil.

If the work is genuinely slow, do not answer with the result. Answer straight away with an acknowledgement and send the real thing later as a normal message, which the other side picks up with On.

Server
local data = Pigeon.new("PlayerData")

data:When("LoadProfile", function(player)
	task.spawn(function()
		local profile = loadFromDataStore(player)  -- slow
		data:BroadcastTo({ player }, "ProfileReady", profile)
	end)
	return true  -- answers immediately
end)
Client
local data = Pigeon.new("PlayerData")

data:On("ProfileReady", function(profile)
	showProfile(profile)
end)

data:Init()

if data:Call("LoadProfile") then
	showSpinner()
end

Every way you get nil back

A request never throws because of the far side. It hands you nil. Sometimes that is instant and sometimes you wait out the whole timeout first.

What happenedWhen you find out
The carrier was dropped, or its transformer was destroyed. Straight away. A dead carrier goes quiet instead of erroring.
CallTo to a player who has not passed the channel's handshake. Straight away. The server does not even send it.
CallTo and the player leaves while you are waiting. As soon as they leave.
Incoming middleware renamed the event to something with no responder. Straight away, with an empty reply. A rename is a decision, so there is nothing to wait for. See Middleware.
The responder returned nothing, or returned nil on purpose. Straight away, and that nil is a real answer.
The event has On listeners but no When. After the timeout, unless a When shows up in time. See below.
Nothing at all is registered for that event. After the timeout, unless a When shows up in time. See below.
Call from a client that has not passed the handshake. After the timeout. The server drops the packet at the door and nothing ever replies.
The responder threw an error. After the timeout. A failed responder sends no reply. The far machine warns about it.
The responder took longer than the timeout. After the timeout.

Calling one on the wrong machine is the exception. Call and Ping on the server throw ERR_NOT_CLIENT, and CallTo on the client throws ERR_NOT_SERVER. Those are your mistakes, so Pigeon tells you loudly.

Listeners are not a substitute for a responder

An event with plenty of On listeners and no When is, as far as a request is concerned, an event with nothing on it. The listeners are not run and they cannot reply. The request is held, waiting for a responder that may never arrive, and the caller times out.

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

shop:On("PriceOf", function(player, itemId)
	return priceOf(itemId)  -- goes nowhere
end)
Client
local price = shop:Call("PriceOf", "sword")  -- nil, ten seconds later

Swap that On for a When and it works. This is the single most likely thing to catch you out when moving code written against older versions of Pigeon, where one method did both jobs.

A request that beats the responder

If a request arrives for an event that has no responder yet, it is not thrown away. It is held for 10 seconds and replayed to the first When that registers for that event, so a responder set up a moment later still runs and still answers, as long as your timeout has not run out first. With the default 10 second timeout the two clocks are very close, so do not lean on it.

The hold is by kind. A held request only ever goes to a When, and registering an On for that event does not collect it.

nil is not proof of failure

A responder that returns nothing gives you nil, and so does one that returns nil on purpose. You cannot tell either apart from a timeout. When the difference matters, return an explicit flag.

Server
-- The first value says whether it worked.
shop:When("Buy", function(player, id)
	if not giveItem(player, id) then
		return false, "sold out"
	end
	return true, id
end)
Client
local ok, detail = shop:Call("Buy", "sword")

if ok == nil then
	print("the server never answered")
elseif ok then
	print("bought " .. detail)
end

Ping Client Yields

carrier:Ping() -> number

Measures a full round trip to the server. It takes no parameters. Calling it on the server errors with ERR_NOT_CLIENT.

Returns the round trip in seconds, as a number.

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

local seconds = shop:Ping()
print(string.format("%.1f ms", seconds * 1000))

There is nothing to register on the server. Every server carrier puts its own When on the reserved ping event the moment it is built, and that is what answers.

Careful

Ping reads the clock, makes a call, and reads the clock again. It does not check whether the call worked. A ping that fails comes back as roughly the timeout, so a result near 10 means the round trip never happened at all. That is what you will see on a guarded carrier before the client has passed the handshake. A ping on a dropped carrier comes back near zero instead, because the call gives up at once.

The very first ping on a fresh client can also include the wait for the pooled RemoteEvent to replicate down. Take a second reading if the first one looks wrong.

Requests do not wait for Init

A client can Emit and Call from its first frame. Replies are never queued either, because the caller is provably sitting there waiting for one. So this works with no Init anywhere:

Client
local shop = Pigeon.new("Shop")
local stock = shop:Call("GetStock")  -- fine, no Init needed

Init only opens the inbound direction: broadcasts from the server, and server side CallTo requests. If a client carrier has a When on it, that responder cannot be reached until Init has run. Read Startup and Buffering for that half.

Note

Event names starting with __pigeon_ are reserved. Ping, handshakes and staged table requests run on them as responders, so putting your own When on one replaces Pigeon's and breaks it. Do not name your own events with that prefix.

What to read next