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 triedWhat 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

mods:Handshake(token) __pigeon_handshake, always let through
your guard runs with (player, token) it returns true
the server marks that player approved Handshake returns true

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.

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

ParameterTypeWhat it is
Callback(player: Player, ...any) -> booleanThe guard. Receives the player, then whatever that client passed to Handshake.

Returns nothing.

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

ParameterTypeWhat it is
...anyWhatever the guard needs to judge this client. Forwarded to it after the player.

Returns a boolean. true means you may now use the channel.

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

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

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

ParameterTypeWhat it is
Targets{Player} | PlayerOne 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.

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

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

the guard says no, or mods:Revoke(player) their approval is cleared
taken out of every room on the carrier taken off every staged table on it
__pigeon_table_revoke names those ids the client drops those mirrors

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.

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

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

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

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

Client
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

SituationWhat 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 Revoke makes a client let go of.
  • Carrier for the full method list, including Revoke.