15 de junio de 2026
Cómo puse 10 000 jugadores en un solo mundo
La mayoría de los juegos online esconden su escala — reparten a los jugadores en salas de 20 o en shards de unos pocos cientos. Para Helix Empire me puse a propósito una meta más dura: 10 000 jugadores en un único mundo compartido, en un solo servidor, en vivo en el navegador. Esta es la historia completa de cómo se construye eso — los cuatro muros con los que chocas, por qué el verdadero cuello de botella es el tráfico y no la CPU, y el momento en que una prueba de carga demostró que mi número bonito era mentira. Es largo, es técnico, y cada afirmación termina en una medición. Las lecciones se trasladan a cualquier sistema de alta carga.
Hago Helix Empire — un juego de estrategia espacial en tiempo real, basado en navegador, cuyo núcleo es la genética: crías castas de criaturas editando su ADN, y esos genes se extienden hacia tu economía, tu ejército y tu ciencia. Pero este post no va realmente del juego. Va del problema de ingeniería que hay debajo, el que me parece genuinamente difícil y genuinamente interesante: meter 10 000 jugadores en un único mundo compartido, en un solo servidor, actualizándose en vivo en cada navegador.
La mayoría de los juegos ni lo intentan. Esconden su escala — salas de 20 personas, o «shards» de unos pocos cientos que nunca se ven entre sí. Yo me puse la meta más dura a propósito, porque es en esa restricción donde vive la arquitectura de verdad, y porque quería demostrar que podía. Esta es la historia entera, contada de forma llana, y termina — como toda ingeniería honesta — en números medidos, no en promesas. Aunque nunca construyas un juego, la forma de este problema aparece en cualquier sistema que tenga que empujar actualizaciones en vivo a mucha gente a la vez.
Dilo en voz alta y aparecen cuatro muros
«Diez mil jugadores en un solo mundo» es una sola frase. En el momento en que intentas construirlo, aparecen cuatro muros contra los que un diseño ingenuo choca de frente.
Muro 1: contarle a todos sobre todos es O(N²). Si cada jugador tiene que saber qué hace cada otro jugador, entonces un ciclo de actualización es cada jugador por cada otro jugador:
10 000 observadores × 10 000 objetos = 100 000 000 pares — por tick
Cien millones de pares, varias veces por segundo. Eso no es «lento», es físicamente imposible. Y es cuadrático: duplica los jugadores y el trabajo se cuadruplica. Cualquier diseño que difunda todos-con-todos está muerto al nacer.
Muro 2: el coste dominante es el tráfico, no la CPU. Este es el contraintuitivo, y es el que lo decide todo. En los hostings en la nube, el tráfico saliente — egress — es el recurso caro, decenas de veces más caro que en servidores bare-metal. Si cada tick envía muchos bytes a cada jugador, la factura de ancho de banda te lleva a la quiebra mucho antes de que el procesador empiece a sudar. Así que el sistema hay que diseñarlo en torno a minimizar lo que sale por el cable, no en torno a la velocidad bruta de cómputo. Esa única constatación reordena todas las demás decisiones.
Muro 3: un mundo quiere vivir en un solo servidor. Reparte un único mundo entre varias máquinas y te has apuntado al consenso distribuido — los servidores discutiendo constantemente sobre qué copia del mundo es la de verdad. Lento, y brutalmente complejo. Así que elegí single-writer: cada mundo tiene exactamente un proceso autorizado a cambiarlo. Sin carreras, sin consenso. Tiene una trampa desagradable escondida dentro, a la que llegaré.
Muro 4: un cliente delgado no aguanta el ritmo. Si el servidor calcula todo y el navegador solo dibuja píxeles, el servidor se convierte en el cuello de botella para 10 000 personas a la vez. Así que el cliente del navegador tiene que ser grueso — lo bastante listo para reconstruir la mayor parte de la imagen él mismo a partir de un poquito de datos.
Cuatro muros. Cada uno mata la solución obvia. La arquitectura no es más que el conjunto de respuestas a estos cuatro, y la disciplina consiste en dejar que las restricciones — no la tecnología más de moda — elijan las herramientas.
El stack, elegido contra los muros
A la gente le encanta elegir tecnología por moda. El trabajo de verdad es lo contrario: nombra primero tus restricciones, luego elige el conjunto más pequeño de herramientas que las responda. Aquí está el mapeo, porque el mapeo es el pensamiento.
La simulación corre en Rust, compilado de dos maneras: un binario nativo para el servidor, y WebAssembly para el navegador. El mismo código, ambos lados. Eso importa más de lo que suena. Como el cliente corre la simulación idéntica, puede reconstruir el mundo a partir de «semillas» compactas e incluso predecir hacia adelante — lo que descarga trabajo del servidor (muros 2 y 4). Y Rust tiene memoria predecible sin pausas del recolector de basura, así que un servidor sostiene más jugadores.
Los frames en tiempo real viajan por WebTransport / QUIC — un flujo binario rápido sobre UDP que esquiva los atascos que sufre TCP bajo pérdida de paquetes (muro 2), con un fallback a WebSocket. Cada mundo es single-writer con event sourcing: un proceso lo muta, y su historia es un flujo de eventos que puedes reproducir para reconstruir o auditar el estado (muro 3). Y se aloja en bare metal (Hetzner), donde el egress es 20–40× más barato que en los hyperscalers (muro 2, otra vez — fíjate con qué frecuencia aparece el muro 2).
Cada una de esas decisiones está al servicio de una sola conclusión: el cuello de botella es el egress y el fan-out de actualizaciones. Acierta con esa decisión y el stack cae solo de ella. Fállala y estarías optimizando con cariño la CPU mientras la factura de ancho de banda te mata en silencio.
Construye en capas, mide cada una
No construí esto en un único empujón heroico. Fui capa por capa, y medí en cada paso — porque la regla cardinal es que no puedes optimizar lo que no has medido. Aquí está la parte que sorprende a la gente: empecé midiendo lo malo que era.
La línea base. Antes de tocar nada, anoté la verdad vergonzosa: leer el estado del juego se disparaba a 4–10 segundos, y las actualizaciones llegaban al navegador a aproximadamente un frame cada 20 segundos. Prácticamente congelado. Pero ahora tenía un número que batir, que es lo único que convierte «se siente lento» en ingeniería.
Matar la trampa del single-writer. ¿Recuerdas la trampa que prometí? Single-writer significa que un proceso cambia el mundo. Pero mientras ese proceso aplica un tick — y un tick escribe en almacenamiento, que es lento — cada lector se queda esperando el mismo lock. Un jugador abre su pantalla de defensa y se cuelga diez segundos, no porque leer sea difícil, sino porque el servidor está justo en mitad de un guardado.
El arreglo es la idea más trasladable de todo este post: separa la lectura de la escritura. Construí cachés sin locks en memoria — proyecciones del mundo listas para servir, actualizadas de forma incremental, que los lectores consultan sin tomar el lock de escritura. El escritor sigue siendo el único dueño de los cambios (así que la consistencia nunca está en duda), pero los lectores dejaron de esperarlo.
Esa separación lectura/escritura no es un truco de juegos. Es el mismo movimiento que hay detrás de las réplicas de lectura, de CQRS y de las vistas materializadas en todas partes — y es el primero al que recurriría en casi cualquier sistema que vaya lento bajo carga.
Enviar la diferencia, no el mundo. Incluso con lecturas instantáneas, cada tick seguía enviando el estado entero — retransmitiendo montones de cosas que no habían cambiado. Pura pérdida sobre el muro 2. Así que cambié a deltas: enviar solo lo que cambió desde el último frame. El tipo delta es pequeño y está comprobado matemáticamente — una función construye la diferencia entre dos estados, otra la reproduce, y un test de convergencia demuestra que una larga cadena de deltas, plegada por el cliente, reproduce el snapshot del servidor exactamente. Como cliente y servidor comparten el mismo código Rust, no pueden divergir. Si alguna vez se pierde un frame, el cliente lo nota y pide un snapshot completo y fresco.
Recortar O(N²) hasta una línea. Esta es la respuesta al muro 1. Un jugador no necesita saber de los otros 10 000 — solo de sus vecinos en el espacio y en la diplomacia. Así que cada delta se recorta al Área de Interés de un jugador: recibes los cambios relevantes para ti, mientras que el contexto compartido como el chat y los intercambios sigue llegando. Eso convierte una difusión cuadrática en una lineal — el tráfico crece con N por el tamaño de tu área de interés, no con N². Sin este paso, 10 000 es imposible sobre el papel. Con él, las cuentas cierran.
El momento en que el número bonito resultó ser mentira
Aquí viene la parte de la que estoy más orgulloso, y es una parte en la que me equivoqué.
Tenía una prueba de carga, y reportaba unos preciosos 320 Mbit/s con 10k jugadores — cómodamente por debajo del presupuesto. Casi me lo creo. Luego miré de dónde salía ese número y descubrí que se apoyaba en una suposición cableada a mano: «asume 32 bytes por actualización de jugador». No una medición. Una suposición que alguien (yo) había tecleado.
Así que enchufé el codificador binario real y medí una actualización de verdad. Eran 104 bytes, no 32. ¿Por qué? Porque estaba enviando el perfil entero de un jugador — once campos de recursos — cada vez, incluso cuando solo cambiaba uno. 104 frente a 32 es 3,25× peor, lo que disparó la proyección hasta cerca de 1,04 Gbit/s con 10k. La respuesta honesta, en ese momento, era la incómoda: «no — con el formato real del cable, no aguanto 10 000».
Ese es el momento que separa a un arquitecto de alguien que entrega una demo bonita. El camino fácil es seguir citando 320. El camino correcto es creerle a la medición, decir en voz alta que el número es malo, y arreglar la cosa de verdad. El arreglo fue un delta por campo: en vez del perfil entero, enviar un id, una diminuta máscara de bits de qué campos cambiaron, y solo los valores de esos campos.
player_id (4 bytes) + máscara de campos cambiados (2 bytes) + solo los valores cambiados
# si cambiaron population, food y science:
4 + 2 + 4 + 8 + 8 = 26 bytes (en vez de 104)
Veintiséis bytes en vez de ciento cuatro. La máscara son dieciséis bits, uno por campo: si el bit está puesto, su valor sigue en el cable; si no, el campo no cambió y no cuesta absolutamente nada.
Pruebas, no promesas
Una arquitectura sin pruebas no es más que una historia contada con seguridad. Así que aquí está la prueba, medida.
Por debajo están las garantías aburridas: tests de ida y vuelta (codifica un delta a bytes, decodifícalo de vuelta, sin pérdida), tests de convergencia (el cliente nunca diverge del servidor), tests de contrato en cada frontera, y 42 tests de extremo a extremo en un navegador Chromium real sobre WebTransport — incluyendo los que de verdad importan para el juego en vivo, como «recibe ticks del servidor por un socket en tiempo real sin polling» y «dos jugadores en un mundo se mantienen sincronizados en tiempo real».
Y el titular: una prueba de enjambre de 10 000 bots que mide el egress con el códec binario real — esta vez sin constantes cableadas. Levanta 10 000 clientes bot en un mundo, corre un tick autoritativo real, aplica las actualizaciones visibles de cada bot a través del filtro de Área de Interés real, y suma los bytes realmente medidos.
Y el número se mantiene honesto: un gate llamado check:load revalida el informe en cada ejecución,
y la proyección se deriva del valor medido en vez de de una suposición. Si algún cambio futuro engorda
por accidente el formato de actualización, el gate falla y la regresión no puede colarse. El guardián
existe precisamente porque ya me había engañado una vez un número que no medí.
La frontera honesta
La madurez no consiste solo en sacar un buen número — consiste en tener claro qué cubre y qué no cubre el número. Así que, llanamente:
Lo que está probado. Por cómputo y latencia, repartir un tick a 10 000 bots se mantiene por debajo de 200 ms con mucho sitio de sobra. Por tráfico, 237.6 Mbit/s con 10k — 24% de un presupuesto de 1 Gbit/s — y eso es una medición real a través del códec real, no una suposición esperanzada.
Lo que aún no está probado. Esto es una proyección a partir de un tick medido con 10k, con bots dentro de un solo proceso — no un clúster en vivo de 10 000 sockets QUIC reales martilleados durante horas. La CPU y la memoria sostenidas bajo un flujo largo de varios minutos, y el canal de actualización por jugador para el cliente grueso del navegador, son la siguiente capa de trabajo. Prefiero decir eso a sobrevenderlo. Lo que importa es que el mayor riesgo — el egress, el que yo mismo había marcado como fuera de presupuesto en el formato real — está cerrado y medido.
Lo que se traslada, aunque nunca construyas un juego
Quita las naves espaciales y la genética, y lo que queda es un conjunto de movimientos que usaría en casi cualquier sistema que tenga que servir a mucha gente a la vez:
- Nombra el cuello de botella real antes de optimizar nada. Aquí era el egress, no la CPU. Todo se siguió de esa única decisión. Los errores más caros se cometen optimizando con belleza el recurso equivocado.
- Separa las lecturas de las escrituras. Un único escritor para la consistencia, más proyecciones de lectura sin locks, mató la contención sin renunciar a la corrección. Este es casi universal.
- Envía diffs, no estados enteros — y si puedes, comparte una sola base de código a través de la frontera para que los dos lados no puedan físicamente discrepar.
- Encuentra el O(N²) y recórtalo a lineal. Casi toda historia de «se cae a escala» tiene una cuadrática escondida dentro. La mía era la difusión; el Área de Interés fue el cuchillo.
- Nunca confíes en un número bonito que no mediste. Mis 320 Mbit/s eran una suposición tecleada que se equivocaba en 3,25×. Todo el resultado dependía de pillar eso y estar dispuesto a decirlo.
- Asegura la victoria detrás de un gate para que una regresión futura dispare una alarma en vez de salir a producción en silencio.
La conclusión
Diez mil jugadores en un solo mundo en vivo es el tipo de meta que suena a bravuconada hasta que la partes en cuatro muros y los respondes uno a uno, midiendo sobre la marcha. El motor lo hace con el 24% de su presupuesto de tráfico, y puedo enseñarte el test que lo demuestra en vez de pedirte que me creas de palabra.
La arquitectura no es un truco ingenioso — es una cadena de decisiones, cada una respondiendo a un muro concreto, cada una respaldada por un número. En eso consiste toda la disciplina: nombra la restricción real, construye hacia ella en capas medidas, y confía en la medición por encima de la historia que querías contar. Helix Empire sale pronto en helixempire.com — y el desarrollo completo de ingeniería vive en el caso de estudio.
Comentarios
Aún no hay comentarios
Inicia sesión para unirte a la conversación.
Sé el primero en compartir una idea.