Files
2026-06-12 21:55:03 +07:00

3.1 KiB

Showdown — room protocol

Contract between the frontend and the backend (Rust, in backend/). The design goal is no database: each room lives entirely in server memory and disappears when its last player disconnects.

Transport

  • One WebSocket per player: GET /ws/room/{roomId} (upgrade).
  • All frames are JSON text messages.
  • The server serves the built frontend (frontend/dist) for every other route, so deep links like /room/lucky-coyote-07 load the SPA.

Room lifecycle

  • A room is created in memory the first time someone connects to its id.
  • Presence is the connection: when a socket closes, that player is removed and a new state snapshot is broadcast (this is why friends vanish when they close the tab).
  • When the last socket closes, the room is deleted. Watchers count as connections (a TV keeps the room alive) but never as players.
  • playerId is generated by the browser and persisted in localStorage. A reconnect with the same playerId replaces the old seat (refresh keeps your identity and your vote).

Client → server messages

{ "type": "join", "playerId": "uuid", "name": "Billy", "emoji": "🤠" } // first message on every (re)connect
{ "type": "watch" }                   // alternative first message: spectator (TV display)
{ "type": "vote", "value": "5" }      // value: deck card, or null to retract
{ "type": "reveal" }                  // anyone may reveal; ignored if no votes yet
{ "type": "reset" }                   // anyone may start a new round
{ "type": "deck", "cards": ["1","2","3"] } // anyone may swap the deck; implies reset

A socket that opens with watch instead of join is a watcher: it receives every state snapshot but never appears in players, and any other message it sends is ignored. The frontend's TV view (/room/{id}/tv) connects this way.

Server → client messages

The server replies to every change (join, leave, vote, reveal, reset, deck) by broadcasting one full-state snapshot to every socket in the room. Snapshots are small (≤ ~16 players), so no deltas are needed.

{
  "type": "state",
  "state": {
    "roomId": "lucky-coyote-07",
    "deck": ["0","1","2","3","5","8","13","21","34","?","☕"],
    "revealed": false,
    "round": 3,
    "players": [
      { "id": "uuid", "name": "Billy", "emoji": "🤠", "voted": true, "vote": null }
    ]
  }
}

Rules the server must enforce:

  • vote is hidden (null) in snapshots until revealed is true — only voted leaks before the showdown.
  • Votes are rejected while revealed is true.
  • reset and deck increment round, clear all votes and set revealed = false. Clients use the round counter to clear local UI state.
  • reveal with zero votes is a no-op.

Server shape (implemented in backend/)

All rooms in one Mutex<HashMap<roomId, Room>>. Each socket is a single async task: it registers (join/watch), subscribes to the room's broadcast channel, then loops — relaying snapshots out and applying client messages in. Disconnect cleanup removes the seat (and the room, when its socket count hits zero). No persistence anywhere.