Skip to content

Spectator Mode

Spectators are read-only observers who attach to a game room without participating. They receive a one-time room snapshot when they join, but they cannot affect the game in any way. Spectators do not count toward the room's max_players limit, so they never block a real player from joining.

Joining as a Spectator

To spectate a room, send a JoinAsSpectator message with the game name, room code, and a display name:

JSON
{
  "type": "JoinAsSpectator",
  "data": {
    "game_name": "my-game",
    "room_code": "HK7T3W",
    "spectator_name": "Observer1"
  }
}

All three fields are required. Unlike JoinRoom, you cannot create a room by spectating -- you must provide an existing room_code.

spectator_name follows the same charset and byte-length rules as player names, but — unlike player names — it carries no uniqueness contract: several spectators may share (or visually match) a display name. This is deliberate. Spectator identity is non-authoritative display metadata, and spectator capacity is unbounded, so enforced uniqueness would let the first client to join under any name permanently block everyone else who uses it. Player names keep strict uniqueness because player identity gates gameplay authority; spectator names do not.

Spectator Joined Response

On success, the server sends a SpectatorJoined message with the current room state:

JSON
{
  "type": "SpectatorJoined",
  "data": {
    "room_id": "550e8400-e29b-41d4-a716-446655440000",
    "room_code": "HK7T3W",
    "spectator_id": "c3d4e5f6-a7b8-9012-cdef-123456789012",
    "game_name": "my-game",
    "current_players": [
      {
        "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
        "name": "Alice",
        "is_authority": false,
        "is_ready": true,
        "epoch": 1,
        "seq": 17
      },
      {
        "id": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
        "name": "Bob",
        "is_authority": false,
        "is_ready": true,
        "epoch": 2,
        "seq": 0
      }
    ],
    "current_spectators": [
      {
        "id": "c3d4e5f6-a7b8-9012-cdef-123456789012",
        "name": "Observer1"
      }
    ],
    "lobby_state": "lobby",
    "reason": "joined"
  }
}

