Skip to content

feat: Add spectator mode#6

Closed
GilbN wants to merge 1 commit into
mainfrom
claude/issue-4-20260409-2147
Closed

feat: Add spectator mode#6
GilbN wants to merge 1 commit into
mainfrom
claude/issue-4-20260409-2147

Conversation

@GilbN
Copy link
Copy Markdown
Owner

@GilbN GilbN commented Apr 9, 2026

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

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>
Copilot AI review requested due to automatic review settings April 9, 2026 21:55
@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 9, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
opk-timer Ready Ready Preview, Comment Apr 9, 2026 9:55pm

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 role on 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._role is always set to 'client' even when the join role is 'spectator'. This makes per-connection role tracking inconsistent and will complicate/undermine any future enforcement based on ws._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.

Comment thread server/index.js
Comment on lines +86 to 94
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
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread src/lib/stores.js
Comment on lines 7 to 13
export const roomState = writable({
code: null,
isHost: false,
isSpectator: false,
connectedPeers: [],
})

Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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())

Copilot uses AI. Check for mistakes.
Comment on lines +25 to 31
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
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
@GilbN GilbN closed this Apr 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add spectator mode

2 participants