Rooms and Lobbies¶
Rooms are the core coordination unit in Signal Fish Server. A room groups players together for a single game session, manages a lobby where everyone signals readiness, and distributes legacy peer metadata when the game starts.
Creating a Room¶
A room is created when a player sends a JoinRoom message without a
room_code. The server generates a unique room code and returns it in the
RoomJoined response.
Client sends:
{
"type": "JoinRoom",
"data": {
"game_name": "my-game",
"player_name": "Alice",
"max_players": 4
}
}
Server responds:
{
"type": "RoomJoined",
"data": {
"room_id": "550e8400-e29b-41d4-a716-446655440000",
"room_code": "HK7T3W",
"player_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"game_name": "my-game",
"max_players": 4,
"supports_authority": true,
"current_players": [
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"name": "Alice",
"is_authority": true,
"is_ready": false
}
],
"is_authority": true,
"lobby_state": "waiting",
"ready_players": [],
"relay_type": "matchbox",
"current_spectators": []
}
}
Alice now shares the room code HK7T3W with other players (via your
game's UI, a chat message, or any other channel).
relay_type is a deployment-defined legacy integration label. It is
informational only and does not select or prove the active transport; use the
authenticated WebSocket relay path or a negotiated v3 SessionPlan for
executable routing.
JoinRoom Resolution Rules¶
JoinRoom is used for both creation and joining. The server resolves each
request using the (game_name, room_code) pair:
JoinRoom payload |
Server behavior | Result |
|---|---|---|
room_code omitted |
Implicit room creation | New room + generated code in RoomJoined |
room_code provided and room exists for game_name |
Join existing room | RoomJoined + PlayerJoined broadcast to others |
room_code provided and room does not exist for game_name |
Explicit room creation | New room using requested room_code |
room_code provided with join_only: true and room does not exist |
Refused | RoomJoinFailed with ROOM_NOT_FOUND; no room is created |
Creation-specific fields such as max_players and supports_authority
apply only when a room is created (implicit or explicit). When joining an
existing room, current room settings remain unchanged.
Joining an Existing Room¶
Other players join by including the room_code in their JoinRoom
message:
{
"type": "JoinRoom",
"data": {
"game_name": "my-game",
"room_code": "HK7T3W",
"player_name": "Bob"
}
}
Bob receives a RoomJoined response with the current room state. All
existing players in the room receive a PlayerJoined broadcast:
{
"type": "PlayerJoined",
"data": {
"player": {
"id": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
"name": "Bob",
"is_authority": false,
"is_ready": false
}
}
}
Room Codes¶
Room codes are auto-generated 6-character alphanumeric strings designed to be easy to read aloud and type. The "clean" code generator excludes characters that are visually confusing:
0(zero) andO(letter O)1(one) andI(letter I)
This leaves 32 unambiguous characters: 2-9 and A-H, J-N, P-Z.
The code length is configurable via the room_code_length protocol setting
(default: 6) and must be greater than zero. An optional
server.room_code_prefix is normalized to uppercase and prepended to generated
codes. Prefixes must be nonblank ASCII alphanumeric strings shorter than the
total code length; startup rejects any configuration that could generate a code
the join validator would refuse.
Automatic room creation tries at most eight independently generated candidates.
An occupied candidate is never treated as a request to join that room; the
server generates another candidate instead. All internal attempts count as one
client room-creation operation for rate limiting. Providing room_code retains
the explicit join/create behavior described above.
Explicit codes and first-claim authority¶
A JoinRoom with a room_code creates the room when the code is unused, and
the creator becomes its authority (first-claim authority). The server cannot
verify that a joiner "intended" a code: a room code works like a capability,
and whoever joins an unused code first owns its room and authority. A player
who learns or guesses another group's planned code can claim it first and
withhold StartGame; the group's recourse is to pick another code.
join_only: true removes the first-claim surprise for joins that must not
create (issue #625). A join naming an unused code is refused
ROOM_NOT_FOUND instead of creating the room and claiming its authority.
Routed joins from a room directory should always set it: the directory owns
creation, and a join that lands where the room does not exist must not
silently open a duplicate room on the wrong home.
Operators should therefore:
- Prefer auto-generated codes (omit
room_code). They are random, and the per-game room caps bound namespace enumeration. - Seal an explicit-code room at creation: make the creating join carry the password (see below). A sealed room is born closed, and a later password-carrying join into a squatted open room is refused (see the password rules below), so the group learns the code is not theirs instead of being seated under a hostile authority.
- Treat server-side reservation of explicit codes (a claim token minted by a trusted operator backend) as a hosted-tier feature; it is not part of the current protocol surface.
Rotation does not reserve the old code. RegenerateRoomCode makes the old
code stop resolving, and a join that names it afterwards behaves like any
unknown code: join-creates-room opens a fresh, unrelated room under it, and
the joiner becomes that room's authority. With join_only: true, the same
stale join is refused ROOM_NOT_FOUND instead (issue #625). Clients must
therefore treat any RoomJoined for a code the user did not just create as a
new room, never as a return to the previous one. After a rotation, share only
the new code.
The current code reaches a member only at join time, in Reconnected, or
out of band from the authority. A member who receives the authority role
after a rotation never learns the new code in-protocol (issue #566); share
it with them before the transfer.
The per-game (max_rooms_per_game) and server-wide (max_rooms) room caps
bound the cost of namespace enumeration in open mode.
Let s be the random suffix length after the normalized prefix. The generator
has 32^s possible suffixes. With r occupied codes for one game, a candidate's
collision probability is r / 32^s, and the probability that all eight uniform
candidates collide is (r / 32^s)^8. Keep expected active rooms below 1% of the
suffix namespace so collision retries remain exceptional. For example, four
random characters provide 1,048,576 suffixes; a two-character prefix with the
default six-character total length therefore comfortably supports the default
1,000-room per-game cap. Monitor race_conditions.room_code_collisions and the
dedicated room_code_retry_operations, room_code_retry_successes,
room_code_retry_exhaustions, and room_code_retry_success_rate fields when
selecting a longer prefix or shorter total length (room_code_retry_success_rate
is null until at least one retry operation has been attempted).
Room Passwords and Bans¶
Rooms are open by default: anyone with the code can join. For hosted or
semi-public deployments the authority can add an access layer (all v3 only,
via the room_operation_ids capability; see
Authority):
Password protection¶
SetRoomAccess { password } seals the room. From then on every seated or
spectator join must present the same password:
{
"type": "JoinRoom",
"data": {
"game_name": "my-game",
"room_code": "ABC123",
"player_name": "Player1",
"password": "open sesame"
}
}
- A join with a missing or wrong password is refused with
PASSWORD_REQUIRED; the server never distinguishes the two, so a wrong guess leaks nothing beyond the refusal itself. - A password presented to an open room is refused with the same
PASSWORD_REQUIREDcode. A join that carries a password states the intent to enter a sealed room. If the code already exists as an open room, the room under that code was created by someone else, and seating the joiner would place them under that room's authority. The refusal keeps the failure loud without revealing whether the room is open or sealed. Join an open room without a password. - The check runs before anything else about the room is evaluated (name conflicts, capacity, session compatibility), so a sealed room reveals nothing to a request without its credential.
- When the creating join carries a password, the room is sealed from
birth -- there is no unlocked window between creation and
SetRoomAccess. SetRoomAccess { password: null }reopens the room. Current members and their reconnection tokens are unaffected either way.- The seal gates fresh joins only. A pending reconnection record armed
before the seal still restores its holder into the sealed room with no
password:
Reconnectcarries no password field, and resuming a prior membership through a live seat credential is not a fresh admission. A ban is the tool that refuses restores. - Passwords are capped at 256 bytes and stored only as salted hashes (a single digest pass, no key-derivation work factor): the plaintext is never logged, echoed, or persisted. Treat the password as a room-scoped convenience secret guarding admission, not an account credential.
Bans¶
BanPlayer { player_id } evicts a seated member exactly like a kick (close
code 4007, no reconnection) and records the id on the room's ban
list. While the room lives, the banned id cannot rejoin as a player or a
spectator (BANNED). UnbanPlayer { player_id } lifts a ban (idempotent).
Bans are room-scoped and in-memory: they expire when the room dies and never extend across rooms or instances. They protect one room's community for one room's lifetime -- use app-level (tenant) controls for anything broader.
Room Lifecycle¶
Every room moves through a simple state machine. The following diagram shows the three states and the transitions between them.
+------------------------------------------+
| ROOM LIFECYCLE |
+------------------------------------------+
Room created (JoinRoom, no code)
|
v
+-----------+
| Waiting | <--- Empty room, waiting for the first player
+-----------+
|
| First player joins
v
+-----------+
| Lobby | <--- Players toggle ready/unready via PlayerReady
+-----------+ (max_players is a ceiling; room need not be full)
|
| Every current player ready, a member sends StartGame
v
+-----------+
| Finalized | ---> GameStarting sent to all players
+-----------+
A player leaving during the lobby no longer regresses a partial room to Waiting; the remaining members stay in the lobby.
Waiting¶
The initial state. The room exists but is empty, awaiting its first player.
- No players are present yet.
- The Waiting→Lobby promotion happens inside the first member's own
JoinRoom: that member'sRoomJoinedis immediately followed by the one-timeLobbyStateChanged { state: lobby }promotion snapshot. No client can act while the room isWaiting, and later joins into the open lobby fire no promotion snapshot. - The room stays here until a player joins or it expires from inactivity.
Lobby¶
Players are present and can coordinate readiness. max_players is a
ceiling, not a required count — the room enters the lobby with one or
more players and need not be full to start.
- Players send
PlayerReadyto toggle their ready/unready status. - Each readiness change triggers a
LobbyStateChangedbroadcast so everyone sees who is ready;all_readyis set once every current player is ready. - Readiness does not start the game on its own. Once every current
player is ready, a member finalizes the lobby with an explicit
StartGame. LobbyStateChangedfires on readiness toggles only (plus the one-time Waiting→Lobby promotion snapshot). A player who joins an open lobby later is always unready and triggers no readiness broadcast, so a cachedall_ready: trueis stale the moment aPlayerJoinedshows a new member — recompute readiness from the current membership instead, and treatStartGame(which re-checks under the room lock) as the only authoritative gate.- Readiness belongs to a membership: a player who leaves and joins the
room again starts unready and must send
PlayerReadyagain. - A member who reconnects resumes the same membership, so it is not forced
unready — but its lobby readiness can be dropped while it is away (any other
member's
PlayerReadyprunes absent members from the ready set). Treat theReconnectedsnapshot'sready_players/is_readyas authoritative rather than assuming the pre-disconnect state survived. A member that was in the room when the game started is still reported ready when it reconnects into that running game; a member that joins or returns to a room that started without it is reported unready, exactly like any other seat-filler. - More players may keep joining until the room reaches
max_players.
LobbyStateChanged example (one player ready):
{
"type": "LobbyStateChanged",
"data": {
"lobby_state": "lobby",
"ready_players": [
"a1b2c3d4-e5f6-7890-abcd-ef1234567890"
],
"all_ready": false
}
}
Finalized¶
A member has sent StartGame while every current
player was ready. The server finalizes the game:
StartGame is accepted only when every current player is ready
(GAME_START_NOT_READY otherwise) and the sender is permitted to start — the
room's authority player if it has one, else any member (GAME_START_FORBIDDEN
otherwise). On finalization the server:
- Records a finalization timestamp.
- Sends a
GameStartingmessage to every player with legacy peer metadata. - For every negotiated-v3 member, follows with a per-recipient
SessionPlanthat describes topology, transport, peers, relay fallback, and ICE only when the selected transport is WebRTC. The relay floor is an explicit no-peerrelay/relayplan; v2 members receive no plan.
{
"type": "GameStarting",
"data": {
"peer_connections": [
{
"player_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"player_name": "Alice",
"is_authority": true,
"relay_type": "matchbox"
},
{
"player_id": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
"player_name": "Bob",
"is_authority": false,
"relay_type": "matchbox"
}
]
}
}
After finalization, the room is typically cleaned up by the server.
Message Flow Example (2 Players)¶
Here is the full message sequence for a two-player game from room creation to game start:
Alice (Client) Server Bob (Client)
| | |
|-- JoinRoom (create) -->| |
|<-- RoomJoined ---------| |
| (code: HK7T3W) | |
|<-- LobbyStateChanged --| |
| (state: lobby) | |
| | |
| |<-- JoinRoom (join) ---|
|<-- PlayerJoined -------|--- RoomJoined ------->|
| | |
|-- PlayerReady -------->| |
|<-- LobbyStateChanged --|-- LobbyStateChanged ->|
| | |
| |<-- PlayerReady -------|
|<-- LobbyStateChanged --|-- LobbyStateChanged ->|
| (all_ready: true) | (all_ready: true) |
| | |
|-- StartGame ---------->| |
|<-- GameStarting -------|--- GameStarting ----->|
| (legacy metadata) | (legacy metadata) |
The move into finalized is signaled by GameStarting itself — the server
never broadcasts a LobbyStateChanged carrying finalized. The finalized
value appears only in the lobby_state field of a later RoomJoined /
Reconnected snapshot.
Once both players are ready, either member may send StartGame (this room has
no claimed authority, so any member may start). The game does not start until
that explicit message arrives.
Single-Player and Solo Starts¶
max_players is a ceiling, so a room can start with fewer players than its
limit — including a single player. The lone member sends PlayerReady and
then StartGame to finalize a solo session and receive legacy GameStarting
peer metadata. A single ready member is always permitted to start (it is the
only member, so the "any member" authorization rule applies when no authority
is set).
Configuration¶
Key room-related configuration options:
max_players-- Maximum players per room. Default:8, maximum:100. Set per room at creation time via theJoinRoommessage.room_code_length-- Nonzero length of generated room codes. Default:6.room_code_prefix-- Optional ASCII-alphanumeric prefix, shorter than the total code length.room_cleanup_interval-- Seconds between cleanup sweeps. Default:60.empty_room_timeout-- Seconds before an empty room is removed. Default:300.inactive_room_timeout-- Seconds before an inactive room is removed. Default:3600.
For the full list of server configuration options, see the Configuration.
Next Steps¶
- Authority System -- Designating one player as the game host
- Reconnection -- Handling dropped connections during a session
- Spectator Mode -- Adding read-only observers to rooms