← Noticias

Por qué reconstruimos en Go el servidor de backgammon online en tiempo real de Gammon42

Cómo hicimos más rápido nuestro backgammon online multijugador bajo carga: movimos la ruta crítica en tiempo real a un pequeño servicio en Go.

technical 1 de julio de 2026

Todo juego online vive o muere por una sola cosa: cómo se siente cuando tocas una ficha y esperas a que el tablero se mueva. Si ese instante es inmediato, el juego se siente vivo. Si tartamudea —aunque sea un segundo— todo se siente roto, por muy bueno que sea el resto.

Esta es la historia de cómo hicimos ese instante más rápido y estable en Gammon42 —nuestro juego de backgammon multijugador online y gratuito— sacando la parte en tiempo real de nuestro backend del framework que ejecuta el resto de la app y llevándola a un servicio pequeño y hecho a medida, escrito en Go. Está escrita para dos tipos de lectores: si solo quieres saber por qué el juego se siente mejor ahora, la primera mitad es para ti. Si quieres el detalle de ingeniería —las pruebas de carga, la arquitectura, los compromisos— todo eso está en la segunda mitad, claramente señalado. Y si prefieres simplemente jugar backgammon online ahora mismo, adelante: el resto seguirá aquí después.

La versión corta

Nuestro backend era una única aplicación Dart construida sobre el framework Serverpod. Lo hacía todo: cuentas, amigos, torneos, compras dentro de la app, chat… y el propio juego en vivo. Serverpod es genuinamente excelente para esto —permitió que un equipo pequeño lanzara rápido un producto cliente-servidor completo y tipado, y todavía hoy ejecuta la mayor parte de Gammon42—. Pero el juego en vivo es un tipo de trabajo fundamentalmente distinto del resto, y a medida que más gente jugaba al mismo tiempo, ambos empezaron a competir por los mismos recursos. La parte del sistema que más necesita ser rápida —mover piezas en una partida en vivo— compartía carril con todo lo demás.

Así que separamos la parte del juego en vivo en su propio servicio dedicado. Se encarga solo de la ruta crítica en tiempo real: dados, movimientos, estado del tablero y mantener a ambos jugadores sincronizados. Todo lo demás se quedó exactamente donde estaba. El resultado es que una noche ajetreada de emparejamientos ya no ralentiza el momento en que haces un movimiento, y las dos mitades del sistema ahora pueden crecer de forma independiente.

Por qué importa la espera

En una partida de backgammon multijugador online, los mensajes que vuelan entre tu teléfono y nuestro servidor son diminutos y constantes: tiré los dados, esto salió; moví esta ficha; ¿es ese un movimiento legal?; aquí está el nuevo tablero; te toca. Docenas de estos por partida, todos sensibles a la latencia, todos con la necesidad de llegar a ambos jugadores casi al instante: eso es lo que significa el multijugador en tiempo real cuando tiras los dados contra un oponente en vivo.

Esa es una forma de trabajo muy distinta de, digamos, cargar tu lista de amigos o procesar una compra. Eso ocurre de vez en cuando y tolera unos cientos de milisegundos. Los movimientos en vivo no: un retraso de un segundo en un movimiento es la diferencia entre un juego que se siente ágil y uno que se siente con lag.

Cuando un solo sistema se encarga de ambos, el trabajo ocasional-pero-pesado puede interponerse en el trabajo constante-pero-ligero. Eso es exactamente lo que empezamos a ver.


La historia técnica

A partir de aquí, esto entra en la ingeniería. Si eso no es lo tuyo, la conclusión de arriba es lo esencial: el juego es más rápido porque su trabajo más sensible al tiempo ahora se ejecuta por su cuenta.

El punto de partida

El backend original es una aplicación Serverpod escrita en Dart, y vale la pena decirlo con claridad: Serverpod es un gran framework. Nos dio mucho gratis —SDKs de cliente generados, un protocolo tipado compartido entre la app Flutter y el servidor, autenticación, acceso a base de datos, streams WebSocket— y permitió que un equipo pequeño avanzara notablemente rápido. Todavía hoy ejecuta la mayor parte del producto: autenticación, usuarios, amigos, torneos, compras dentro de la app, chat y herramientas de administración, y no tenemos planes de cambiar eso.

El juego en tiempo real corría dentro de ese mismo proceso. El servidor era la autoridad: tiraba los dados, validaba cada movimiento contra las reglas del backgammon, mantenía el estado canónico del tablero y enviaba las actualizaciones a ambos clientes por streams WebSocket. Correcto y limpio, pero compartía un runtime con todos los demás endpoints del producto.

Encontrar el muro

