Rooms

A room is a named list of players that lives on one carrier. You put players in it on the server, then send to the whole list in one call. Parties, teams, one lobby per match: anything where the same message goes to some players and not others.

What a room actually is

It is a list of Player objects with a name, held on the carrier. That is all. There is no Instance, nothing replicates, and the client has no idea rooms exist.

Every room method is server only. Call one on the client and it errors with ERR_NOT_SERVER. A client joins a party by asking the server to put it in one, using Emit or Call, and the server decides.

Rooms belong to a carrier

The room name is only unique inside one carrier. A carrier called "Party" and a carrier called "Chat" can both have a room called "lobby" and they are two unrelated lists.

Server
local party = Pigeon.new("Party")
local chat = Pigeon.new("Chat")

party:JoinRoom("lobby", player)

-- Nothing was added to this one. It warns that "lobby" does not exist.
chat:SendToRoom("lobby", "Message", "hello")

Pigeon.new("Party") hands back the same carrier every time, so any script on the server can ask for "Party" and see the same rooms. That stops being true the moment you pass options, because a carrier built with options is always a fresh one with its own empty room list.

Careful

Pigeon.new("Party", {}) gives you a second carrier on the same name. Its rooms are separate from the cached one's, so a room you created through one is not there in the other.

The methods

CreateRoom Server

carrier:CreateRoom(RoomID: string, Members: {Player}) -> ()

Makes a room and fills it with the players you pass. If a room already exists under that name it is thrown away and replaced, so the old members are no longer in it.

On a carrier with a handshake guard, every player you name has to have passed it, or the call errors with ERR_NOT_APPROVED and no room is made. See Rooms and guarded channels.

ParameterTypeWhat it is
RoomIDstringThe room name, unique inside this carrier.
Members{Player}The players to seed the room with. Pass an empty table, or nothing at all, for none.

Returns nothing.

Server
party:CreateRoom("party_1", { leader })
party:CreateRoom("party_1", { leader, friend })  -- replaces the first list

-- An empty room is fine. It just has nobody in it yet.
party:CreateRoom("party_2", {})

Members has to be a table here, even for one player. Only JoinRoom and LeaveRoom take a single player as well as a list.

Careful

CreateRoom does not check for duplicates. A player listed twice is in the room twice, and every SendToRoom reaches them twice. JoinRoom does check, so prefer it if you are not sure.

JoinRoom Server

carrier:JoinRoom(RoomID: string, Members: {Player} | Player) -> ()

Adds one player or a list of them to a room. If the room does not exist yet it is created. A player already in the room is not added again.

ParameterTypeWhat it is
RoomIDstringThe room name. Created if it does not exist.
Members{Player} | PlayerOne player or a list of them.

Returns nothing.

Server
party:JoinRoom("party_1", player)             -- one player
party:JoinRoom("party_1", { a, b, c })        -- or several
party:JoinRoom("party_1", player)             -- no effect, already in

Because it creates the room for you, most code never needs CreateRoom. Reach for CreateRoom when you want to wipe an existing list, or when you want the room to exist before anyone is in it.

On a carrier with a handshake guard, a player who has not passed it cannot be added. The call errors with ERR_NOT_APPROVED and nobody you named is added, not even the players who are approved. See Rooms and guarded channels.

LeaveRoom Server

carrier:LeaveRoom(RoomID: string, Members: {Player} | Player) -> ()

Removes one player or a list of them. If the room does not exist, or the player was never in it, nothing happens and nothing is logged.

ParameterTypeWhat it is
RoomIDstringThe room name to take them out of.
Members{Player} | PlayerOne player or a list of them.

Returns nothing.

Server
party:LeaveRoom("party_1", player)
party:LeaveRoom("nope", player)  -- quiet, does nothing

Taking the last player out does not delete the room. It stays as an empty list until you call DestroyRoom.

DestroyRoom Server

carrier:DestroyRoom(RoomID: string) -> ()

Drops the room. Nothing is sent to the members and nothing else changes for them, they simply cannot be reached through that name any more. Destroying a room that does not exist is a no-op.

ParameterTypeWhat it is
RoomIDstringThe room name to drop.

Returns nothing.

Server
party:SendToRoom("party_1", "Disbanded")
party:DestroyRoom("party_1")

Send first, then destroy. Once the room is gone SendToRoom has nobody to send to.

SendToRoom Server

carrier:SendToRoom(Room: string, event: string, ...: any) -> ()

