Todos los trabajos
En curso2026 — presente

Helix Empire — 10 000 jugadores en un mundo en tiempo real

Una estrategia espacial en tiempo real, basada en navegador, cuyo núcleo es la genética: los jugadores crían castas de criaturas editando ADN. Lo difícil es el motor: 10 000 jugadores en un único mundo compartido en un solo servidor, actualizándose en vivo. Diseñé la arquitectura en torno al verdadero cuello de botella (el tráfico saliente, no la CPU) y lo demostré con una prueba de carga medida: 237.6 Mbit/s con 10k jugadores, el 24% del presupuesto. Hecho en solitario, ahora mismo en alfa.

Rol
Arquitecto-ingeniero (en solitario)
Stack
Rust · WebAssembly · WebTransport / QUIC · Event sourcing · Area of Interest · Hetzner bare-metal
Período
2026 — presente

El problema

Helix Empire es una estrategia espacial en tiempo real, en el navegador. Su núcleo no es construir, sino la genética: un jugador cría castas de criaturas editando su ADN (de ahí «Helix», la doble espiral). Los genes fijan los rasgos, los rasgos deciden cómo rinden las criaturas en distintos entornos, eso mueve la economía y el ejército, lo que mueve la ciencia, que abre nuevas ediciones del genoma. Un bucle cerrado: genes → rasgos → economía → ciencia → laboratorio → de nuevo genes. Fuerza del imperio = genes × planetas × tecnología.

Técnicamente, eso significa miles de jugadores en un mundo compartido, vivo en tiempo real — la economía hace tic, las criaturas se reproducen, las flotas se mueven, los recursos cambian cada segundo — y todo eso tiene que llegar al navegador de cada jugador sin lag.

La mayoría de los juegos online esconden su escala: dividen a los jugadores en salas pequeñas de 20–50, o en shards. Yo me puse un objetivo más difícil a propósito — 10 000 jugadores simultáneos en un único mundo en un solo servidor — porque justo en esa restricción es donde vive la arquitectura interesante.

Los cuatro muros

Di «10 000 en un mundo» en voz alta y aparecen cuatro muros contra los que se estrella un diseño ingenuo.

  1. Difundir todos-con-todos es O(N²). Si cada jugador debe saber de cada otro, un tic son 10 000 × 10 000 = 100 000 000 pares — varias veces por segundo. Físicamente imposible. Duplica los jugadores y cuadruplicas el trabajo.
  2. El coste dominante es el tráfico, no la CPU. El hecho poco intuitivo del hosting: el tráfico saliente (egress) es la parte cara — decenas de veces más caro en los hyperscalers que en bare metal. Si cada tic envía muchos bytes a cada jugador, la factura de ancho de banda mata el proyecto mucho antes que la CPU. Así que la arquitectura tiene que diseñarse en torno a minimizar el tráfico.
  3. Un mundo quiere un servidor. Repartir un mundo entre máquinas obliga a un consenso distribuido (¿qué servidor tiene el estado verdadero?) — lento y complejo. Elegí single-writer: exactamente un proceso puede mutar un mundo. Sin carreras, sin consenso. (Tiene una trampa desagradable — más abajo.)
  4. Un cliente fino no aguanta. Si el servidor calcula todo y el navegador solo dibuja, el servidor es el cuello de botella. El cliente tiene que ser grueso — capaz de reconstruir la imagen a partir de un mínimo de datos.

El stack — elegido contra los muros, no contra las modas

El sentido de la arquitectura es que la tecnología sirve a las restricciones, no a la moda. Cada elección aquí responde a un muro concreto.

Rust + un núcleo determinista compilado a WebAssembly

El núcleo de la simulación está escrito una sola vez en Rust y se compila tanto a un binario de servidor nativo como a WASM para el navegador. El mismo código corre en ambos lados, así que el cliente grueso puede recalcular el mundo a partir de «semillas» compactas y predecir hacia delante — sacando carga del servidor (muros n.º 2, n.º 4). La memoria predecible de Rust y la ausencia de recolector de basura significan más jugadores por servidor.

Por qué: una sola fuente de verdad para la simulación significa que cliente y servidor físicamente no pueden discrepar — la convergencia es estructural, no un test que espero que pase.

