Go Back Home

The Resistance: real-time social deduction

The Resistance: real-time social deduction

The Resistance is an unofficial digital adaptation of Don Eskridge's social deduction board game. The goal was to let groups of friends play online without physical components: create a room with a code, join from a phone, and play through five missions with hidden roles, votes, and real-time sabotage.

The code lives in two repositories (frontend and backend), deployed at theresistance.alejandroquintana.dev.

Rules and game flow

Two secret teams compete across five missions. The Resistance (majority) can only contribute to success; Spies (minority) know each other and can sabotage missions without being caught.

  • Resistance wins: 3 successful missions out of 5.
  • Spies win: 3 failed missions, or 5 consecutive team rejections by the leader.
  • Players: 5 to 12 per room (official rules encoded in game.rules.ts).

Each round follows a strict finite state machine:

  1. Lobby — waiting for players; the room creator starts the game.
  2. Propose team — the leader picks who goes on the mission.
  3. Vote team — everyone approves or rejects; simple majority decides.
  4. Mission — team members secretly choose success or sabotage.
  5. Reveal — game over; roles and summary are shown.
The Resistance lobby with room code and five players
Lobby on mobile
Lobby · desktop and mobile (5 players connected)

The technical challenge

The UI matters, but the core of the project is distributed game logic:

  • Millisecond sync: every client in a room must see the same phase, mission, and results instantly.
  • Asymmetric information: spies see allies; resistance players do not. The server cannot broadcast full state to everyone.
  • Authoritative server: every action (propose, vote, sabotage) is validated on the backend; clients only render.
  • Mobile and reconnection: WiFi drops or switching to cellular must not ruin a 30–45 minute session.

System architecture

Client-server architecture with in-memory state on the backend (Map<string, Room>). There is no Redis or database: rooms exist while the server process runs, which keeps deployment simple and fits short game sessions among friends.

flowchart TB
  subgraph clients [React SPA clients]
    A[Player A]
    B[Player B]
    C[Player C]
  end
  subgraph server [Authoritative Node.js]
    IO[Socket.io]
    RH[room.handlers]
    GH[game.handlers]
    RS[roomService]
    GS[gameService]
  end
  A -->|WebSocket| IO
  B -->|WebSocket| IO
  C -->|WebSocket| IO
  IO --> RH
  IO --> GH
  RH --> RS
  GH --> GS
  GS --> RS
  GH -->|public game:update| IO
  GH -->|per-socket game:role| IO
        

Tech stack

  • Frontend: React 19, TypeScript, Vite 7, React Router 7, Tailwind CSS 4, Socket.io Client 4.8, Lucide React.
  • Backend: Node.js, Express 5, TypeScript, Socket.io 4.8, CORS and dotenv.

Backend: layers and events

The server organizes logic as socket handlersservicesdomain and rules:

  • roomService — rooms, players, sessionId, socket↔session mapping, kicks, reconnection, and getPublicState().
  • gameService — game FSM: spy assignment, proposals, votes, missions, and win conditions.
  • Handlers: connection.handlers, room.handlers, game.handlers.

Events are centralized in socket.events.ts to avoid magic strings, e.g.:

  • room:create, room:join, room:update
  • game:start, game:update, game:role
  • team:propose, team:vote, mission:act
  • player:disconnected, player:reconnected, player:kick

When the game starts, the backend shuffles roles, stores spy sessionId values in internal state, and sends each player a game:role event on their own socket (with the spy list only if they are a spy or the game has ended).

Frontend: routes and global state

The SPA exposes four main screens via React Router:

  • / — create or join a room.
  • /lobby/:roomCode — waiting, copy code, kick (creator).
  • /game/:roomCode — active game.
  • /reveal/:roomCode — final results.
In-game team voting screen
Team vote · voteTeam phase

SocketContext holds the WebSocket connection, public room state, private role, spy list (when applicable), notifications, and methods (createRoom, joinRoom, startGame, etc.). Hooks such as useGame, useKickPlayer, and useKickedListener encapsulate UI logic.

Notable components:

  • MissionTracker — progress across all 5 missions.
  • TeamSelector and VoteButtons — propose and vote phases.
  • MissionAction and MissionSuspense — action and result reveal.
  • ReconnectionNotification — feedback on connection loss or recovery.
Mission phase with mission tracker
Mission on mobile
Mission in progress · MissionTracker and team actions

Challenges solved

1. Reconnection without losing the game

Problem: on mobile, losing the WebSocket when switching networks is common. Instantly removing the player ruins the experience.

Solution: on join, each client gets a sessionId stored in localStorage (with room code and name). If the socket drops mid-game, the server sets connected: false and waits up to 5 minutes before removal. On reconnect, the client emits room:join with the same sessionId; the server rebinds the socket and resends game:update (public state) and game:role. If the role was lost on the client, game:requestRole is the fallback.

Socket.io is configured with pingTimeout: 300000 (5 min) and websocket + polling transports for unstable networks.

2. Secrets: public state vs. private role

Problem: emitting the full game object would let players inspect network traffic in DevTools and see who the spies are.

Solution: two information channels. Broadcast game:update uses getPublicState(), exposing phase, leader, proposed team, mission results, and who has already acted — but never the spies array or in-progress secret votes/actions. Roles travel only via game:role with io.to(socketId).emit(...), filtering the spy list per recipient.

3. Disconnect in lobby vs. in-game

Problem: the same disconnect event must behave differently by phase.

Solution: in lobby, the player is removed immediately and, if they were creator, the role passes to the next player. In-game, the rest receive player:disconnected and a grace timer starts; only after timeout does the server emit player:removed and clean game state (including removing disconnected spies from the internal array).

4. Disconnected leader

Problem: if the current leader loses connection, the game cannot stall.

Solution: getNextConnectedLeaderIndex() advances the leader index, skipping players with connected: false until an active player is found.

5. Server-side anti-cheat

Problem: a malicious client could send fail on a mission while playing resistance.

Solution: in performMissionAction, if the action is fail and the sessionId is not in state.spies, the server ignores it. Only the current leader can propose a team; only connected players count toward closing votes and missions.

6. Room management (lobby)

Extra features that improve real-world play among friends:

  • Kick player (creator only) with cleanup of votes and team in progress.
  • Change starting leader in lobby before the game begins.
  • Restart game or return to lobby after reveal.
  • 5-character room codes generated on the server.

Encoded rules (example)

Board-game tables are not hardcoded in the UI: they live in game.rules.ts. For example, with 7+ players, the fourth mission requires 2 fails to fail; spy count scales from 2 (5–6 players) up to 5 (12 players).

Conclusion

The Resistance was a key project for mastering server-authoritative architectures, event-driven programming, and real-time APIs without leaning on REST request/response. I learned to separate public and private state, model FSMs on the server, and prioritize reconnection on mobile devices.

A natural next step would be optional stats persistence or Redis-backed rooms for horizontal scaling; for the current scope, in-memory Maps and selective per-socket emission are enough and keep the system simple.