Skip to content

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:

JSON
{
  "type": "JoinRoom",
  "data": {
    "game_name": "my-game",
    "player_name": "Alice",
    "max_players": 4
  }
}

Server responds:

JSON
{
  "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:

JSON
{
  "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:

JSON
{
  "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) and O (letter O)
  • 1 (one) and I (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:

JSON
{
  "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_REQUIRED code. 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: Reconnect carries 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.

Text Only
+------------------------------------------+
|            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's RoomJoined is immediately followed by the one-time LobbyStateChanged { state: lobby } promotion snapshot. No client can act while the room is Waiting, 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 PlayerReady to toggle their ready/unready status.
  • Each readiness change triggers a LobbyStateChanged broadcast so everyone sees who is ready; all_ready is 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.
  • LobbyStateChanged fires 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 cached all_ready: true is stale the moment a PlayerJoined shows a new member — recompute readiness from the current membership instead, and treat StartGame (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 PlayerReady again.
  • 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 PlayerReady prunes absent members from the ready set). Treat the Reconnected snapshot's ready_players / is_ready as 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):

JSON
{
  "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 GameStarting message to every player with legacy peer metadata.
  • For every negotiated-v3 member, follows with a per-recipient SessionPlan that describes topology, transport, peers, relay fallback, and ICE only when the selected transport is WebRTC. The relay floor is an explicit no-peer relay/relay plan; v2 members receive no plan.
JSON
{
  "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:

Text Only
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 the JoinRoom message.
  • 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