The example shows negotiated v3: every current_players entry carries its current relay epoch and exact recipient-visible seq baseline. A pre-v3 recipient receives the same snapshot without either field, but with the frozen legacy connection_info field. Protocol v3 snapshots never echo connection_info (issue #529): the self-declared metadata keeps its single consumer, the GameStarting legacy handoff surface. The pair makes the snapshot usable as an accountability baseline if live spectator delivery is added later; spectators do not receive relayed gameplay today.

This gives the spectator an immediate snapshot of who is in the room, what state the lobby is in, and who else is spectating.

What Spectators Receive

A spectator receives only two direct messages tied to its own spectator session:

  • SpectatorJoined -- the room snapshot delivered once on a successful join (current players, current spectators, and lobby state).
  • SpectatorLeft -- the confirmation delivered when the spectator leaves or disconnects.

Known limitation: In the current server, spectators are not added to the room's broadcast set. Room broadcasts -- PlayerJoined, PlayerLeft, LobbyStateChanged, GameData, AuthorityChanged, GameStarting -- and peer spectator notifications (NewSpectatorJoined, SpectatorDisconnected) are delivered to the room's players, not to spectators. A spectator that needs live room updates must re-fetch or rely on out-of-band data; it will not receive these events over its WebSocket today.

What Spectators Cannot Do

Spectators are strictly read-only. The following actions are not available to spectators:

  • Send GameData -- Spectators cannot inject game data into the room. Attempted game data (JSON or raw binary) is answered with NOT_IN_ROOM, since spectator connections are never seated in the player roster.
  • Send PlayerReady -- Spectators cannot affect the ready-up flow.
  • Send StartGame -- Spectators cannot finalize the lobby. Like the other in-room lobby control actions, a spectator's StartGame is answered with NOT_IN_ROOM: the start authorization trusts the sender's room assignment, and spectator connections are never assigned to a room.
  • Send AuthorityRequest -- Spectators cannot claim or release authority.
  • Affect max_players -- Spectators are tracked separately and do not occupy player slots.

Spectator Notifications

When a spectator joins, all players in the room receive a NewSpectatorJoined broadcast (spectators do not receive this notification):

JSON
{
  "type": "NewSpectatorJoined",
  "data": {
    "spectator": {
      "id": "c3d4e5f6-a7b8-9012-cdef-123456789012",
      "name": "Observer1",
      "connected_at": "2025-01-15T10:35:00Z"
    },
    "current_spectators": [
      {
        "id": "c3d4e5f6-a7b8-9012-cdef-123456789012",
        "name": "Observer1",
        "connected_at": "2025-01-15T10:35:00Z"
      }
    ],
    "reason": "joined"
  }
}

When a spectator leaves or disconnects, a SpectatorDisconnected message is broadcast:

JSON
{
  "type": "SpectatorDisconnected",
  "data": {
    "spectator_id": "c3d4e5f6-a7b8-9012-cdef-123456789012",
    "reason": "disconnected",
    "current_spectators": []
  }
}

On protocol v3 connections both broadcasts are delta events: current_spectators is [] and spectator_count carries the room's total after the change. Members track the roster by applying these deltas to the roster from their RoomJoined, SpectatorJoined, or Reconnected snapshot. Pre-v3 connections receive the full roster and never receive spectator_count.

Leaving Spectator Mode

To leave, send a LeaveSpectator message:

JSON
{
  "type": "LeaveSpectator"
}

The server confirms with a SpectatorLeft response:

JSON
{
  "type": "SpectatorLeft",
  "data": {
    "room_id": "550e8400-e29b-41d4-a716-446655440000",
    "room_code": "HK7T3W",
    "reason": "voluntary_leave",
    "current_spectators": []
  }
}

After the server removes the stored spectator entry, a recovered panic during local detach completion still completes the leave. The server sends the same confirmation and room notification, and records the notification for reconnect replay. Shutdown drain can suppress these messages as it does for an ordinary detach. If the server cannot publish the updated delivery handle after one retry, it closes the spectator connection and still publishes the room notification.

When Spectator Join Fails

If the room does not exist, admission input is invalid, the shared room-admission attempt budget is exhausted, or spectating is not allowed, the server responds with a SpectatorJoinFailed carrying the reason and an error_code (for a missing room, the literal reason is Room not found):

JSON
{
  "type": "SpectatorJoinFailed",
  "data": {
    "reason": "Room not found",
    "error_code": "ROOM_NOT_FOUND"
  }
}

Every JoinAsSpectator is answered by exactly one of SpectatorJoined or SpectatorJoinFailed, so a client can await that pair without a timeout — the same contract JoinRoom has with RoomJoined / RoomJoinFailed. Valid room codes are case-insensitive and normalized before lookup. Invalid game names and room codes use INVALID_GAME_NAME and INVALID_ROOM_CODE; exhausted shared admission budgets use RATE_LIMIT_EXCEEDED.

Use Cases

  • Tournament viewing -- Initialize an audience view without interfering with gameplay, then carry live match state through an out-of-band channel.
  • Coaching -- A coach observes a student's game to provide feedback after the session.
  • Debugging -- During development, attach a spectator to capture the SpectatorJoined room snapshot (current players, spectators, and lobby state) without disrupting the game under test. Live room updates are not delivered to spectators today (see the limitation above).
  • Streaming -- Seed a broadcast overlay from the room snapshot, with live game state supplied by an out-of-band application channel.

Spectator Limits

New rooms get a spectator capacity by default. server.default_max_spectators is null by default, which derives each room's capacity from its player ceiling (2 * max_players). Set a positive value to fix the capacity for every created room, or 0 to restore unlimited spectators (not recommended for multi-tenant deployments). A join past the capacity is refused before any room mutation and surfaces through the terminal SpectatorJoinFailed response carrying the TOO_MANY_SPECTATORS error code.

Next Steps