Fire and forget to every member. It is exactly a BroadcastTo whose recipient list is the room, so the same rules apply in the same order: the handshake filter runs, then outgoing middleware, then anything for a client that has not called Init yet is queued.

ParameterTypeWhat it is
RoomstringThe room name to send to.
eventstringThe event to raise on each member.
...anyThe payload, passed on to each member's handler.

Returns nothing. There is no report of who received it.

Server
local party = Pigeon.new("Party")

party:SendToRoom("party_1", "Message", player.Name, "ready?")
Client
local party = Pigeon.new("Party")

party:On("Message", function(who, text)
	print(who .. ": " .. text)
end)

party:Init()

If the room does not exist, Pigeon warns and sends nothing:

ERR_NO_ROOM | Room 'party_1' does not exist.

An empty room is not the same thing. It exists, so there is no warning, and the send quietly does nothing.

Note

There is no room version of Call. If you need answers back, loop the members yourself and use CallTo on each one. See Requests and Replies.

Players who leave the game

You do not have to clean up after them. When a player leaves, Pigeon takes them out of every room on that carrier for you.

Anything you keep alongside the room is still yours to clear. If you hold your own table of party leaders keyed by player, clear it on PlayerRemoving yourself.

A party system, start to finish

Pigeon has no way to ask which rooms exist or who is in one. Keep whatever you need to check in a table of your own, and use the room only for sending.

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

local party = Pigeon.new("Party")

-- Pigeon does not expose room membership, so track what you need to check.
local leaderOf = {}   -- RoomID -> Player

-- These two answer a Call, so they are responders, not listeners.
party:When("Create", function(player)
	local id = "party_" .. player.UserId
	party:CreateRoom(id, { player })
	leaderOf[id] = player
	return id
end)

party:When("Join", function(player, id)
	if not leaderOf[id] then
		return false, "no such party"
	end
	party:JoinRoom(id, player)
	party:SendToRoom(id, "Joined", player.Name)
	return true
end)

-- These two answer nothing, so On is right.

party:On("Say", function(player, id, text)
	if not leaderOf[id] then
		return
	end
	party:SendToRoom(id, "Message", player.Name, text)
end)

party:On("Leave", function(player, id)
	party:LeaveRoom(id, player)
	-- The player is already out, so this one does not reach them.
	party:SendToRoom(id, "Left", player.Name)

	if leaderOf[id] == player then
		party:SendToRoom(id, "Disbanded")
		party:DestroyRoom(id)
		leaderOf[id] = nil
	end
end)

Players.PlayerRemoving:Connect(function(player)
	-- Pigeon pulls them out of the rooms. This table is yours.
	for id, leader in leaderOf do
		if leader == player then
			party:SendToRoom(id, "Disbanded")
			party:DestroyRoom(id)
			leaderOf[id] = nil
		end
	end
end)
Client
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Pigeon = require(ReplicatedStorage.Pigeon)

local party = Pigeon.new("Party")

party:On("Joined", function(name) print(name .. " joined") end)
party:On("Left", function(name) print(name .. " left") end)
party:On("Message", function(name, text) print(name .. ": " .. text) end)
party:On("Disbanded", function() print("party over") end)

-- Handlers are in place, so let the queued messages through.
party:Init()

local id = party:Call("Create")
party:Emit("Say", id, "anyone want to run this?")

Careful

A room is not a permission check. The client picked that id, so anything it sends alongside one has to be validated on the server, exactly like any other message.

Rooms and guarded channels

Everything above is about a carrier with no guard on it. Install a handshake and rooms get one extra rule: the room may only hold players the guard has approved.

An unapproved player cannot be put in a room

CreateRoom and JoinRoom check every player you name. If the carrier has a guard and one of them has not passed it, the call errors:

ERR_NOT_APPROVED | Kestrel has not passed the handshake, so cannot be put in room 'match_1'.

The message names the player and the room, so you know which pair was refused. A carrier with no guard is unchanged and still takes anybody.

The reason is that 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, rather than leaving you a room that quietly does nothing.

The check is all or nothing

Every player named is checked before a single one is added, so a refused call changes nothing at all.

The callWhat is left behind
JoinRoom naming one approved and one unapproved player Neither is added. The approved one does not slip in.
JoinRoom on a room that does not exist yet The room is not created.
CreateRoom that is refused No empty room, and a room already under that name is not replaced.
Server
local match = Pigeon.new("Match")
match:UseHandshake(guard)

