
Arquitectura WebSocket, explicada con el juego Piedra, Papel o Tijera (Rock, Paper, Scissors)
A veces la mejor forma de entender una tecnología es construir algo que te obligue a usarla. Así fue como un juego de Piedra, Papel o Tijeras me enseñó WebSocket, canales privados con ApiGatewayManagementApi, arquitecturas event-driven con EventBridge y el patrón Observer.
Siempre tuve la duda de cómo funcionaba una API WebSocket. En mi trabajo diario como Cloud Engineer construyo sobre APIs REST o HTTP sin pensarlo dos veces —es lo que conozco, lo que funciona, y raramente tengo un motivo para salir de ahí. Pero WebSocket siempre me generó curiosidad: ¿cuándo realmente lo necesitas? ¿Cómo se comporta en AWS? ¿Qué tan diferente es implementarlo?
La mejor forma que encontré para responderme esas preguntas fue construir algo donde WebSocket no fuera opcional. Un juego multijugador encaja perfecto: todos los jugadores necesitan ver el marcador actualizado en tiempo real, sin polling, sin recargar la página. Nació RockDrop —Piedra, Papel o Tijeras multijugador donde el host crea una sala con un QR y todos se unen escaneándolo.
Si quieres probarlo antes de seguir leyendo: rockdrop.game . Necesitas al menos dos dispositivos (o dos tabs) para jugar.
Lo que sigue es lo que aprendí implementándolo.
¿Por qué no API REST esta vez?
Antes de escribir código, me pregunté por qué no simplemente usar lo que ya sé.
Con HTTP Polling el cliente pregunta al servidor cada N segundos: "¿hay algo nuevo?". Con 8 jugadores preguntando cada segundo son 8 requests/seg de puro ruido. Y la latencia es exactamente el intervalo del poll: si tu oponente envió su movimiento medio segundo después de tu última consulta, esperas otro segundo completo para enterarte. En un juego eso arruina la experiencia.
Server-Sent Events (SSE) fue más tentador. Es más simple que WebSocket y cubre el 80% de los casos de tiempo real. El problema: es unidireccional. El servidor puede enviar eventos al cliente, pero no al revés. En mi caso el servidor necesita saber en todo momento qué jugadores están activamente conectados para no desperdiciar broadcasts en conexiones cerradas. Con SSE esa información solo llega cuando el cliente hace una petición HTTP adicional.
WebSocket resuelve ambos problemas: la conexión es persistente y full-duplex. El cliente y el servidor se hablan en las dos direcciones sobre el mismo canal, sin round-trips adicionales. En AWS eso se traduce en un API Gateway con soporte WebSocket nativo.
Cómo funciona el canal: cada jugador tiene el suyo propio
Aquí está la parte que más me costó entender conceptualmente antes de verla en código.
Cuando un jugador abre la app y se conecta al API Gateway WebSocket, AWS le asigna automáticamente un
connection_id único. Ese identificador es exclusivo de esa conexión —ningún otro jugador puede acceder a él, ni enviarte mensajes directamente a través de él. Es como si cada jugador tuviera su propio tubo privado de comunicación con el servidor.python
1
2
3
4
5
6
7
8
9
10
11
12
# connect.py — se ejecuta cada vez que un jugador se conecta
def handler(event, context):
connection_id = event["requestContext"]["connectionId"]
session_id = query_params.get("session_id")
player_id = query_params.get("player_id")
# Guardamos la "dirección postal" de cada jugador en DynamoDB
players_table.update_item(
Key={"session_id": session_id, "player_id": player_id},
UpdateExpression="SET ws_connection_id = :cid, #s = :s",
ExpressionAttributeValues={":cid": connection_id, ":s": "CONNECTED"},
)El servidor guarda ese
connection_id en DynamoDB. A partir de ese momento sabe exactamente cómo llegar a cada jugador.El papel de ApiGatewayManagementApi
Aquí entra un servicio que no conocía hasta este proyecto:
ApiGatewayManagementApi.En una API REST normal, el servidor responde a una petición que llegó. En WebSocket el servidor necesita poder iniciar comunicación hacia el cliente en cualquier momento, sin que el cliente haya preguntado nada.
ApiGatewayManagementApi es el cliente SDK que te permite hacer exactamente eso desde una Lambda: tomar un connection_id y empujar un mensaje a ese canal específico.python
1
2
3
4
5
6
7
8
9
10
11
12
13
# ws.py — enviar un mensaje a todos los jugadores de una sesión
def broadcast(session_id, event_name, payload):
client = boto3.client('apigatewaymanagementapi', endpoint_url=WS_ENDPOINT)
players = get_connected_players(session_id)
message = json_dumps({"event": event_name, "payload": payload}).encode()
for player in players:
connection_id = player.get("ws_connection_id")
try:
client.post_to_connection(ConnectionId=connection_id, Data=message)
except client.exceptions.GoneException:
# La conexión ya no existe — el jugador se desconectó
mark_player_disconnected(player["session_id"], player["player_id"])post_to_connection es la función clave. Le dices: "al jugador con este connection_id, envíale este mensaje ahora". Cada connection_id es una ruta directa a un canal privado. El servidor puede hacer broadcast iterando todos los jugadores de la sesión y enviando individualmente a cada uno —nadie recibe los mensajes de los demás por accidente, el servidor elige explícitamente a quién le habla.El
GoneException es también importante: si un jugador cerró la app y su conexión ya no existe, AWS lo notifica aquí. Así el servidor mantiene limpio el estado de quién está realmente presente.La arquitectura del juego: qué pasa cuando alguien juega su movimiento
El flujo de una ronda conecta varios servicios de AWS de una forma que considero una buena práctica de arquitectura serverless:
1
2
3
4
5
6
7
8
9
10
11
12
13
Jugador elige ROCK
↓
REST API → Lambda submit_move
→ guarda movimiento en DynamoDB
→ ¿ya jugaron todos? → dispara evento en EventBridge
↓
Lambda resolve_round
↓
broadcast("ROUND_RESOLVED")
↓
ApiGatewayManagementApi envía a cada connection_id
↓
Frontend recibe → UI se actualiza¿Por qué EventBridge y no llamar a resolve_round directamente?
La alternativa directa sería que
submit_move detecte que todos jugaron y llame síncronamente a la lógica de resolución. Funciona, pero crea un acoplamiento fuerte: submit_move necesita conocer a resolve_round, y si algo falla en la resolución el error ocurre dentro del mismo request del jugador.Con EventBridge,
submit_move simplemente publica un evento: "todos los jugadores de esta sesión ya enviaron su movimiento". No sabe ni le importa quién lo procesa. resolve_round está suscrito a ese evento y actúa de forma independiente. Este patrón se llama productor-consumidor y es uno de los pilares de las arquitecturas event-driven, específicamente del patrón EDA (Event-Driven Architecture).Las ventajas concretas en este caso:
- Si
resolve_roundfalla, EventBridge puede reintentar sin afectar asubmit_move - Puedo agregar nuevos consumidores del evento (analytics, notificaciones) sin tocar el código existente
- Los componentes son independientemente desplegables y testeables
Para un juego de fin de semana esto es quizás sobrediseño. Pero en sistemas financieros o de alta concurrencia —que es donde trabajo en mi día a día— este patrón es estándar y vale la pena entenderlo.
El patrón Observer: el concepto que le faltaba al frontend
Como desarrollador backend, hay muchos patrones de frontend que simplemente no están en mi radar. Vivo en el mundo de APIs, bases de datos y arquitecturas serverless —los patrones de UI no son mi día a día.
Un día, mientras scrolleaba Instagram, me apareció un post sobre el patrón Observer. Lo abrí casi por accidente y mientras lo leía tuve ese momento de: espera, esto es justo lo que necesita mi app. El frontend de RockDrop tenía problemas de sincronización que no sabía cómo articular bien —y ahí estaba el concepto que me faltaba para describirlos.
Lo interesante fue lo que pasó después: le pasé esa información a la IA con contexto claro sobre el patrón, y la calidad de las sugerencias cambió por completo. En lugar de intentar describir el comportamiento que buscaba, simplemente dije "implementa esto siguiendo el patrón Observer" —y el resultado fue una solución limpia que hizo que el frontend finalmente funcionara correctamente. Conocer el vocabulario hace que la colaboración con herramientas de IA sea mucho más efectiva.
¿Qué es el patrón Observer? Define una relación donde cuando un objeto cambia de estado, todos los interesados son notificados automáticamente. No tienen que preguntar: se enteran en cuanto ocurre el cambio.
En RockDrop el "objeto que cambia" es el store central de Zustand. Los componentes de React son los "interesados". Cuando llega un evento WebSocket con el resultado de una ronda, el store se actualiza y cada componente que depende de esa parte del estado se re-renderiza solo, sin que nadie lo coordine manualmente.
Zustand: gestión de estado para quien viene del backend
Si vienes del backend y el frontend no es tu terreno principal, Zustand es probablemente el concepto más fácil de adoptar para manejar estado en React.
¿Qué problema resuelve? En una aplicación React con múltiples componentes, compartir información entre ellos es tedioso. La solución nativa (pasar props de padre a hijo) se vuelve un laberinto cuando los componentes están en distintas partes del árbol. Zustand ofrece una tienda global —un objeto JavaScript— que cualquier componente puede leer o modificar directamente.
¿Por qué es relevante para WebSocket? Porque los eventos del servidor no llegan a un componente específico: llegan a la conexión. Zustand actúa como el puente: el hook de WebSocket recibe el evento y llama una acción del store; el store actualiza el estado; los componentes que escuchan ese estado se actualizan automáticamente.
La parte más poderosa es que los componentes pueden suscribirse a slices específicos del estado. No al estado completo:
js
1
2
3
4
5
6
7
8
// Este componente solo se re-renderiza cuando `players` cambia
const players = useGameStore(s => s.players)
// Este solo cuando cambia `gamePhase`
const gamePhase = useGameStore(s => s.gamePhase)
// Este solo cuando cambia `bracket`
const bracket = useGameStore(s => s.bracket)Esto es el patrón Observer en acción: cuando llega
ROUND_RESOLVED, solo los componentes suscritos a las propiedades que cambiaron reciben la notificación. Un componente que solo muestra el código de sesión no se re-renderiza aunque lleguen diez eventos de ronda seguidos.1
2
3
4
5
6
7
8
9
WebSocket recv "ROUND_RESOLVED"
↓
store.roundResolved(payload)
↓
Zustand diff → ¿qué cambió?
├── players ✓ → Leaderboard se actualiza
├── gamePhase ✓ → GamePage se actualiza
├── bracket ✓ → Bracket se actualiza
└── sessionId ✗ → sin cambio → sin re-renderLo que me llevo
Construir RockDrop no fue construir un juego. Fue construir el contexto adecuado para que ciertos conceptos dejaran de ser abstractos.
WebSocket con API Gateway tiene un modelo mental distinto al REST. No hay request-response: hay canales,
connection_ids y un servidor que puede hablar primero. ApiGatewayManagementApi es la pieza que hace posible ese monólogo del servidor, y el GoneException es cómo el sistema se mantiene honesto sobre quién sigue en la conversación.EventBridge como desacoplador convierte una operación síncrona frágil en un evento que puede fallar, reintentar y crecer sin tocar el código existente. En arquitecturas event-driven esto no es elegancia por elegancia: es resiliencia.
El patrón Observer resultó ser algo que ya aplicaba intuitivamente, pero darle nombre y entender su estructura lo hace comunicable. Ahora cuando alguien pregunta cómo sincronizo la UI con eventos del servidor, tengo un vocabulario para explicarlo.
El patrón Observer resultó ser el concepto que le faltaba a mi implementación. Encontrarlo por casualidad y poder dárselo como contexto a la IA fue lo que desbloqueó el frontend.
A veces la mejor forma de entender una tecnología es construir algo que te obligue a usarla correctamente. Para WebSocket, un juego multijugador fue exactamente eso.
¿Construyendo algo serverless o tienes preguntas sobre la implementación? Me encuentras en la comunidad de AWS Community Builders o en linkedin como @jajuarezcruz.
Enjoyed reading this content? Let the author know!
Your likes, comments, shares, and saves help creators reach more builders.
Loading recommendations
Loading article