feat: Add spectator mode#6
Conversation
Spectators can join a room with only a room code — no name or lane required. They receive full timer state syncs but do not appear in the host's shooter list and cannot trigger reshoots. Changes: - server: accept `role: 'spectator'` in JOIN_ROOM; skip lane check and PEER_JOINED/PEER_LEFT notifications for spectators - SocketClient: send `role` field when joining a room - stores: add `isSpectator` field to roomState initial value - i18n: add spectator translations (no/en) - HomeView: spectator checkbox that hides name/lane inputs; recent rooms history shows 'Spectator' label for spectator entries - App: restore spectator role on page reload/session restore - TimerView: show 'Spectator' badge in top bar for spectator sessions Co-authored-by: GilbN <GilbN@users.noreply.github.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
Pull request overview
Adds a spectator join flow so users can watch a running room without occupying a lane/name, while still receiving timer state syncs.
Changes:
- Add “join as spectator” option on the home join form and persist spectator flag in room storage/history.
- Send
roleon client join and display spectator status in the timer view UI. - Update relay server join/leave notifications so spectators don’t appear in the host’s peer list.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| src/views/TimerView.svelte | Shows spectator badge; persists isSpectator into room history on disconnect. |
| src/views/HomeView.svelte | Adds join-as-spectator toggle; joins with role: 'spectator' and stores isSpectator. |
| src/lib/stores.js | Extends roomState with isSpectator. |
| src/lib/peer/SocketClient.js | Adds role to join payload and stores it on the client instance. |
| src/lib/i18n.js | Adds spectator-related translation strings. |
| src/App.svelte | Restores sessions as client vs spectator based on saved room state. |
| server/index.js | Accepts role on join; suppresses host peer notifications for spectators. |
Comments suppressed due to low confidence (1)
server/index.js:97
ws._roleis always set to'client'even when the joinroleis'spectator'. This makes per-connection role tracking inconsistent and will complicate/undermine any future enforcement based onws._role(e.g., blocking spectator-originated RELAY messages). Set it from the computed role (or remove it entirely if unused).
const peerId = generatePeerId()
room.clients.set(peerId, { ws, name: name || '', lane: lane || '', role: isSpectator ? 'spectator' : 'client' })
ws._roomCode = code
ws._role = 'client'
ws._peerId = peerId
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const isSpectator = role === 'spectator' | ||
|
|
||
| if (!isSpectator && lane && isLaneTaken(room, lane)) { | ||
| send(ws, { action: 'ERROR', reason: 'lane_taken' }) | ||
| return | ||
| } | ||
| const peerId = generatePeerId() | ||
| room.clients.set(peerId, { ws, name: name || '', lane: lane || '' }) | ||
| room.clients.set(peerId, { ws, name: name || '', lane: lane || '', role: isSpectator ? 'spectator' : 'client' }) | ||
| ws._roomCode = code |
There was a problem hiding this comment.
Spectators can still send a non-empty lane in JOIN_ROOM, and because spectators are stored in room.clients, they will make isLaneTaken() return true for that lane and block real shooters from joining (lane poisoning/DoS). For spectators, the server should ignore/overwrite name/lane (e.g., force both to empty strings) and/or update isLaneTaken() to only consider non-spectator clients.
| export const roomState = writable({ | ||
| code: null, | ||
| isHost: false, | ||
| isSpectator: false, | ||
| connectedPeers: [], | ||
| }) | ||
|
|
There was a problem hiding this comment.
roomState now includes isSpectator, but several places in the codebase set/reset roomState with objects that omit this field (e.g. SocketClient/SocketHost destroy() and some updates). Because Svelte stores replace the whole object on set(), omitting isSpectator will turn it into undefined and can lead to inconsistent UI/logic. Update all roomState.set(...) / roomState.update(...) call sites to always include (or preserve via spread) isSpectator.
| export const roomState = writable({ | |
| code: null, | |
| isHost: false, | |
| isSpectator: false, | |
| connectedPeers: [], | |
| }) | |
| export const initialRoomState = { | |
| code: null, | |
| isHost: false, | |
| isSpectator: false, | |
| connectedPeers: [], | |
| } | |
| export function createInitialRoomState() { | |
| return { | |
| ...initialRoomState, | |
| connectedPeers: [], | |
| } | |
| } | |
| export const roomState = writable(createInitialRoomState()) |
| async joinRoom(code, { name = '', lane = '', role = 'client' } = {}) { | ||
| this.code = code.toUpperCase() | ||
| this.name = name | ||
| this.lane = lane | ||
| this.role = role | ||
| this._destroyed = false | ||
| this._roomClosed = false |
There was a problem hiding this comment.
role is now tracked on the client, but the in-memory roomState is still updated as { ...s, code, isHost: false } on JOINED (and later reset on destroy()), so roomState.isSpectator will remain false/undefined even for spectators. Since UI/logic may rely on roomState.isSpectator, propagate role into the store (e.g., set isSpectator: this.role === 'spectator') and ensure destroy() resets it to false.
Adds a spectator mode that lets users watch a running competition without entering a name or lane number. Spectators receive all timer state syncs but are invisible to the host's shooter list and cannot trigger reshoots.
Closes #4
Generated with Claude Code