Un congreso, doce o trece mesas, una tablet por equipo y una pantalla gigante al fondo de la sala. Arranca la pregunta, empieza la cuenta atrás y, a mitad, a alguien se le cae el wifi del móvil. Cuando vuelve, la pantalla ya va por la siguiente fase. ¿Qué ve ese equipo? Si la respuesta es "lo que se quedó antes de desconectarse", el juego está mal construido.
Esto es lo que aprendí montando juegos en directo para eventos: en tiempo real, lo difícil no es mandar mensajes por WebSockets. Lo difícil es decidir quién manda y qué pasa cuando un mensaje no llega.
El servidor es el árbitro
Los clientes de estos juegos (tablets, móviles, pantalla grande) no deciden nada. Solo dibujan el estado que les llega y envían intenciones: "este equipo responde la opción B". Todo lo demás vive en Laravel: en qué fase está la partida, cuándo empezó, qué respuestas se han dado y cuántos puntos tiene cada equipo.
La partida es una pequeña máquina de estados: vídeo, pregunta, revelación, ranking, espera. Y las transiciones por tiempo las calcula el servidor con su propio reloj, no con un temporizador en el navegador de nadie. Por ejemplo, la pasada de pregunta a revelación se dispara cuando se acaba el tiempo o cuando han respondido todos los equipos, con un pequeño margen de gracia para no cortar en seco.
Un detalle que me gusta: cuando se acaba el vídeo que introduce cada pregunta, el cronómetro empieza exactamente en ese instante, aunque la transición se ejecute unos milisegundos después. Se guarda el momento teórico de inicio, no el real. Así nadie gana ni pierde tiempo por cuándo se procesó la petición.
Anti-trampas sin complicarse
Con un servidor autoritativo, las trampas se cortan casi solas:
La respuesta correcta no viaja al cliente mientras la pregunta está abierta. El snapshot incluye el campo, pero a
nullhasta la fase de revelación.Los puntos por rapidez se calculan con los segundos restantes según el reloj del servidor, no con lo que diga el cliente.
Un equipo solo responde una vez por pregunta, y se comprueba en el servidor.
$puntos = $correcta
? (int) round(1000 * ($restante / max(1, $partida->duracion_pregunta)))
: 0;Más puntos cuanto antes aciertas. Y el "antes" lo mide quien no tiene interés en mentir.

Un equipo elige un comodín contra otra mesa mientras la otra responde una pregunta. Ambas pantallas dependen del mismo estado del servidor.
Reverb como vía rápida, no como única vía
Laravel Reverb hace el trabajo de difundir. Cada vez que cambia algo relevante, el servidor lanza un evento con el estado completo de la partida a un canal por partida:
class PartidaActualizada implements ShouldBroadcastNow
{
public function broadcastOn(): Channel
{
return new Channel('partida.'.$this->partidaId);
}
public function broadcastWith(): array
{
return $this->estado;
}
}Fíjate en que no mando "ha cambiado la fase a ranking", sino el snapshot entero: fase, momento de inicio, pregunta, ranking, equipos que han respondido. Un cliente que se reconecta no necesita reconstruir nada a partir de una secuencia de eventos que se perdió: el siguiente mensaje que reciba ya le deja al día. Los eventos son idempotentes casi por diseño.
Eso sí, el WebSocket puede fallar. Por eso broadcast() en el servicio del juego está envuelto en un try/catch que solo deja un aviso en el log. El estado ya está guardado en base de datos antes de difundirlo. Si Reverb se cae, la partida no se rompe; simplemente la vía rápida deja de ser rápida.
La red de seguridad: un poll aburrido
La segunda pata es la menos vistosa y la que más noches buenas me ha dado. Cada cliente, además de escuchar el canal, pide GET /state cada 2,5 segundos. Ese endpoint devuelve exactamente el mismo snapshot que se difunde por Reverb, después de aplicar las transiciones por tiempo pendientes.
const POLL_MS = 2500;
channel.listen('.PartidaActualizada', (data: Estado) => aplicar(data));
const id = setInterval(fetchState, POLL_MS);Los dos caminos acaban en la misma función aplicar. Si llega primero el WebSocket, perfecto, la pantalla cambia al instante. Si se perdió, como mucho en dos segundos y medio el cliente se reconcilia solo. No hay lógica especial de "recuperación": el estado de arranque, el de después de un corte y el de cada cambio normal son el mismo código.
En la parte de Echo también configuré tiempos de espera cortos para detectar conexiones muertas y forzar el reintento cuando el estado pasa a no disponible o a fallido. Es la primera línea de defensa; el poll es la segunda.
El reloj: no te fíes del dispositivo
Queda un problema que se nota en cuanto pones diez tablets distintas: no todas tienen la misma hora. Si calculas la cuenta atrás con Date.now(), cada equipo verá un cronómetro diferente.
La solución es pequeña. Cada snapshot incluye server_time. El cliente guarda la diferencia entre ese valor y su reloj local y la suma siempre que necesita "la hora de ahora". La cuenta atrás se calcula contra el momento de inicio de fase que manda el servidor y se repinta cada 100 ms. Da igual que la tablet vaya adelantada o atrasada: todas ven el mismo tiempo.
Lo que me llevo
Si vas a montar algo en tiempo real, mi receta después de varias ediciones es esta:
Un único dueño del estado, el servidor, guardado antes de difundirlo.
Eventos con el estado completo, no diferencias.
Un poll barato como red de seguridad, usando el mismo código que el evento.
Reloj del servidor para todo lo que tenga que ver con el tiempo.
Un fallo de difusión nunca debe romper la lógica del juego.
No es la arquitectura más sofisticada, y precisamente por eso aguanta una sala llena de gente con wifi de congreso. Puedes ver cómo se ve todo esto en marcha en la página de juegos en directo.
Si tienes un evento en mente y quieres que la gente participe de verdad, cuéntame tu proyecto y vemos qué dinámica encaja.

