Volver al Inicio

The Resistance: deducción social en tiempo real

The Resistance: deducción social en tiempo real

The Resistance es una adaptación digital no oficial del juego de mesa de deducción social de Don Eskridge. El objetivo del proyecto fue que grupos de amigos pudieran jugar online sin material físico: crear una sala con código, unirse desde el móvil y vivir las cinco misiones con roles ocultos, votaciones y sabotajes en tiempo real.

El código vive en dos repositorios (frontend y backend), desplegado en theresistance.alejandroquintana.dev.

Reglas y flujo de juego

Dos equipos secretos compiten en cinco misiones. La Resistencia (mayoría) solo puede contribuir al éxito; los Espías (minoría) conocen a sus compañeros y pueden sabotear misiones sin ser descubiertos.

  • Victoria de la Resistencia: 3 misiones exitosas de 5.
  • Victoria de los Espías: 3 misiones fallidas, o 5 rechazos consecutivos del equipo propuesto por el líder.
  • Jugadores: de 5 a 12 por sala (reglas oficiales codificadas en game.rules.ts).

Cada ronda sigue una máquina de estados finita estricta:

  1. Lobby — espera de jugadores; el creador inicia la partida.
  2. Proponer equipo — el líder de turno elige quién va a la misión.
  3. Votar equipo — todos aprueban o rechazan; mayoría simple decide.
  4. Misión — el equipo elige en secreto éxito o sabotaje.
  5. Revelación — fin de partida; se muestran roles y resumen.
Lobby de The Resistance con código de sala y cinco jugadores
Lobby en vista móvil
Lobby · escritorio y móvil (5 jugadores conectados)

El reto técnico

La interfaz es importante, pero el núcleo del proyecto es la lógica distribuida:

  • Sincronización en milisegundos: todos los clientes de una sala deben ver la misma fase, misión y resultados al instante.
  • Información asimétrica: los espías ven a sus aliados; la resistencia no. El servidor no puede emitir el estado completo a todos.
  • Servidor autoritativo: cada acción (proponer, votar, sabotear) se valida en backend; el cliente solo renderiza.
  • Móvil y reconexión: pérdidas de WiFi o cambio a datos no deben destruir una partida de 30–45 minutos.

Arquitectura del sistema

Arquitectura cliente-servidor con estado en memoria en el backend (Map<string, Room>). No hay Redis ni base de datos: las salas viven mientras el proceso del servidor está activo, lo que simplifica el despliegue y encaja con sesiones de juego cortas entre amigos.

flowchart TB
  subgraph clients [Clientes React SPA]
    A[Jugador A]
    B[Jugador B]
    C[Jugador C]
  end
  subgraph server [Node.js autoritativo]
    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 -->|game:update público| IO
  GH -->|game:role por socket| IO
        

Stack tecnológico

  • 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 y variables de entorno con dotenv.

Backend: capas y eventos

El servidor organiza la lógica en handlers de socketserviciosdominio y reglas:

  • roomService — salas, jugadores, sessionId, mapeo socket↔sesión, expulsiones, reconexión y getPublicState().
  • gameService — FSM del juego: asignación de espías, propuestas, votaciones, misiones y condiciones de victoria.
  • Handlers: connection.handlers, room.handlers, game.handlers.

Los eventos están centralizados en socket.events.ts para evitar strings mágicos, por ejemplo:

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

Al iniciar la partida, el backend baraja roles, guarda los sessionId de los espías en el estado interno y envía a cada jugador por su socket un evento game:role con su rol (y la lista de espías solo si es espía o si la partida terminó).

Frontend: rutas y estado global

La SPA expone cuatro pantallas principales vía React Router:

  • / — crear o unirse a sala.
  • /lobby/:roomCode — espera, copiar código, expulsar (creador).
  • /game/:roomCode — juego activo.
  • /reveal/:roomCode — resultados finales.
Pantalla de partida con votación de equipo
Votación de equipo · fase voteTeam

