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:
{
"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:
{
"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
StartGameis answered withNOT_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):
{
"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:
{
"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:
The server confirms with a SpectatorLeft response:
{
"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):
{
"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
SpectatorJoinedroom 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¶
- Rooms and Lobbies -- Room lifecycle and the ready-up flow
- Authority System -- How authority works for players
- Reconnection -- Recovering from dropped connections