Antes de cambiar nada, queríamos números, no corazonadas. Así que construimos un banco de pruebas de carga que genera jugadores sintéticos que se autentican, entran en el emparejamiento, son emparejados en partidas reales y juegan movimientos aleatorios legales de principio a fin, pasando por exactamente los mismos streams WebSocket que usa un cliente Flutter real. Sin atajos, sin endpoints simulados.

Unas cuantas decisiones deliberadas hicieron que los resultados fueran fiables:

  • Multirregión, hardware separado. El generador de carga corría en un centro de datos (Núremberg) y golpeaba el servidor en otro (Helsinki), a unos 30 ms de distancia. Eso se acerca más a un usuario real que un benchmark en la misma máquina, y garantizaba que el generador de carga no pudiera robar CPU al servidor que estábamos midiendo.
  • Concurrencia con rampa automática. El banco de pruebas duplicaba el número de jugadores concurrentes según un calendario fijo y registraba una instantánea cada cinco segundos: latencia de autenticación, latencia de emparejamiento y latencia de confirmación de movimientos en p50/p95/p99, además de recuentos de errores y de partidas caídas por timeout.
  • Umbrales de ruptura honestos. Definimos “roto” de antemano —confirmación de movimientos p95 por encima de 1,5 s, o tasa de error de movimientos por encima del 2%— para que la prueba declarara el fallo de forma objetiva en lugar de que nosotros miráramos un gráfico a ojo.

La forma de los resultados fue clara. Con baja concurrencia el servidor estaba perfectamente sano: con unas pocas docenas de jugadores, las confirmaciones de movimientos volvían en unos 100–180 ms. Pero a medida que el banco de pruebas subía la rampa —80, luego 160, luego 320 jugadores concurrentes— la ruta en tiempo real se degradaba de forma brusca. La confirmación de movimientos p95 trepaba al rango de varios segundos, la cola p99 se estiraba mucho peor, el emparejamiento se ralentizaba y las partidas empezaban a morir por el timeout de abandono de cuatro minutos: unas pocas al principio, luego docenas. En algún punto alrededor de los 320 jugadores, se dispararon los umbrales de ruptura.

La conclusión importante no era “Dart es lento” ni “Serverpod no puede con ello” —ninguna de las dos es cierta, y ambas ejecutan cómodamente el resto de Gammon42 hasta el día de hoy—. Era algo más simple: un modelo de manejo de peticiones de propósito general, cargando a la vez con todos los endpoints pesados del producto, no es la forma ideal para una manguera de mensajes de juego diminutos y críticos en latencia. Eso es cierto para casi cualquier framework todo-en-uno: la ruta crítica había desarrollado su propio conjunto de requisitos, y necesitaba tanto un modelo de concurrencia afinado exactamente para eso como su propio runtime para dejar de competir con todo lo demás.

La decisión: un sidecar, no una reescritura

El movimiento tentador —reescribir todo el backend— era también el equivocado. La mayor parte de la superficie de Serverpod (cuentas, compras dentro de la app, torneos, chat) funcionaba bien y no tenía nada que ver con el cuello de botella. Reescribirla habría sido un riesgo enorme a cambio de cero beneficio.

En su lugar usamos el patrón sidecar. Construimos un servicio pequeño y separado que posee únicamente la ruta crítica del juego en tiempo real, y dejamos que Serverpod poseyera todo lo demás. Los dos corren lado a lado en el mismo VPS y comparten el mismo Postgres y Redis, así que no hay problema de sincronización de datos entre ellos: solo una división limpia de responsabilidades. El radio de impacto era pequeño, el despliegue podía ser incremental, y si algo salía mal podíamos volver atrás.

Para ese servicio elegimos Go, específicamente por cómo maneja la concurrencia.

La arquitectura: una goroutine por partida

El diseño central es una goroutine por sala de juego.

En Go, una goroutine es una unidad de ejecución concurrente extremadamente ligera —cuesta unos pocos kilobytes, no un hilo completo del sistema operativo— y el runtime de Go multiplexa muchos miles de ellas a través de los núcleos de CPU disponibles. Esa propiedad es lo que hace posible el diseño: cada partida activa obtiene su propia goroutine que posee en exclusiva el estado de esa partida.

Esto compra dos cosas que importan enormemente para un servidor de juego:

  • Sin bloqueos en la ruta crítica. Como una sola goroutine posee el estado de cada sala, cada tirada de dados, movimiento y actualización del tablero de esa partida se procesa de uno en uno por esa goroutine. Otras partes del sistema hablan con una sala enviándole un comando, no metiendo la mano y tocando sus datos. No hay estado mutable compartido por el que competir en la ruta crítica, lo que significa que no hay contención de bloqueos ni carreras de datos: la clase de bugs de concurrencia se elimina por diseño en lugar de a base de depuración.
  • Aislamiento natural. Una sala ocupada o que se comporta mal no puede corromper ni bloquear a otra. Diez mil salas tranquilas cuestan casi nada mientras esperan.

