Startup and Buffering

A game starts sending before every client is ready to listen. Pigeon holds those messages instead of losing them, and lets them go when the client says it is ready. That is all Init does.

The problem

Server scripts and local scripts do not start at the same moment. The server can load, decide the shop stock changed and broadcast it, all before a joining client's LocalScript has run a single line.

With a plain RemoteEvent that message is gone. It fired, nothing was connected, and nobody will ever hear about it. You end up writing a "tell me the current state" round trip for every system just to paper over the first second of the session.

Init Client

carrier:Init() -> PigeonCarrier

The client registers its handlers first, then calls Init once. Init tells the server "I am ready on this channel", and the server sends everything it has been holding for that client on that channel, oldest first. It takes no parameters and it never yields.

Returns the same carrier, so you can chain from it.

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

shop:On("StockChanged", updateShopUi)
shop:On("Sold", playSoldSound)

-- Handlers are in place. Let the backlog through.
shop:Init()

A message that arrives too early

shop:Broadcast("StockChanged", stock) is this client ready on "Shop"?
no, hold it in the "Shop" queue
shop:On(...) then shop:Init() server releases the queue, oldest first your handlers run

Once a client is ready on a channel it stays ready for the rest of its session. Later broadcasts go straight out with no queue involved.

The rules of Init

RuleWhat it means for you
Client only. On the server it does nothing at all. It does not error, so shared code can call it without checking which machine it is on.
It returns the carrier. You can chain it, or ignore the return value.
Calling it twice is safe. The second call does nothing. There is no second flush and no warning.
It does not yield. It returns immediately, even on the first frame, before the remotes have replicated in. If there is no remote to send on yet the readiness packet is held and goes out ahead of everything else the carrier queued behind it. See The Ref Pool.
It is per channel. Readying "Shop" releases the "Shop" backlog and nothing else.
It only opens the inbound direction. The client can Emit and Call from its first frame with no Init at all.
A dropped carrier ignores it. No error. See Cleanup.

Chaining works because Init hands the carrier back:

Client
local shop = Pigeon.new("Shop")
shop:On("StockChanged", updateShopUi)

local same = shop:Init()
print(same == shop) --> true

Readiness is per channel

The queue is kept per client, per channel, and a channel is just a carrier's name. Bringing one channel up says nothing about any other.

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

shop:On("StockChanged", updateShopUi)
shop:Init()          -- Shop's backlog arrives now

-- Quests is still queued on the server. Nothing has been lost.
task.wait(5)

quests:On("Updated", updateQuestUi)
quests:Init()        -- Quests' backlog arrives now

This is why a system that loads late does not lose its opening messages just because another system was quicker.

Note

Readiness is keyed by the channel name, not by the carrier object. If you build two carriers with the same name on one client, calling Init on either one opens that name for both of them.

What the queue will and will not hold

LimitBehaviour
64 messages Per client, per channel. When a 65th arrives the oldest one is dropped to make room.
30 seconds A held message older than this is swept away, whether the queue is full or not.
The player leaves Everything held for them is dropped at once, and nothing can queue for them again.
Unreliable sends Never queued. A message that was allowed to go missing in transit has no business turning up a minute late, so it is simply dropped if the client is not ready. See Unreliable Sending.
Replies to a request Never queued. The far side is already yielded on the answer, so holding it would strand that thread until its timeout.
Server to client requests Queued like any other message. A CallTo to a client that has not called Init waits in the queue, and the server thread waits with it. Unless that client opens the channel inside the call's timeout, the call comes back nil. See Requests and Replies.

Careful

A client that never calls Init hears nothing from the server on that channel. Its own Emit and Call keep working perfectly, which makes this a quiet bug: half the channel works. If a broadcast is not showing up, check that Init was called on that exact channel name.

Orphan replay: the last few frames

Init covers the gap before the client is ready. There is a second, smaller gap it cannot cover: a message that arrives on a ready channel a moment before the handler for that particular event exists.

Pigeon holds those too. A message with no listener for its event is kept for 10 seconds and replayed to the first listener that registers for it.

A message that arrives before its handler

"Sold" arrives nobody is listening for "Sold" held, up to 10 seconds
shop:On("Sold", fn) held messages replay into fn, in order

Worth knowing about it:

  • It works on both machines. A client Emit that beats the server's On is held the same way.
  • It is per channel and per event name, and holds up to 64 of them. The oldest goes first when that fills.
  • The held messages are replayed once, to the first listener only. A second listener registered afterwards does not see them.
  • 10 seconds, then they are swept.

Treat this as a safety net, not a plan. It exists because the moment a client declares itself ready the server starts sending, and that can land a frame or two before your game code has finished wiring up.

The rule

Register every On you care about, then call Init once. That is the whole thing.

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

local shop = Pigeon.new("Shop")

-- 1. every handler first
shop:On("StockChanged", updateShopUi)
shop:On("Sold", playSoldSound)
shop:On("Restocked", flashRestockBadge)

-- 2. then open the channel, once
shop:Init()

-- 3. now use it normally
print(shop:Call("Buy", "sword"))

The server has nothing to do. It never calls Init, and it does not need to know which clients are ready. It just sends, and Pigeon decides whether that goes out now or waits.

What to read next