SocketContext concentra la conexión WebSocket, el estado público de la sala, el rol privado, la lista de espías (si aplica), notificaciones y métodos (createRoom, joinRoom, startGame, etc.). Hooks como useGame, useKickPlayer y useKickedListener encapsulan la lógica de UI.

Componentes destacados:

  • MissionTracker — progreso de las 5 misiones.
  • TeamSelector y VoteButtons — fases de propuesta y voto.
  • MissionAction y MissionSuspense — acción y revelación de resultado.
  • ReconnectionNotification — feedback al perder o recuperar conexión.
Vista de partida con tracker de misiones y fase de misión
Misión en vista móvil
Misión en curso · MissionTracker y acciones del equipo

Retos resueltos

1. Reconexión sin perder la partida

Problema: en móvil es habitual perder el WebSocket al cambiar de red. Expulsar al jugador al instante arruina la experiencia.

Solución: al unirse, cada cliente recibe un sessionId persistido en localStorage (junto con código de sala y nombre). Si el socket cae durante la partida, el servidor marca al jugador como connected: false y espera hasta 5 minutos antes de eliminarlo. Al reconectar, el cliente emite room:join con el mismo sessionId; el servidor reasocia el socket, reenvía game:update (estado público) y game:role. Si el rol se perdió en cliente, existe game:requestRole como respaldo.

Socket.io está configurado con pingTimeout: 300000 (5 min) y transportes websocket + polling para redes inestables.

2. Secretos: estado público vs. rol privado

Problema: emitir el objeto completo del juego permitiría inspeccionar la red en DevTools y ver quién es espía.

Solución: dos canales de información. El broadcast game:update usa getPublicState(), que expone fase, líder, equipo propuesto, resultados de misiones y votantes que ya actuaron — pero nunca el array spies ni los votos/acciones secretas en curso. Los roles viajan solo en game:role con io.to(socketId).emit(...), filtrando la lista de espías según el jugador destinatario.

3. Desconexión en lobby vs. en partida

Problema: el mismo evento disconnect debe comportarse distinto según la fase.

Solución: en lobby, el jugador se elimina de inmediato y, si era creador, el rol pasa al siguiente en la lista. En partida, se notifica player:disconnected al resto y se inicia el temporizador de gracia; solo tras el timeout se emite player:removed y se limpia el estado de juego (incluido quitar espías desconectados del array interno).

4. Líder desconectado

Problema: si el líder de turno pierde conexión, la partida no puede quedarse bloqueada.

Solución: getNextConnectedLeaderIndex() avanza el índice del líder saltando jugadores con connected: false hasta encontrar uno activo.

5. Anti-trampas en servidor

Problema: un cliente malicioso podría enviar fail en misión siendo de la resistencia.

Solución: en performMissionAction, si la acción es fail y el sessionId no está en state.spies, el servidor ignora la jugada. Solo el líder actual puede proponer equipo; solo jugadores conectados cuentan para cerrar votaciones y misiones.

6. Gestión de sala (lobby)

Funciones extra que mejoran la experiencia real entre amigos:

  • Expulsar jugador (solo creador) con limpieza de votos y equipo en curso.
  • Cambiar líder inicial en lobby antes de empezar.
  • Reiniciar partida o volver al lobby tras la revelación.
  • Códigos de sala de 5 caracteres generados en servidor.

Reglas codificadas (ejemplo)

Las tablas del juego de mesa no están hardcodeadas en la UI: viven en game.rules.ts. Por ejemplo, con 7+ jugadores la cuarta misión requiere 2 fallos para fracasar; el número de espías escala de 2 (5–6 jugadores) hasta 5 (12 jugadores).

Conclusión

The Resistance fue un proyecto clave para dominar arquitecturas server-authoritative, programación orientada a eventos y diseño de APIs en tiempo real sin recurrir a REST request/response. Aprendí a separar estado público y privado, a modelar FSMs en el servidor y a priorizar la reconexión en dispositivos móviles.

El siguiente paso natural sería persistencia opcional de estadísticas o salas con Redis para escalado horizontal; para el alcance actual, el Map en memoria y la emisión selectiva por socket son suficientes y mantienen el sistema simple.