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:

ScriptKindWhere it goes
PointsScriptServerScriptService
PointsClientLocalScriptStarterPlayer › StarterPlayerScripts

Step 1: Require Pigeon on both sides

Same module, both machines.

Server
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Pigeon = require(ReplicatedStorage.Pigeon)
Client
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.

Server
local points = Pigeon.new("Points")
Client
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.

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

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

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

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

Server
points:When("GetTotal", function(player)
	return totals[player] or 0
end)
Client
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.

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

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

-- Same event name as the Points channel. Different channel, so no overlap.
shop:Broadcast("Changed", { sword = 3, shield = 1 })
Client
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

points:Broadcast("Changed", 10) Points\0Changed points:On("Changed")
shop:Broadcast("Changed", stock) Shop\0Changed shop:On("Changed")

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

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

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

Server
local points = Pigeon.new("Points")
Client
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.

Server
points:When("Spend", function(player, amount)
	--                      ^ the sender
end)
Client
-- 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