Getting Started
We are going to build a channel called Points. The server awards
points, the client hears about it, and the client can ask for its total. Seven
small steps, all of it runnable.
This assumes Pigeon is already in ReplicatedStorage. If it is not,
do Installation first.
You need two scripts:
| Script | Kind | Where it goes |
|---|---|---|
Points | Script | ServerScriptService |
PointsClient | LocalScript | StarterPlayer › StarterPlayerScripts |
Step 1: Require Pigeon on both sides
Same module, both machines.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Pigeon = require(ReplicatedStorage.Pigeon)
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Pigeon = require(ReplicatedStorage:WaitForChild("Pigeon"))
The client waits, because a LocalScript can start running before the Pigeon folder has replicated to it.
Step 2: Make the carrier, with the same name
A carrier is a named channel. The name is the only thing the two machines have to agree on. Get it right and they are talking. Get it wrong and both sides work perfectly, in silence, forever.
local points = Pigeon.new("Points")
local points = Pigeon.new("Points")
Note
Called with just a name, Pigeon.new is a lookup. The carrier is
built once and every later call anywhere in your code hands back the same
object, so you do not have to pass it around. Pass an options table and you get
a fresh uncached carrier instead, even if the table is empty.
Step 3: Add a listener on the client
On takes an event name and a function. The event name is yours to
pick. It lives inside this channel, so "Changed" here has nothing to
do with "Changed" on any other channel.
local points = Pigeon.new("Points")
points:On("Changed", function(total)
print("you now have", total, "points")
end)
The handler gets exactly what the server sent. No player argument, because on the client there is only ever one sender. It will not fire yet. Step 4 opens the channel.
Step 4: Call Init
Init tells the server this client is ready to receive on this
channel. Call it once, after your handlers are registered.
local points = Pigeon.new("Points")
points:On("Changed", function(total)
print("you now have", total, "points")
end)
-- Handlers are in place. Open the channel.
points:Init()
Until Init lands, the server holds everything it sends you on this
channel: up to 64 packets, for 30 seconds. Then the backlog arrives in the order
it was held. That is why registering handlers first and calling
Init last is the right order, and why you miss nothing by doing it.
Note
Init only opens the inbound direction, and only for this channel.
Sending from the client never needs it, and other channels stay queued until
they call their own. On the server Init does nothing at all, so
shared code can call it without checking which machine it is on.
Step 5: Broadcast from the server
Now the server side. It keeps a total per player and sends the new total to that player whenever it changes.
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Pigeon = require(ReplicatedStorage.Pigeon)
local points = Pigeon.new("Points")
local totals: { [Player]: number } = {}
local function award(player: Player, amount: number)
totals[player] = (totals[player] or 0) + amount
points:BroadcastTo({ player }, "Changed", totals[player])
end
Players.PlayerAdded:Connect(function(player)
award(player, 10) -- welcome bonus
end)
Players.PlayerRemoving:Connect(function(player)
totals[player] = nil
end)
Press play. The welcome bonus fires on PlayerAdded, which happens
well before your LocalScript has called Init, and you still see
you now have 10 points in the output. That is the buffer at work.
There are three ways to send from the server. Pick by who you mean.
| Method | Who gets it |
|---|---|
points:BroadcastTo({ player }, "Changed", 10) | The players you list. |
points:Broadcast("Changed", 10) | Everyone. |
points:BroadcastExcept({ player }, "Changed", 10) | Everyone but the players you list. |
Step 6: Add a Call that returns a value
Emit and Broadcast are fire and forget. When the client
needs an answer it uses Call, and the server answers it with
When, whose return value is the reply. There is no separate object and
no second event to wire up.
When is the other half of On. A listener registered with
On is never run for a request and cannot answer one, whatever it
returns, so anything a client Calls needs a When on the
server. There is one responder per event name, and registering a second replaces it.
points:When("GetTotal", function(player)
return totals[player] or 0
end)
local total = points:Call("GetTotal")
print("starting total:", total)
Call yields the thread it is on until the server's responder returns.
Whatever that responder returns is what you get back, and you can return more than
one value.
points:When("Spend", function(player, amount)
local have = totals[player] or 0
if have < amount then
return false, "not enough points"
end
totals[player] = have - amount
points:BroadcastTo({ player }, "Changed", totals[player])
return true
end)
local ok, reason = points:Call("Spend", 25)
if not ok then
print("cannot spend:", reason)
end
Careful
A Call gives up after 10 seconds by default and returns
nil. It returns nil the same way if the carrier has
been dropped, so check the result before you use it. To change the wait, pass
Pigeon.new("Points", { Timeout = 3 }). Remember that any options
table gives you an uncached carrier, so build it once and share that one.
Step 7: Add a second channel
Channels do not leak into each other. Here is a second one that uses the very same event name.
local shop = Pigeon.new("Shop")
-- Same event name as the Points channel. Different channel, so no overlap.
shop:Broadcast("Changed", { sword = 3, shield = 1 })
local shop = Pigeon.new("Shop")
shop:On("Changed", function(stock)
print("shop stock changed, swords left:", stock.sword)
end)
shop:Init()
The Points handler never fires for shop stock and the
Shop handler never fires for points. The channel name travels with
every message, joined to the event name, so what actually goes on the wire for
these two is Points\0Changed and Shop\0Changed. They are
different names, so they never meet.
Two channels, one event name
Each channel also has its own Init and its own queue. Calling
points:Init() releases points traffic only. Shop traffic keeps
waiting until shop:Init().
The finished scripts
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Pigeon = require(ReplicatedStorage.Pigeon)
local points = Pigeon.new("Points")
local totals: { [Player]: number } = {}
local function award(player: Player, amount: number)
totals[player] = (totals[player] or 0) + amount
points:BroadcastTo({ player }, "Changed", totals[player])
end
points:When("GetTotal", function(player)
return totals[player] or 0
end)
points:When("Spend", function(player, amount)
local have = totals[player] or 0
if have < amount then
return false, "not enough points"
end
totals[player] = have - amount
points:BroadcastTo({ player }, "Changed", totals[player])
return true
end)
Players.PlayerAdded:Connect(function(player)
award(player, 10)
end)
Players.PlayerRemoving:Connect(function(player)
totals[player] = nil
end)
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Pigeon = require(ReplicatedStorage:WaitForChild("Pigeon"))
local points = Pigeon.new("Points")
points:On("Changed", function(total)
print("you now have", total, "points")
end)
points:Init()
print("starting total:", points:Call("GetTotal"))
local ok, reason = points:Call("Spend", 25)
if not ok then
print("cannot spend:", reason)
end
Common mistakes
Forgetting Init
The most common one by far. Your listeners are correct, the server is sending,
and nothing arrives. Without Init the server keeps holding that
channel's messages, and after 30 seconds it starts dropping them.
-- Nothing will ever reach this handler.
local points = Pigeon.new("Points")
points:On("Changed", print)
-- Add this.
points:Init()
Sending still works without it. A client can Emit and
Call from the very first frame. It is only the inbound direction
that waits.
A different name on each side
The name is the channel, and it is compared exactly. Case counts. Spaces count. Neither side errors, so this looks like a bug in your handler.
local points = Pigeon.new("Points")
local points = Pigeon.new("points") -- lowercase p, a completely separate channel
Put the name in one shared module and require it from both sides if you keep tripping on this.
Expecting the player argument on the client
Server handlers get the sending player first. Client handlers do not. That holds for
On listeners and When responders alike. Copying a handler
from one side to the other and forgetting to change the arguments shifts everything
by one.
points:When("Spend", function(player, amount)
-- ^ the sender
end)
-- Wrong: total lands in `player`, and `total` is nil.
points:On("Changed", function(player, total) end)
-- Right.
points:On("Changed", function(total) end)
Using a reserved event name
Event names starting with __pigeon_ belong to Pigeon. It runs its
own ping, handshake and staged table traffic over those names on your channel.
-- Do not do this.
points:When("__pigeon_ping", function() end)
Warning
Nothing stops you registering one, which is what makes it dangerous. There is
only one responder per event, so a When of yours on
__pigeon_ping, __pigeon_handshake or
__pigeon_table_request replaces Pigeon's own and breaks pings,
handshakes or staged tables on that channel. An On on a reserved
name is no better: it fires on Pigeon's own protocol packets. Stay off the
__pigeon_ prefix and there is nothing to think about.
Next
- Carriers for everything a carrier can do.
- Sending and Receiving for the send methods in full.
- Requests and Replies for
When,Call,CallToand timeouts. - Startup and Buffering for what
Initis really doing.