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:
- Lobby — espera de jugadores; el creador inicia la partida.
- Proponer equipo — el líder de turno elige quién va a la misión.
- Votar equipo — todos aprueban o rechazan; mayoría simple decide.
- Misión — el equipo elige en secreto éxito o sabotaje.
- Revelación — fin de partida; se muestran roles y resumen.
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 socket → servicios → dominio y reglas:
-
roomService— salas, jugadores,sessionId, mapeo socket↔sesión, expulsiones, reconexión ygetPublicState(). -
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.
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.-
TeamSelectoryVoteButtons— fases de propuesta y voto. -
MissionActionyMissionSuspense— acción y revelación de resultado. -
ReconnectionNotification— feedback al perder o recuperar conexión.
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.