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.
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.
| Parameter | Type | What it is |
|---|---|---|
RoomID | string | The 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.
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.
| Parameter | Type | What it is |
|---|---|---|
RoomID | string | The room name. Created if it does not exist. |
Members | {Player} | Player | One player or a list of them. |
Returns nothing.
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.
| Parameter | Type | What it is |
|---|---|---|
RoomID | string | The room name to take them out of. |
Members | {Player} | Player | One player or a list of them. |
Returns nothing.
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.
| Parameter | Type | What it is |
|---|---|---|
RoomID | string | The room name to drop. |
Returns nothing.
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.
| Parameter | Type | What it is |
|---|---|---|
Room | string | The room name to send to. |
event | string | The event to raise on each member. |
... | any | The payload, passed on to each member's handler. |
Returns nothing. There is no report of who received it.
local party = Pigeon.new("Party")
party:SendToRoom("party_1", "Message", player.Name, "ready?")
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.
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)
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 call | What 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. |
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
| Method | Does it check approval? |
|---|---|
CreateRoom | Yes. Errors with ERR_NOT_APPROVED. |
JoinRoom | Yes. Errors with ERR_NOT_APPROVED. |
LeaveRoom | No. Taking somebody out never needs approval. |
DestroyRoom | No. It does not look at members at all. |
SendToRoom | No. 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
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.
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)
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 expect | What 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
- Sending and Receiving for the broadcast methods rooms are built on.
- Handshakes for the guard a room member has to pass, and what
Revoketakes away. - Carrier for the full method list.