Handshakes
A handshake locks a channel. You install a guard on the server, and until that guard
says yes to a client, the client cannot send on the channel, cannot hear anything on
it, and cannot be put in a room on it. That is what makes a
moderator only channel safe to shout on. When somebody should not be there any more,
Revoke puts them back outside without waiting for
them to ask again.
The model
A guard is a function you give to UseHandshake. The moment you install
one, the channel is shut to everybody. Nobody is grandfathered in.
A client gets in by calling Handshake. That runs your guard on the
server. If it returns true, that client is marked approved and the
channel opens for them. Until then they are shut out in both directions:
| What is tried | What happens to a client that is not approved |
|---|---|
Client Emit |
The packet reaches the server and is dropped before any listener or incoming middleware runs. |
Client Call |
Dropped the same way. No reply is sent, so the call waits out the timeout and returns nil. |
Server Broadcast |
They are filtered out of the recipient list. A broadcast to everyone reaches only the approved. |
Server BroadcastTo / BroadcastExcept |
Same filter. Naming them explicitly does not get past it. |
Server SendToRoom |
Same filter, even if they are in the room. |
Server CreateRoom / JoinRoom |
Naming them errors with ERR_NOT_APPROVED, and nobody in that call is added. |
Server CallTo |
Returns nil straight away without sending anything. |
Client RequestTable |
The request is dropped, so it times out and then errors with ERR_CAPTURE_DENIED. |
Client Ping |
No reply comes back, so it waits the full timeout and reports that as the latency. |
The one thing that always gets through is the handshake itself. It travels on a
reserved event called __pigeon_handshake, which the guard check skips.
That is how anyone ever gets in.
Note
Every reserved name starts with __pigeon_. Do not use that prefix
for your own events.
An approval is not permanent. A guard that says no on a later attempt takes it back, and
so does Revoke, which does not wait for the client to
ask. Either way that client drops straight back to every row in the table above. What
else it clears is under Losing access.
One handshake, step by step
Client asks to be let in
Handshake first, then rooms
A room on a guarded carrier may only hold players the guard has
approved. CreateRoom and JoinRoom check every player you name,
and if one of them has not passed, the call errors:
ERR_NOT_APPROVED | Kestrel has not passed the handshake, so cannot be put in room 'alerts'.
The message names the player and the room. Everyone named is checked before a single one
is added, so a refused call changes nothing at all: name one approved player and one who
is not and neither goes in, and a refused CreateRoom leaves no empty room
behind. Only the two calls that put players into a room check.
LeaveRoom, DestroyRoom and SendToRoom are
unchanged, and a carrier with no guard takes anybody, exactly as it always did.
A room on a guarded channel is a list of people allowed to hear something. Somebody who cannot pass the guard is filtered out of every send anyway, so putting them in builds state that can never do anything. Failing at the call says so, instead of leaving you a room that quietly does nothing.
So the order is: let the client handshake, then put it in rooms. The simplest way is to join them from a listener, because nothing an unapproved client sends reaches one.
mods:UseHandshake(function(player, token)
return isModerator(player) and token == tokenFor(player)
end)
-- Too early. Nobody has handshaked yet, so this throws ERR_NOT_APPROVED.
Players.PlayerAdded:Connect(function(player)
mods:JoinRoom("alerts", player)
end)
-- In time. A listener only runs for a client the guard let in.
mods:On("Ready", function(player)
mods:JoinRoom("alerts", player)
end)
if mods:Handshake(myToken) then
mods:Emit("Ready")
end
Careful
Do not call JoinRoom from inside the guard itself. A player is only
marked approved after your guard has returned true, so a join in there
always errors with ERR_NOT_APPROVED.
Installing a guard does not empty your rooms. It clears the approved list and leaves
every room exactly as it was, so a room built before UseHandshake keeps
members that nobody has approved. They are filtered out of every send, and
JoinRoom will not take them anywhere new until they handshake again.
The methods
UseHandshake Server
carrier:UseHandshake(Callback: (player: Player, ...any) -> boolean) -> ()
Installs the guard and closes the channel. The guard is handed the player and
whatever they passed to Handshake. It must return exactly
true to admit them. Anything else, including a truthy value that is not
true, is a refusal.
| Parameter | Type | What it is |
|---|---|---|
Callback | (player: Player, ...any) -> boolean | The guard. Receives the player, then whatever that client passed to Handshake. |
Returns nothing.
local mods = Pigeon.new("Moderation")
mods:UseHandshake(function(player, token)
return isModerator(player) and token == tokenFor(player)
end)
Calling this on the client errors with ERR_NOT_SERVER.
Handshake Client Yields
carrier:Handshake(...: any) -> boolean
Sends whatever you pass to the server's guard and waits for the answer.
| Parameter | Type | What it is |
|---|---|---|
... | any | Whatever the guard needs to judge this client. Forwarded to it after the player. |
Returns a boolean. true means you may now
use the channel.
local mods = Pigeon.new("Moderation")
if mods:Handshake(myToken) then
mods:Emit("Kick", targetName)
else
print("not allowed on this channel")
end
A carrier with no guard on the server admits everybody, so this returns
true. That means client code can call it without knowing whether the
channel is locked.
It returns false in three cases: the guard refused, the carrier has been
dropped or its
transformer destroyed, or no reply arrived before
the timeout. The last one is worth remembering. If the reply is late, you see
false while the server has you marked approved. Try again if that
matters.
Calling this on the server errors with ERR_NOT_CLIENT.
Note
You do not need Init first. Replies are never queued, so a handshake
works from the start. You still want Init before any broadcast on
the channel can reach you. See Startup and Buffering.
Approved Server
carrier:Approved(Targets: {Player}?) -> {Player}
Narrows a list of players down to the ones this carrier will talk to. Pass nothing to start from every player in the game.
| Parameter | Type | What it is |
|---|---|---|
Targets | {Player}? | The players to filter. Left out, it starts from every player in the game. |
Returns a {Player} holding the approved subset. With no
guard installed it is the list you passed in, handed straight back.
local online = mods:Approved()
print(#online .. " moderators are on")
-- Only the approved half of this party.
local some = mods:Approved(partyMembers)
You rarely need it, because every send already runs this filter. It is useful for counting, or for skipping expensive work when nobody is listening.
Careful
This is the one method here that does not error on the client. A client never holds a guard, so it just hands back every player it can see. Treat it as server only.
Revoke Server
carrier:Revoke(Targets: {Player} | Player) -> number
Throws one player or a list of them off the channel now. It clears their approval, takes them out of every room on this carrier, and makes them let go of every staged table they were mirroring from it. You do not have to wait for them to handshake again, and they are not asked.
| Parameter | Type | What it is |
|---|---|---|
Targets | {Player} | Player | One player or a list of them. |
Returns a number: how many of the players you named were
actually admitted. Somebody who never handshaked, or who you revoked a moment ago, is
not counted. With no guard installed nobody is ever marked admitted, so it is always
0.
mods:Revoke(player) -- one player
mods:Revoke({ a, b, c }) -- or several
local count = mods:Revoke(demoted)
print(count .. " of them were on the channel")
Calling this on the client errors with ERR_NOT_SERVER. On a
dropped carrier it does nothing and returns 0.
What it costs the client, down to the wire, is under Losing access.
The guard runs on every attempt
Pigeon does not cache the answer. Each Handshake call runs your guard
again, and the newest answer is the one that counts. Three things follow from that.
A refusal is not permanent. A client turned away can try again later and be let in, after a promotion for example. Nothing has to be rebuilt.
A later failure takes access away. If an approved client handshakes
again and the guard returns false this time, they are shut out again, exactly as if you
had called Revoke. See
Losing access for what that clears.
A guard that errors refuses that attempt. Pigeon catches it, warns, and answers false. A broken guard is treated the same as one that said no, so any approval the player already had goes too. The alternative would be worse: the client is told it was refused while the server still counts it as approved, and the two sides disagree about who is allowed in.
Careful
A client can call Handshake as often as it likes, and your guard runs
every single time. Keep it cheap. If it hits a datastore or anything else slow,
rate limit it yourself.
When you rate limit, do not return false for a call you are throttling.
That would revoke an approval the player already earned. Hand back the last answer
instead.
local lastTry = {}
local lastAnswer = {}
mods:UseHandshake(function(player, token)
local now = os.clock()
-- Too soon. Repeat the previous answer rather than refusing.
if lastTry[player] and now - lastTry[player] < 1 then
return lastAnswer[player] == true
end
lastTry[player] = now
local granted = isModerator(player) and token == tokenFor(player)
lastAnswer[player] = granted
return granted
end)
game:GetService("Players").PlayerRemoving:Connect(function(player)
lastTry[player] = nil
lastAnswer[player] = nil
end)
Losing access
An approved player loses access in one of two ways: your guard answers no on a later
Handshake, an error in it counting as no, or the server calls
Revoke. Both clear the same things. To shut everyone
out at once, replace the guard instead.
| What they had | After they lose access |
|---|---|
A place in Approved |
Gone. Broadcast, BroadcastTo, BroadcastExcept and SendToRoom all skip them, and CallTo returns nil without sending. |
| The right to send | Gone. Their Emit and Call are dropped at the door, before any listener or incoming middleware runs. |
| A place in a room | Gone from every room on the carrier. JoinRoom will not put them back until they are approved again. |
| A mirror of a staged table on this carrier | Taken off the subscriber list, then dropped from the client's cache and marked released. It stops updating. |
A player loses access
Only somebody who was in loses anything to a refusal. A guard turning away a client that
was never approved touches nothing, because there is nothing to take away, and that
client already receives nothing. Revoke is blunter: it clears the rooms and
staged tables of every player you name, in or not. Only its return value cares who was
actually in.
The staged tables let go on their own
For every staged table on the carrier the player was subscribed to, they come off the
subscriber list and the id is remembered. If there was at least one, they are sent a
single reserved message, __pigeon_table_revoke, naming those ids. That one
goes straight down the transport rather than through BroadcastTo, because
the player has just stopped being approved and BroadcastTo would filter
them out.
On the client each named mirror is dropped from the carrier's cache and marked released. Its release hook is cleared first, so the client does not report back something the server has already done.
Note
That message is the only thing sent. If the player held no mirror from this carrier,
nothing goes over the wire at all. Nobody is told they lost the channel unless they
asked: a Handshake that fails answers false, and
Revoke says nothing.
Revoke only shuts somebody out when a guard is installed
Approved filters only when there is a guard, and every send runs through
Approved. So on a carrier with no UseHandshake,
Revoke stops nothing: the player keeps hearing every broadcast and can
still Emit. It does take them out of every room and release their staged
tables, and it returns 0.
Careful
Revoke on an open channel is not a mute. If you want it to shut
somebody out, the carrier needs a guard.
What they already received stays received
Losing access cuts off the future. Neither route can reach into a client's memory. Every value they were sent before is still there, including the last copy of any staged table they were mirroring. Do not treat it as making sent data secret again. If something must never be seen, do not send it.
They can come back
None of it is a ban. The client can call Handshake again whenever it likes
and your guard judges it afresh. Approve it and the channel opens again, and a new
RequestTable for a released id fetches a fresh mirror with a fresh
snapshot.
Rooms do not come back on their own. Being approved again puts nobody back in a room, so
if you want them in one, call JoinRoom once they are through.
If you want the removal to stick, the guard has to say no. Keep the decision in your own state and read it from inside the guard.
Replacing a guard
A carrier holds one guard. Installing a second one replaces the first and closes the channel again, because everyone who is currently in was judged by the old rules.
local mods = Pigeon.new("Moderation")
mods:UseHandshake(oldRules)
-- ... clients handshake and get in ...
mods:UseHandshake(newRules) -- everyone is out again
Warning
Clients are not told. Nothing is sent when a guard is replaced, and the only
thing Revoke sends is the message that releases
staged tables. A client that was in simply stops hearing anything, and its sends
stop arriving, until it calls Handshake again.
Replacing a guard clears the approved list and nothing else. Patches stop reaching those
clients like any other broadcast, but rooms keep their members, staged table
subscriptions stand, and the mirrors clients hold just go stale.
Revoke is the call that empties a player out of all of
it. Until they handshake again, JoinRoom will not take them either.
There is no public way to take a guard off once it is on. The closest thing is replacing it with one that lets everyone in, and even then every client has to handshake once more, because installing it cleared the approved list.
-- Reopens the channel, but nobody is approved until they ask again.
mods:UseHandshake(function()
return true
end)
Messages sent while a client is locked out are gone
The guard filter runs before the startup queue, not after it. A client that is not approved yet is removed from the recipient list, so there is nothing left to queue for them.
Careful
Broadcasts made before a client passes the handshake are not held for them. They
are not queued and they do not arrive late. If a client needs the current state
after it gets in, have it ask with Call.
Once a client is approved the normal rules take over again, so a message sent
before it has called Init is queued as usual.
A moderator only channel
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Pigeon = require(ReplicatedStorage.Pigeon)
local mods = Pigeon.new("Moderation")
mods:UseHandshake(function(player, token)
return isModerator(player) and token == tokenFor(player)
end)
-- Nothing reaches this handler unless the guard approved the sender.
-- It answers a Call, so it is a When responder rather than an On listener.
mods:When("Kick", function(player, targetName)
local target = findPlayer(targetName)
if not target then
return false, "no such player"
end
target:Kick("Kicked by " .. player.Name)
return true
end)
-- Only approved moderators see this. Everyone else is filtered out.
mods:Broadcast("Alert", "10 reports on the same player")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Pigeon = require(ReplicatedStorage.Pigeon)
local mods = Pigeon.new("Moderation")
mods:On("Alert", function(text)
showModAlert(text)
end)
-- Handlers first, then open the inbound side, then ask to be let in.
mods:Init()
if mods:Handshake(myToken) then
local ok, reason = mods:Call("Kick", "someone")
if not ok then
print("kick failed:", reason)
end
end
A player who is not a moderator can build the same carrier, register the same
handlers and call Emit all day. Nothing they send reaches a listener and
no alert ever reaches them.
Demoting a moderator
Someone loses the role while they are online. You want them off the channel now, not
the next time they happen to handshake. That is two lines: change the state your guard
reads, then Revoke them.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Pigeon = require(ReplicatedStorage.Pigeon)
local mods = Pigeon.new("Moderation")
local rank = {} -- Player -> string
mods:UseHandshake(function(player, token)
return rank[player] == "moderator" and token == tokenFor(player)
end)
-- The live report queue, mirrored by every moderator that asks for it.
local queue = Pigeon.StagedTable({ open = {} })
mods:CaptureTable("report_queue", queue)
local function demote(player)
-- Change this first. The guard reads it, so a fresh Handshake now fails too.
rank[player] = "player"
-- Clears their approval, takes them out of every room on this carrier,
-- and releases their mirror of report_queue.
local removed = mods:Revoke(player)
if removed == 1 then
-- They are already out, so this does not reach them.
mods:Broadcast("Alert", player.Name .. " is no longer a moderator")
end
end
Order matters. Revoke on its own lasts until the client asks again, and
your guard would still say yes. Setting rank first is what makes it
stick.
local mods = Pigeon.new("Moderation")
mods:On("Alert", showModAlert)
mods:Init()
if mods:Handshake(myToken) then
local queue = mods:RequestTable("report_queue")
renderQueue(queue:GetTable())
end
-- After the server revokes: no more alerts, nothing this client emits arrives,
-- and queue stops updating. What it already drew is still on screen.
The client is not told any of that happened. If it needs to know, have it call
Handshake again on a timer or when the user does something, and read the
answer.
Odds and ends
| Situation | What happens |
|---|---|
| A player leaves and rejoins | Their approval is dropped when they leave, and they are taken out of every room. They must handshake again. |
The guard returns a truthy value that is not true |
Counts as a refusal. |
| The carrier is dropped | Handshake returns false. The guard and the approved list are cleared. |
| Nobody at all is approved | A broadcast does nothing. Outgoing middleware does not even run. |
| Two carriers share a transformer | Guards are per carrier. Locking one says nothing about the other. |
| You revoke somebody who was never approved | They do not count towards the return value. They still come out of every room, and any staged table they were on the list for is dropped anyway. |
| You revoke the same player twice | The second call returns 0. There is nothing left to clear. |
You name an unapproved player in CreateRoom or JoinRoom |
The call errors with ERR_NOT_APPROVED and nobody in it is added. |
| A room built before you installed the guard | It keeps its members. They hear nothing until they handshake, because every send filters. |
Pigeon exposes no public flag on the client for whether you are still in. The only
signal is what Handshake returned. Hold on to it, and call
Handshake again if you want a fresh answer.
What to read next
- Rooms for the lists only an approved player can be put in.
- Middleware for shaping messages that already got past the guard.
- Startup and Buffering for what is queued and what is not.
- Staged Tables for the mirrors
Revokemakes a client let go of. - Carrier for the full method list, including
Revoke.