Real-time transports
Backlog Hold'em provides two ways to perform the same room workflow.
HTTP commands with SSE events
This is the frontend default.
- Commands are sent as HTTP
POSTrequests. EventSourceopensGET /events/rooms/{code}.- The server sends an authoritative snapshot immediately.
- Every committed mutation publishes another complete public snapshot.
- A keepalive comment is sent every 15 seconds.
If the first SSE room event does not arrive within five seconds, the frontend closes the stream and falls back to WebSocket for the current session. Waiting for an event, rather than only the connection's open signal, also detects intermediaries that accept but buffer the stream. This runtime fallback does not overwrite the user's saved transport preference.
An SSE event looks like:
id: 7
event: room
data: {"code":"A1B2C3","version":7,...}The event ID is the room event-stream sequence. The transport sends a fresh snapshot on connection. Durable domain events are retained for server-side audit/replay, but are not exposed as SSE events because they can contain unrevealed votes.
Bidirectional WebSocket
The frontend connects to:
/ws/rooms/{code}The same-origin WebSocket handshake automatically includes the room's HTTP-only participant cookie. The credential does not appear in the URL, and cross-origin upgrades are rejected.
Commands use a correlation ID:
{
"kind": "command",
"type": "vote",
"id": "client-generated-uuid",
"payload": { "value": "5" }
}Supported mutation types are vote, reveal, reset, topic, demo-team, and end. HTTP/SSE clients use the matching POST /api/rooms/{code}/{type} endpoint.
The server responds with a correlated acknowledgement:
{
"kind": "ack",
"type": "vote",
"id": "client-generated-uuid",
"room": { "code": "A1B2C3", "version": 7 }
}Errors use kind: "error" and an error string. Independent room changes use kind: "event"; the initial state uses kind: "snapshot".
Shared semantics
Both modes require a valid room participant cookie and invoke the same application methods. WatchRoom subscribes before authenticating and reading the initial state, then filters duplicate versions. This prevents a read/subscribe handoff gap while keeping authorization shared by both adapters. Neither adapter receives credentials, unrevealed vote values, current-round revision counts, or completed history. Named spectators use their room identity with either transport and receive the same snapshots, while the shared application rules reject spectator vote commands. An ended session's summary remains available to room participants.
Event hub behavior
Each process maintains subscribers by room code. SQLite publishes local commits directly. PostgreSQL first receives a committed database notification and reloads the current projection. Each subscriber has a one-item channel. Publication is non-blocking; a newer snapshot replaces an unread older snapshot. WebSocket connections close when their separate outbound queue cannot keep up, allowing reconnect to recover the latest state.
Scaling boundary
SQLite uses a process-local hub and supports one backend process. PostgreSQL uses expected-version transactions to prevent lost updates and transactional LISTEN/NOTIFY invalidations so every replica reloads and publishes committed room changes to its own connections. Notification listeners reconnect after connection failures. Notifications need not be durable because initial/reconnected streams always read the current projection; the event log remains the durable record.
Presence is still connection-local. A future feature that displays online/offline presence would require a separate distributed presence model.