WebTransport / QUIC, mundos single-writer, event sourcing

Los frames en tiempo real viajan por WebTransport / QUIC (un flujo binario rápido sobre UDP, sin los bloqueos de cabecera de línea de TCP), con un fallback a WebSocket. Cada mundo tiene un escritor, y su historia es un flujo de eventos — así que no hay consenso dentro del mundo, y el estado se puede reconstruir o auditar a partir de los eventos (muro n.º 3).

Por qué: el single-writer compra consistencia gratis; el event sourcing compra recuperabilidad. El coste es una trampa de contención en las lecturas, que las cachés de read-model de abajo pagan.

Lo que construí, paso a paso — midiendo en cada paso

No lo construí todo de golpe. Fui por capas, midiendo en cada una — porque no puedes optimizar lo que no has medido.

Paso 0 — línea base. Antes de arreglar nada, medí lo mal que estaba: lecturas de estado disparándose a 4–10 segundos, push al cliente a aproximadamente un frame cada 20 segundos. Eso me dio un número a batir.

Pasos 1–3 — matar la contención de single-writer. Aquí está la trampa. Si un proceso muta el mundo, entonces mientras aplica un tic (escribiendo en almacenamiento — lento), todos los lectores esperan en el mismo lock. Abres la pantalla de defensa y te cuelgas 10 segundos porque el servidor está a mitad de guardado. La solución: cachés de read-model lock-free en RAM, actualizadas de forma incremental y leídas sin el lock de escritura.

0.002s
lectura de defensa, era 0.002–4.4s con picos
~0.006s
feed de eventos, era ~0.2s
lectura = escritura
separadas: los lectores ya no esperan al escritor

El movimiento arquitectónico: separar lecturas de escrituras. Un único escritor sigue siendo dueño de la consistencia; los lectores leen proyecciones en RAM y dejan de esperar. La contención desapareció, cada endpoint pasó establemente a sub-segundo.

Pasos 4–5 — enviar la diferencia, no el estado entero. Incluso con lecturas rápidas, cada tic seguía enviando el estado completo — reenviando montañas de cosas que no habían cambiado. La solución: deltas. Un WireSessionStateDelta comprobado matemáticamente donde between(prev, next) construye la diferencia y apply(prev, delta) la reproduce. Un test de convergencia demuestra que una cadena de deltas plegada por el cliente reproduce exactamente el snapshot del servidor — y como cliente y servidor comparten el mismo código Rust, no pueden divergir. ¿Se perdió un frame? El cliente lo nota (ResyncRequired) y pide un snapshot completo.

Paso 6 — Area of Interest recorta O(N²) a lineal. Un jugador no necesita los 10 000 — solo sus vecinos espaciales y diplomáticos. AreaOfInterest + filter_frame recortan cada delta a lo que un suscriptor de verdad le importa, mientras se preserva el contexto a nivel de colonia (chat, comercios). La difusión cuadrática se vuelve lineal: el tráfico crece como N × (tamaño del área de interés), no como N².

Paso 7 — la última milla honesta. Esta es la parte más importante de la historia. Una prueba de carga reveló que el bonito número de «320 Mbit/s» descansaba sobre una suposición fija de 32 bytes por delta de jugador. Conecté el codec binario real y medí: un delta real era de 104 bytes, porque enviaba el perfil entero de un jugador (11 campos de recursos) aunque cambiara un solo campo. 104 frente a 32 es 3.25× de más — una proyección de ~1.04 Gbit/s con 10k. La respuesta honesta en ese momento era: «no, con tráfico real no aguanto 10k».

La solución fue un delta por campo, WirePlayerDelta:

player_id (4 bytes) + máscara de bits de campos cambiados (2 bytes) + SOLO los valores cambiados

Si cambiaron population, food y science, el cable lleva exactamente esos tres más la máscara — 4 + 2 + 4 + 8 + 8 = 26 bytes en lugar de 104. La máscara de 16 bits dice qué campos siguen; un campo sin cambios no cuesta nada.

Cómo está demostrado — y por qué el número es de fiar

