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.
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)
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.
| Parameter | Type | What it is |
|---|---|---|
event | string | The 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 as | Server responder receives | Client 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.
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)
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 sent | What runs | What comes back |
|---|---|---|
Emit, Broadcast, BroadcastTo, BroadcastExcept, SendToRoom | Every On for that event | Nothing |
Call, CallTo | The one When for that event | What 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.
| Parameter | Type | What it is |
|---|---|---|
event | string | The event to ask on the server. |
... | any | The request payload. |
Returns the server responder's return values, in order, or
nil if the call timed out or the carrier is dead.
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.
| Parameter | Type | What it is |
|---|---|---|
Target | Player | The one client to ask. |
Event | string | The event to ask on that client. |
... | any | The 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.
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()
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.
shop:When("Stats", function(player)
return 12, "gold", true
end)
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.
shop:When("Stats", function(player)
return 1, nil, 6
end)
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
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.
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)
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 happened | When 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.
local shop = Pigeon.new("Shop")
shop:On("PriceOf", function(player, itemId)
return priceOf(itemId) -- goes nowhere
end)
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.
-- 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)
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.
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:
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
- Sending and Receiving for
On, the other half of the split. - Startup and Buffering for what
Initholds back and why. - Handshakes for why a call can be silently refused.
- Carrier API for the full signatures.