Alrededor de esas goroutines de sala se sitúa un reparto de apoyo ligero:

  • Un hub de salas a nivel de proceso —un simple mapa detrás de un lock optimizado para lectura— que da una búsqueda O(1) de una sala por ID. El lock se mantiene solo durante la búsqueda microscópica; todo el trabajo real por sala ocurre detrás de la propia goroutine de cada sala, así que el hub nunca se convierte en un cuello de botella.
  • Una capa WebSocket no bloqueante. El bucle de lectura de la conexión hace lo mínimo posible: decodifica el sobre de un mensaje entrante y lo entrega a la goroutine de sala o de vestíbulo correcta. El trabajo pesado nunca ocurre en la ruta de lectura del socket, así que una operación lenta no puede atascar la conexión.
  • Un pool de conexiones a Postgres (vía pgx) y un protocolo de red compacto y versionado cuyos tipos de mensaje reflejan deliberadamente los enums de Serverpod. Ese último detalle es lo que permitió que la app Flutter hablara con el nuevo servicio usando el mismo vocabulario compartido que ya conocía: añadimos un cliente de juego dedicado en el lado de la app y pudimos migrar el tráfico en vivo al servicio Go partida por partida, en lugar de en un único cambio arriesgado.

Todo el conjunto se compila en un contenedor diminuto: una compilación Docker multietapa que produce una imagen de runtime distroless. Imagen pequeña, arranque rápido y una huella de memoria modesta, lo que importa cuando comparte máquina con el backend principal.

Por qué esto es más rápido bajo carga, honestamente

Estamos siendo deliberadamente cuidadosos aquí: tenemos una base de referencia rigurosa para la ruta antigua (las pruebas de carga de arriba), y no vamos a citar números precisos de “después” cuyos datos no hemos publicado. Pero las razones por las que el nuevo diseño aguanta mejor bajo concurrencia son estructurales, no ilusiones:

  • Una concurrencia que coincide con la carga de trabajo. Una partida en vivo se modela de forma natural como un actor independiente, de larga duración y dirigido por mensajes. Una goroutine por sala mapea eso uno a uno. Miles de partidas simultáneas se convierten en miles de goroutines baratas que el runtime planifica a través de cada núcleo, en lugar de miles de peticiones haciendo cola a través de un modelo construido para una forma de tráfico distinta.
  • Sin contención en la ruta crítica. Serializar el estado de cada sala a través de su propia goroutine elimina el bloqueo de la parte del sistema que corre con más frecuencia y más importa. Menos sobrecarga de coordinación significa una latencia más predecible, especialmente en la cola p99 donde la ruta antigua dolía más.
  • Aislamiento de recursos. El servicio en tiempo real y el backend principal ya no comparten runtime. Una oleada de emparejamientos en una noche ajetreada no puede dejar sin recursos al código que valida tu movimiento, ni viceversa. Cada uno puede escalarse, perfilarse y razonarse por su cuenta.
  • Un runtime compilado y predecible. Un binario nativo de Go con un recolector de basura de baja latencia maneja de forma uniforme las ráfagas de mensajes diminutos y frecuentes: exactamente el perfil de tráfico del backgammon en vivo.

Qué conservamos, y qué te diríamos que copiaras

La parte con la que estamos más contentos no es la elección del lenguaje: es la contención. No reescribimos un sistema que funcionaba. Identificamos el único componente cuyos requisitos habían divergido de todo lo que lo rodeaba, le dimos a ese componente el runtime que realmente necesitaba, y dejamos el resto en paz. Base de datos compartida, caché compartida, protocolo compatible, migración incremental, retroceso fácil.

Si te llevas una sola cosa de esto: antes de lanzarte a una reescritura, haz pruebas de carga con la honestidad suficiente para encontrar el muro real, y luego mueve solo lo que de verdad está chocando contra él.


¿Quieres sentir tú mismo la diferencia? Puedes jugar backgammon online gratis —sin necesidad de cuenta— contra oponentes reales en partidas multijugador rápidas y fiables, directamente en tu navegador en play.table-online.ro. ¿Prefieres tu teléfono? Consigue Gammon42, nuestro juego de dados de backgammon online, en la App Store o Google Play y tira contra jugadores de todo el mundo.

Que ruedes bien. 🎲

¿Listo para jugar?

Jugar ahora
Jugar ahora