-- Errors on the first unapproved player. Nobody is added, and if
-- "match_1" did not exist, it still does not.
match:JoinRoom("match_1", { approved, notApproved })

-- Catch it if a refusal is normal for you.
local ok, err = pcall(function()
	match:JoinRoom("match_1", players)
end)
if not ok then
	warn(err)
end

Only the two calls that add players check

MethodDoes it check approval?
CreateRoomYes. Errors with ERR_NOT_APPROVED.
JoinRoomYes. Errors with ERR_NOT_APPROVED.
LeaveRoomNo. Taking somebody out never needs approval.
DestroyRoomNo. It does not look at members at all.
SendToRoomNo. It filters at send time instead, as below.

Sending still filters

SendToRoom goes through the same filter as every other broadcast, and it always did. A member the guard has not approved is dropped from the recipient list and hears nothing. Being in a room does not get you past a guard.

With the rule above, that filter has little left to do, but it still matters in one case. Installing a guard closes the channel to everybody and does not empty your rooms, so a room built before UseHandshake keeps its members while none of them are approved. They hear nothing until they handshake.

SendToRoom

party:SendToRoom("party_1", "Message", text) look the room up
drop anyone the guard has not approved outgoing middleware
send now, or queue per client each member's On handler

Losing access takes a player out of every room

However an approved player loses access, by handshaking again and being turned away or by the server calling Revoke, they are taken out of every room on the carrier. They are also unsubscribed from every staged table the carrier holds, and their client is told to let those mirrors go.

The two routes differ on who they touch. A guard that turns away a client it had never approved changes nothing, because there was no approval to take back. Revoke is blunter: it empties every player you name out of every room and off every staged table, approved or not. Only the number it returns cares who was actually in.

That distinction matters because a room can hold an unapproved player. Installing a guard does not empty your rooms, so a room built before UseHandshake keeps members nobody has approved, and Revoke is what takes them out.

Rooms do not come back on their own. Being approved again gets the channel back, not the room. Put them back with JoinRoom if you want them there.

Handshake first, then room

The rule fixes the order you have to work in. You cannot queue a player into a room and let them handshake later, so the room join has to happen after the guard has said yes.

Do not try to do it inside the guard either. While your guard is still running the player is not marked approved yet, so JoinRoom would error, and an erroring guard counts as a refusal. You would turn away the player you were trying to add.

The way through is to let the client tell you it is in. Nothing an unapproved client sends reaches a listener, so a handler on a guarded carrier only ever runs for a player who has already passed.

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

local match = Pigeon.new("Match")

local ticketOf = {}   -- Player -> RoomID, filled by your matchmaker

match:UseHandshake(function(player, id)
	-- Judge them only. Do not touch rooms in here.
	local wanted = ticketOf[player]
	return wanted ~= nil and wanted == id
end)

-- Only an approved client can reach this handler, so the join cannot be refused.
match:On("Ready", function(player)
	local id = ticketOf[player]
	if not id then
		return
	end

	match:JoinRoom(id, player)
	match:SendToRoom(id, "Joined", player.Name)
end)

match:On("Say", function(player, text)
	local id = ticketOf[player]
	if id then
		match:SendToRoom(id, "Message", player.Name, text)
	end
end)
Client
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Pigeon = require(ReplicatedStorage.Pigeon)

local match = Pigeon.new("Match")

match:On("Joined", function(name) print(name .. " joined") end)
match:On("Message", function(name, text) print(name .. ": " .. text) end)

-- Handlers, then the inbound side, then ask to be let in.
match:Init()

if match:Handshake(myTicket) then
	-- Now the server can put me in the room.
	match:Emit("Ready")
end

Written the other way round, with match:JoinRoom(id, player) in the matchmaker that hands out the ticket, that line errors: the player has not handshaked yet.

Careful

If your guard revokes people, the room join has to run again after they are approved again. A client that handshakes a second time is back on the channel but not back in its room until you call JoinRoom.

What rooms do not do

You might expectWhat is true
A way to list rooms or read members There is none. Keep your own table if you need to check.
Empty rooms clean themselves up They do not. Only DestroyRoom removes a room.
The client can join a room It cannot. Every room method is server only.
Rooms are shared between carriers They are not. Each carrier holds its own.
A room survives the carrier It does not. Destroying the carrier clears every room on it.
You can put a player in a room before they handshake Not on a guarded carrier. CreateRoom and JoinRoom raise ERR_NOT_APPROVED.
A player revoked and then approved again is back in their room They are not. Call JoinRoom again yourself.

What to read next