Unreliable Sending
A carrier can send on a cheaper lane that does not promise delivery or order. It is for things you send constantly and can afford to lose, like positions and visual effects.
Turning it on
Pass one option when you build the carrier.
local positions = Pigeon.new("Positions", { Unreliable = true })
That is the whole switch. Every fire and forget send on this carrier now takes the unreliable lane. Fire and forget means you send and move on, with no reply and no confirmation.
carrier.Unreliable Shared
carrier.Unreliable: boolean
The option is stored on the carrier as a plain field. It is true only if
you passed Unreliable = true, and false otherwise, including
when you pass no options at all.
| Value | What sends do |
|---|---|
false | Everything takes the reliable lane. This is the default. |
true | Fire and forget sends take the unreliable lane. Requests and replies still take the reliable one. |
local reliable = Pigeon.new("Chat")
print(reliable.Unreliable) --> false
local positions = Pigeon.new("Positions", { Unreliable = true })
print(positions.Unreliable) --> true
Careful
Passing any options table makes a fresh carrier that Pigeon does not cache.
Calling Pigeon.new("Positions") somewhere else gives you a
different, reliable carrier on the same channel name. The two share the channel,
so a packet goes to every carrier that bound its event with On or
When, and a handler registered on both fires twice. Hold on to the one you built and pass it
around. See Carriers.
What unreliable actually means
Underneath, Pigeon keeps two remotes for every bucket in its pool: a normal
RemoteEvent and an UnreliableRemoteEvent twin. The
unreliable one is cheaper to send on, and Roblox gives you nothing in return.
| Reliable | Unreliable | |
|---|---|---|
| Arrives | Always | Usually. Some are lost. |
| Order | Same order you sent them | Any order |
| Cost | Higher | Lower |
| Held for a client that is not ready | Yes, up to 64 for 30 seconds | No, dropped |
So an unreliable message can vanish, and two sent a frame apart can arrive the wrong way round. Your handler has to be fine with both.
Only fire and forget uses the lane
Requests never go unreliable, no matter what the carrier says. A request is a question that yields until it gets an answer, and dropping either half would leave a thread waiting for nothing.
| Method | Lane on an unreliable carrier |
|---|---|
Emit | Unreliable |
Broadcast, BroadcastTo, BroadcastExcept | Unreliable |
SendToRoom | Unreliable |
Call | Reliable, always |
CallTo | Reliable, always |
The reply a When responder returns | Reliable, always |
Ping, Handshake, RequestTable | Reliable, because each is a Call underneath |
A reply is never held back either. The far side is already yielded on it, so it is provably listening, and holding it would strand that thread until its timeout. See Requests and Replies.
Nothing is queued for a client that has not called Init
On a normal carrier, the server holds messages for a client until that client calls
Init() on the channel. Up to 64 per channel, for 30 seconds. That is
what stops a broadcast landing before the client has registered its handlers.
See Startup and Buffering.
Unreliable messages are not held. They are thrown away.
Server sends to a client that has not called Init
That is the right call. You already told Pigeon this message may be lost in transit. A message that can be lost has no business being kept in a queue and replayed seconds later, when it is stale and the player has moved on. Holding a position from thirty seconds ago and delivering it as if it were current would be worse than losing it.
Note
The client still has to call Init(). Until it does, an unreliable
carrier receives nothing at all from the server, and unlike a reliable one it
gets no backlog once it does. Call Init() as soon as your handlers
are set up.
Sending from the client can fall back
Sending from the client never needs Init. But the unreliable remotes
have to replicate down from the server before the client can use them, and that
takes a moment after joining.
If the client calls Emit on an unreliable carrier before the twin has
arrived, Pigeon does not drop the message. It sends it on the reliable remote instead.
If neither remote has arrived, the message waits in the client's outbox and goes out
as soon as one of them does. So your first few sends after joining may be reliable
ones. Nothing breaks, and it settles by itself.
A position streaming example
Every heartbeat the server sends everyone's position to everyone. Losing a frame does not matter, because another one is a sixtieth of a second behind it.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local RunService = game:GetService("RunService")
local Players = game:GetService("Players")
local Pigeon = require(ReplicatedStorage.Pigeon)
local positions = Pigeon.new("Positions", { Unreliable = true })
RunService.Heartbeat:Connect(function()
local snapshot = {}
for _, player in Players:GetPlayers() do
local character = player.Character
local root = character and character.PrimaryPart
if root then
-- String keys, because a table of positions is not a plain array.
snapshot[tostring(player.UserId)] = root.Position
end
end
positions:Broadcast("Update", snapshot)
end)
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Pigeon = require(ReplicatedStorage.Pigeon)
local positions = Pigeon.new("Positions", { Unreliable = true })
positions:On("Update", function(snapshot)
for userId, position in snapshot do
moveGhost(userId, position)
end
end)
-- Without this the server sends nothing, and nothing is kept for later.
positions:Init()
Each message carries the whole picture rather than a change to it. That is the pattern that survives a lossy lane: if one goes missing, the next one still tells the client everything it needs. A message that said "move player 5 forward by 2 studs" would leave the client permanently wrong the first time one was dropped.
The client passes { Unreliable = true } too, but only because this
carrier might send as well. Receiving works either way. The flag only ever affects
the sends that carrier makes.
What to use it for
| Good fit | Bad fit |
|---|---|
| Character and vehicle positions | Currency and inventory changes |
| Aim direction and look vectors | Purchases and trades |
| Cosmetic effects and hit sparks | Quest progress and unlocks |
| Live meters that refresh constantly | Anything the player would notice missing |
The test is simple. If losing one message leaves the game in a wrong state that never fixes itself, use a reliable carrier.
Staged tables on an unreliable carrier
Warning
Do not capture a staged table on an unreliable carrier. Staged tables replicate their writes as fire and forget broadcasts, so on an unreliable carrier those patches take the lossy lane. A dropped patch is never resent, and the client's copy is wrong from then on. Keep staged tables on a reliable carrier. See Staged Tables.
Switching at runtime
Unreliable is a plain field on the carrier and it is read at the moment
you send. You can flip it.
local carrier = Pigeon.new("Effects", { Unreliable = true })
local payload = { round = 2 }
carrier.Unreliable = false
carrier:Broadcast("Important", payload) -- reliable
carrier.Unreliable = true
This is worth knowing but it is not a habit to get into. If a channel has both kinds of traffic on it, two carriers with two names is clearer than one you keep toggling, and it cannot be got wrong by a race.
Write handlers that do not mind repeats
This lane can drop a message and it can deliver messages out of order. Write your handlers so that neither one leaves the game in a wrong state.
The rule is to send what a value is, not how to change it. A message that sets a number is safe to miss, because the next one corrects it. A message that adds to a number is not, because a missed one is gone and a repeated one counts twice.
-- Fragile. A dropped or repeated message leaves the wrong number.
positions:On("Hit", function(amount)
health -= amount
end)
-- Fine. Every message says what the value is now.
positions:On("Health", function(value)
health = value
end)
If something really has to be counted, number it yourself and ignore anything you have already seen. That also throws away messages that arrive out of order.
local lastSeen = 0
positions:On("Tick", function(index, payload)
if index <= lastSeen then
return
end
lastSeen = index
apply(payload)
end)
Note
Anything that must arrive exactly once does not belong on this lane at all. Put it on a normal carrier instead.
What to read next
- Startup and Buffering for what
Initdoes and why it matters more here. - The Ref Pool for where the unreliable twins come from.
- Sending and Receiving for the methods themselves.