Arquitectura sin prueba es una promesa. Lo demostré con números: tests unitarios de round-trip y convergencia, tests de contrato en cada puerto, y 42 tests e2e en Chromium real sobre WebTransport (incluyendo «recibe tics del servidor por un socket en tiempo real sin polling» y «dos jugadores en un mundo se sincronizan en tiempo real»).

La prueba estrella es un test de enjambre de 10 000 bots que mide el egress con el codec binario real — sin constantes fijas. Construye 10 000 clientes bot en un mundo, ejecuta un tic autoritativo real, aplica los deltas visibles de cada bot a través del filtrado AoI real, y suma los bytes medidos.

26 bytes
delta típico de jugador, bajado desde 104
237.6 Mbit/s
egress con 10 000 × 5 tics/s
24%
del presupuesto de 1 Gbit/s — ~76% de margen
≤ 200 ms
latencia de fan-out del tic con 10k

La cifra está defendida por un gate check:load: se verifica un informe en cada ejecución, y la proyección se deriva del valor medido, no de una suposición. Si alguien hincha por accidente el formato del delta, el gate falla.

La frontera honesta

La madurez arquitectónica no es solo conseguir un buen número — es ser honesto sobre sus límites.

Demostrado: por cómputo y latencia, el fan-out a 10 000 bots se mantiene dentro de 200 ms con mucho margen; por egress, 237.6 Mbit/s (24% de un presupuesto de 1 Gbit/s), medido con el codec real, no supuesto.

Aún no demostrado: esto es una proyección a partir de un tic medido con 10k (bots en un proceso), no un clúster en vivo de 10 000 sockets QUIC reales bajo carga durante horas. El comportamiento sostenido de CPU/RAM bajo un flujo de varios minutos, y el canal AoI por suscriptor para el cliente WASM grueso, son la siguiente capa de trabajo. Pero el mayor riesgo — el egress, que yo mismo marqué como por encima del presupuesto en el formato real — está cerrado y medido.

Qué es de arquitecto aquí, no solo de desarrollador

  1. Nombré el verdadero cuello de botella: egress, no CPU. Todo el stack sirve a esa única conclusión. Falla ahí y optimizas lo que no debes.
  2. Separé lecturas de escrituras: single-writer para la consistencia + proyecciones lock-free en RAM para las lecturas. Maté la contención sin sacrificar la corrección.
  3. Reduje O(N²) a lineal mediante Area of Interest — sin ello, 10k es imposible en principio.
  4. Deltas en vez de snapshots, con un solo código base en servidor y cliente, así que la convergencia está garantizada por la arquitectura, no por suerte.
  5. No confié en el número bonito hasta medirlo. Descubrí que «32 bytes» era una suposición, medí los 104 reales, diseñé el delta por campo, lo llevé a 26, y lo demostré midiendo.
  6. Blindé el resultado tras un gate para que una regresión no se cuele sin que nadie lo note.

Un motor que de forma medida sirve a 10 000 jugadores en un mundo al 24% de su presupuesto de tráfico no es suerte ni un solo truco. Es una secuencia de decisiones arquitectónicas, cada una respondiendo a un muro concreto, cada una respaldada por un número.

Snapshot del stack

CapaElecciónMuro que resuelve
Núcleo de simulaciónRust, determinista, compilado a nativo + WASMcliente grueso, más jugadores/servidor (n.º 2, n.º 4)
TransporteWebTransport / QUIC, fallback WebSocketflujo binario de baja latencia (n.º 2)
Modelo de mundoSingle-writer + event sourcingsin consenso, recuperable (n.º 3)
LecturasProyecciones read-model lock-free en RAMlecturas sub-segundo sin contención
ActualizacionesDeltas binarios por campo + Area of InterestO(N²) → lineal, egress mínimo (n.º 1, n.º 2)
HostingHetzner bare-metalegress 20–40× más barato que los hyperscalers (n.º 2)

Helix Empire está en alfa, hecho en solitario — diseño de producto, núcleo de simulación en Rust, cliente WASM, transporte, la separación lectura/escritura, el pipeline de deltas + AoI, el harness de carga y la prueba medida. Pronto se podrá jugar en helixempire.com.