El protocolo que agar.io nunca publicó

1 La pestaña Network está sospechosamente tranquila
Abre agar.io, empieza una partida y saca las devtools antes de morir. Pestaña Network, filtra por XHR y Fetch — ese /api/whatever que devuelve JSON y en el que se apoya prácticamente cualquier sitio del planeta — y observa cómo un juego con miles de cosas en movimiento no te entrega casi nada. Sin polling, sin un endpoint que devuelva un bloque ordenado de estado. Para algo tan vivo, el panel está inquietantemente quieto.
Todo se esconde en la única fila que normalmente pasarías de largo: el WebSocket. Ábrelo y no hay peticiones ni respuestas, solo frames, y los frames no son texto. Columnitas de hex que no significan nada hasta que conoces las reglas.
Ese silencio es la parte interesante. Los círculos son lo fácil: cómete a los más pequeños, huye de los más grandes. La máquina que nunca miras es la buena. Cada blob en tu pantalla es una persona en su propia conexión en algún lugar del mundo, y todas esas pantallas tienen que ponerse de acuerdo, constantemente, sobre dónde está cada cosa — en esos frames, y en ningún otro lugar.
2 Los dos extremos se derretían por hablar en inglés
Hace años construí uno de estos. No agar.io: un primo más pequeño, mi propio juego .io en tiempo real, muchas noches volcadas en un plano lleno de puntos en movimiento. El servidor era Tornado, el framework asíncrono de Python, un hogar natural para unos cientos de conexiones de larga vida que casi no dicen nada pero lo dicen constantemente. Después un incendio se lo llevó. Repositorio perdido, sin backup, sin drama. Lo que quedó fue la forma del problema, que resulta ser la parte que vale la pena guardar.
Hablaba JSON, porque JSON es a lo que uno recurre. En cada tick el servidor serializaba la vista de cada jugador en un bonito grafo de objetos — {"id": 40021, "x": 1234, "y": 9981, "r": 52, "name": "..."} por blob — y lo mandaba por el socket con json.dumps. Un snapshot concurrido rondaba los 2.4 KB por jugador por tick. A 20 ticks por segundo, con la sala llenándose, el event loop empezaba a quedarse atrás alrededor de los 150 jugadores. El movimiento se puso pastoso, y después dio tirones.
Entonces hice lo que hay que hacer antes de arreglar nada, que es demostrar dónde vive el costo. No era la red; a la máquina le sobraba ancho de banda. Era CPU, en los dos extremos. json.dumps masticaba el bucle principal en el servidor, y en el profile del navegador JSON.parse estaba sentado justo en el camino del frame, peleando con el bucle de render a 60fps por los mismos milisegundos. Estaba haciendo que dos máquinas se hablaran en un idioma humano miles de veces por segundo.
Así que dejé de mandar las palabras. Mira en qué se gasta ese snapshot: por cada blob de la pantalla deletrea x, después y, después r, después name, en texto, veinte veces por segundo, a un programa que ya sabe lo que pidió. Los nombres de los campos eran la mayor parte del paquete. El arreglo fue acordar el orden de antemano: tantos bytes para el id, estos dos para la posición horizontal, estos dos para la vertical, y después mandar solo los números, uno pegado al otro, nada entre ellos. Un marcador al frente que diga de qué tipo de mensaje se trata, y después nada de lo que envía vuelve a deletrear x ni name nunca más. El snapshot concurrido bajó más o menos un orden de magnitud, de ~2.4 KB a menos de 200 bytes. La misma información, la misma máquina.
Eso es lo que me devolvió a las devtools. Últimamente he estado dándole vueltas a reconstruir algo así — los pequeños juegos .io con los que sigo experimentando viven en bytes-over-the-wire —, más por curiosidad que por plan, y a lo que volvía una y otra vez era a cómo se ve la buena versión a una escala a la que nunca me acerqué. agar.io es el que todo el mundo ya jugó, así que me puse a leer su protocolo.
float64 a enteros de 32 bits y después de 16 — así que lee esto como la forma del protocolo, no como una afirmación de que alguna build en vivo coincida byte por byte. Los tamaños de paquete son aritmética sobre los anchos de campo documentados: conté registros, no milisegundos, y no hay ningún benchmark en ninguna parte de este post. Las viejas cifras de Tornado son los números más blandos de aquí, recordados de un repositorio que se llevó un incendio, así que tómalos como la forma de un resultado. Los fragmentos ilustran cómo se habla este protocolo; nada de eso es código sacado de nada que se haya publicado.3 La dirección elige el significado
Cada frame, en las dos direcciones, empieza igual: un uint8 en el offset 0, el opcode, diciendo de qué tipo de mensaje se trata. Todo lo que viene después es posicional — anchos conocidos en un orden que ambos extremos acordaron antes de que la conexión existiera, cada número multibyte en little-endian, el byte más bajo primero.
Presiona espacio para dividirte y el mensaje entero es un byte:
// the whole split packet
const SPLIT = 17;
ws.send(new Uint8Array([SPLIT])); // opcode, no payload. that's the message.
Expulsar masa es el opcode 21, un byte. Espectar es 1, un byte. El servidor ya sabe quién eres por la conexión; no necesita que le deletrees {"action":"split"}, y no necesita un payload que acompañe al verbo. Las acciones que un jugador en pánico aporrea con más ganas cuestan lo más barato que puedes poner en un socket.
La entrada que mandas constantemente — dónde está tu mouse — es apenas más grande: opcode 16, después X e Y, después un uint32 que nombra la celda que estás dirigiendo. Las primeras versiones mandaban esas coordenadas como float64, dos doubles de ocho bytes para una posición de mouse en un mapa acotado; las versiones posteriores las encogieron a enteros de 32 y después de 16 bits.
Las tablas son lo bastante pequeñas como para que las dos direcciones reutilicen números, que es el detalle que atrapa a la gente. 16 subiendo es tu mouse; 16 bajando es el snapshot del mundo. 255 subiendo es un reset-connection que lleva una versión de protocolo uint32; 255 bajando es un paquete comprimido que envuelve a otro más grande. Nada dentro del byte te dice cuál es. La dirección elige el significado, y se espera que cada extremo sepa de qué lado de la conexión está parado.
4 Ida y vuelta: la conversación completa
Sin nombres de campo en el protocolo, un cliente desactualizado y un servidor nuevo no fallarían, se corromperían mutuamente en silencio. Así que lo primero que cruza es un handshake de versión, dos paquetes de reset-connection que llevan versiones uint32, y después el apodo como string terminado en null. Solo entonces empieza el stream.
CLIENT SERVER
| |
| ws.binaryType = "arraybuffer" |
| (every frame lands as an ArrayBuffer, never a string) |
| |
| --- 255 reset-connection [u32 protocolVersion] -------> |
| --- 254 reset-connection [u32 clientVersion] -------> |
| --- 0 set-nickname [null-terminated string] ----> |
| allocate the player
| <-- 64 set-border [f64 x1][f64 y1][f64 x2][f64 y2] ----| the map edges
| <-- 32 add-node [u32 cellId] ------------------------| "this one is yours"
| |
| === world snapshots ===|
| <-- 16 update-nodes [u16 eatCount][(u32,u32) x N] |
| [node record][node record]...[u32 0] ----------| YOUR viewport only
| <-- 17 update-position [f32 x][f32 y][f32 zoom] -------| where to point the camera
| |
| --- 16 mouse-move [X][Y][u32 cellId] ----------------> | constantly
| --- 17 split (1 byte) --------------------------------> |
| --- 21 eject (1 byte) --------------------------------> |
| |
| <-- 255 LZ4 wrapper around { 16 | 64 } ------------------| only the fat ones
| <-- 49 leaderboard [rows] -----------------------------| ~1 Hz, nobody's hurry
| |
| between snapshots the client still draws ~60 fps: |
| remote blobs eased between the last two snapshots, |
| my own blob predicted forward from my own mouse |
v v
time timeTodo lo demás aquí es un acercamiento a una de esas flechas.
5 Catorce bytes para un blob, y ninguno dice “x”
La flecha gorda hacia abajo es 16, update-nodes, y resuelve las muertes antes de reportar posiciones: después del opcode viene un conteo uint16 de las celdas recién comidas, después esa cantidad de pares (eaterId, eatenId), y solo entonces los sobrevivientes. Comer no es un radio que da la casualidad de que llega a cero; es una lista de destrucción a la cabeza del paquete, así que el cliente retira un blob y le acredita su masa antes de leer dónde está nada. Después los registros de nodo corren uno pegado al otro hasta que un uint32 cero dice basta.
one node record (16-bit coordinate flavour)
+--------+-------+-------+--------+----+----+----+-------+---------+
| id | x | y | radius | R | G | B | flags | name? |
| u32 | i16 | i16 | u16 | u8 | u8 | u8 | u8 | str* |
+--------+-------+-------+--------+----+----+----+-------+---------+
offset 0 4 6 8 10 11 12 13 14
4 2 2 2 1 1 1 1 0 or n+1
* the name rides along ONLY when a bit in `flags` says it's there.
null-terminated: UTF-16 on protocol <= 5, UTF-8 from 6 on.
no name flag -> 14 bytes flat <-- the steady-state caseCatorce bytes para un blob, y ni uno solo deletrea x. Lo único de longitud variable es el nombre, y aparece solo cuando ese bit de flag admite que hay uno: un apodo sale cuando un cliente se encuentra por primera vez con la celda de ese jugador, y después el bit se queda apagado y el cliente lo busca en lo que ya tiene. Un apodo UTF-8 de 12 caracteres más su terminador son 13 bytes, casi tan grande como el registro que lo lleva, y un jugador dividido es dueño de muchos registros.
Leerlo de vuelta es caminar un offset:
// walking one node record out of the frame
const dv = new DataView(buf);
let o = 1; // offset 0 was the opcode
const id = dv.getUint32(o, true); o += 4; // <-- true = little-endian
const x = dv.getInt16 (o, true); o += 2;
const y = dv.getInt16 (o, true); o += 2;
const radius = dv.getUint16(o, true); o += 2;
const r = dv.getUint8(o++), g = dv.getUint8(o++), b = dv.getUint8(o++);
const flags = dv.getUint8(o++);
// ... name only if (flags & NAME_BIT), then straight into the next record
El offset es el parser. Sin búsqueda, sin delimitador, sin lookup de claves, sin un grafo de objetos entregado después al recolector de basura. Y ese , true en cada getter no es decoración: DataView usa big-endian por defecto y este protocolo es little-endian en todas partes. Olvídalo en un solo getter y cada coordenada vuelve con los bytes al revés. Un blob parado en x = 1234 — 0x04D2, bytes D2 04 — se lee de vuelta como 0xD204, que como int16 con signo es -11772: miles de unidades fuera del mapa, en la dirección equivocada, y todo el campo se colapsa en un borrón diagonal delgado apretujado en una esquina. No es un crash. Un , true por getter y se despliega. El tipo de bug más honesto que existe: los bytes están bien, solo los estás leyendo en la dirección equivocada.
6 Los frames que el servidor nunca mandó
Los snapshots llegan mucho menos seguido de lo que tu monitor dibuja, y el juego igual se ve como sesenta cuadros suaves. Ese hueco es una mentira que te cuenta el cliente, y una que carga peso. Corre dos pistas dentro del mismo frame: los blobs remotos se suavizan entre los dos snapshots más recientes, así que se dibujan más o menos un snapshot atrasados pero perfectamente suaves, mientras que tu propio blob no puede permitirse ir detrás de tu mouse y se mueve en el instante en que tú lo mueves, localmente, y después lo empujan de vuelta hacia la versión del servidor cada vez que el siguiente snapshot no está de acuerdo.
Fíjate en lo que eso le compra al registro de arriba: ningún campo de velocidad por ninguna parte. Nada de lo que envía dice hacia dónde va un blob. El cliente deduce el movimiento restando las dos posiciones que ya tiene.
Esta es la parte que mi viejo juego en Tornado tenía al revés. El movimiento daba tirones, así que mandé más snapshots para suavizarlo, y fundí el event loop más rápido. La respuesta era la opuesta: menos snapshots, más apretados, y dejar que el cliente invente los frames intermedios. La suavidad es trabajo del cliente. El trabajo del servidor es ser autoritativo y callado.
7 El mundo es tu vecindario, no el mapa
Aquí está la decisión que fue invisible en la pestaña Network hasta que noté lo que no estaba en el paquete. Ese stream de update-nodes no es el mundo. Es tu mundo. El mapa completo y todos los que están en él viven del lado del servidor; a cada cliente se le entrega solo la rebanada alrededor de sus propias celdas. Tu snapshot se queda en unas pocas docenas de registros, jueguen diez personas o diez mil, porque escala con lo que cabe en tu pantalla.
+-----------------------------+
| SERVER |
| the whole map: every cell, |
| every food pellet, virus |
+--------------+--------------+
query(x, y, viewExtent) once per tick, per player
+---------------+----------+----------+---------------+
v v v v v
player A player B player C player D player E
one screenful its slice ... ... its slice
(~100 records) (bigger blob = bigger slice)
nobody ever receives the map. everybody receives a neighbourhood.El bucle que lo hace no tiene nada de notable, que es justamente el punto. Aproximadamente lo que hacía mi viejo servidor en Python, de memoria:
# the shape my old Tornado server used, from memory
def on_message(self, data):
op = data[0] # one byte, offset 0, always
if op == MOUSE_MOVE:
x, y, cell_id = struct.unpack_from("<hhI", data, 1)
self.player.target = (x, y)
elif op == SPLIT: # nothing to unpack
self.player.split()
elif op == EJECT:
self.player.eject()
def tick(self): # once per tick, for every player
for p in self.players:
near = self.world.query(p.x, p.y, p.view_extent)
p.send(encode_update_nodes(near))struct.unpack_from con un format string y un offset — < little-endian, hh las dos coordenadas con signo, I el id de la celda — y esa única línea es el decoder entero, porque el layout es el parser en los dos extremos. La línea que importa es world.query: decide qué tan grande va a ser el paquete, y nunca pregunta cuánta gente está en línea.
Empaquetar bytes encoge un mensaje. El culling decide que hay un mensaje pequeño que mandar, sin importar cuán grande se ponga el servidor. El layout del registro es detalle de cada juego. La torre es el motor.
8 Cuenta los bytes, después cuéntalos como JSON
Como cada campo tiene un ancho conocido, puedes sumar un snapshot exactamente en lugar de adivinar. Toma una pantalla ocupada: 76 celdas de jugador más comida y un virus, unos 100 registros. A 14 bytes cada uno en estado estable eso son ~1.4 KB, más el opcode, la lista de pares comidos y el terminador. La misma imagen como JSON compacto — {"i":40021,"x":1234,"y":9981,"r":52,"c":1274} son 45 caracteres, digamos ~40 bytes por registro después de recortar las claves tan a fondo como cualquiera lo haría de forma realista — son ~4 KB.
one busy viewport, ~100 node records
------------------------------------------------
binary update-nodes, steady state ~1.4 KB
the same picture as compact JSON ~4 KB (~2.9x bigger)
at 25 snapshots/s: ~35 KB/s vs ~100 KB/s per clientagar.io comprime encima de eso, pero de forma selectiva: el opcode 255 bajando es un envoltorio LZ4, y solo envuelve los paquetes gordos, el stream de update-nodes y el borde del mapa. Esa selectividad es el diseño. LZ4 no es el compresor más fuerte que hay disponible; es uno de los más rápidos para descomprimir, y el cliente paga ese costo dentro de cada frame que dibuja. Una mejor tasa que tienes que desempaquetar lentamente es un peor trato aquí.
Pero el tamaño era la mitad barata, y no el muro contra el que choqué hace años. Ese fue JSON.parse en el camino caliente: miles de campos por segundo, cada uno compitiendo con el bucle de render, cada parse dejando un grafo de objetos para que el recolector lo barra después como un tirón visible. El texto es un idioma precioso para los humanos y uno silenciosamente caro para las máquinas que tienen que hablarlo constantemente.
9 El patrón debajo del juego
Quítale los blobs y la forma es vieja. El truco de dos pistas — entidades remotas suavizadas entre snapshots, la tuya predicha por delante del servidor — no es la idea ingeniosa de nadie en 2015: QuakeWorld lo lanzó en 1996, enterrando la latencia del dial-up bajo predicción del lado del cliente y compresión delta. Valve escribió la versión moderna para el resto de nosotros: un servidor Source, el motor debajo de Counter-Strike y Team Fortress, envía snapshots de entidades comprimidos por delta contra la última línea base que cada cliente confirmó, y después se apoya en el mismo cliente que predice e interpola. Blizzard dio toda una charla en la GDC sobre el netcode de Overwatch — la misma columna vertebral autoritativa del lado del servidor y cargada de predicción, alrededor de un estricto sistema de entidad-componente. Glenn Fiedler cataloga las tres maneras de llevar una simulación a la red, y agar.io se sienta de lleno en la familia de la interpolación por snapshots, que es como sabes qué esquinas se le permite recortar. La otra rama — mandar las entradas y correr una simulación determinista idéntica en todas partes — es cómo Age of Empires hizo caber un ejército en un módem: “1500 Archers on a 28.8” mandaba los comandos de cada jugador en lugar de las posiciones de mil quinientas unidades, el mismo instinto que el rollback netcode corre hoy en los juegos de pelea, prediciendo la entrada del oponente y rebobinando cuando la suposición estuvo mal.
Y no son solo los juegos. Dejar los nombres de campo fuera del protocolo es lo que automatiza un serializador con esquema: los dos extremos compilan el mismo esquema de Protocol Buffers, así que los bytes llevan valores y nada más, igual que ese registro de nodo lleva un bit de nombre-presente en lugar de la palabra "name". FlatBuffers y Cap’n Proto lo llevan más lejos, leyendo los campos directamente del buffer sin ningún paso de parseo. Un sensor en una radio limitada habla MQTT por la misma razón, y los robots y los stacks de conducción autónoma sobre ROS 2 mueven su mundo por DDS, el bus binario de pub/sub en tiempo real, porque a esas tasas el texto nunca alcanzaría el ritmo. Los sistemas en los que trabajo en finanzas y antifraude corren con el mismo instinto: los datos de mercado de Nasdaq se envían como TotalView-ITCH, un stream secuenciado de mensajes binarios de layout fijo, un libro de órdenes que se mantiene enviando el delta y dejando que cada receptor reconstruya el estado (la versión en lenguaje llano es la entrada más amable). Incluso invierte la elección de agar.io, corriendo en big-endian con precios en enteros de punto fijo, y el punto sobrevive a la inversión: cuando muchas partes tienen que ponerse de acuerdo sobre números que se mueven rápido, el protocolo es el rendimiento.
10 Algunas cosas que voy aprendiendo sobre el tiempo real
- El tipo más pequeño que aún dice la verdad. Una posición son dos
int16, no dosfloat64— el encogimiento que agar.io hizo a lo largo de sus propias versiones. Doce bytes ahorrados por registro, cien registros por frame, veinticinco frames por segundo. - El orden es el esquema. Anchos fijos en offsets fijos significan que el lector nunca busca, nunca parsea, nunca reserva memoria: ~1.4 KB donde JSON quería ~4 KB. Y un bit en un byte de
flagsreemplaza 13 bytes de apodo en cada registro de cada snapshot después del primero, porque el byte más barato es el que ya mandaste. - Guarda el mundo, transmite el sector. Empaquetar encoge un mensaje; el culling decide que haya siquiera un mensaje pequeño, que es por qué el paquete escala con una pantalla y no con la cantidad de jugadores.
- Comprime solo lo que está caliente, y solo con algo que se desempaqueta rápido. LZ4 envuelve los opcodes
16y64y nada más, porque ese costo aterriza dentro de cada frame que el cliente dibuja.
Todavía no he reconstruido el juego que perdí, y leer el protocolo de alguien más no es lo mismo que tener uno propio. Pero lo que parece un juego simple sobre comerse círculos no lo es en absoluto. En algún lugar detrás hay días de alguien sopesando el ancho de un campo contra el presupuesto de un fotograma, decidiendo qué te está permitido ver y qué tan poco puede costar decírtelo, para que todo llegue sintiéndose instantáneo y tú no tengas que pensarlo ni una vez. Esa contención es el oficio. El juego es buenísimo; la ingeniería que hay debajo lo es todavía más, y es la misma lección que mi trabajo diario me sigue enseñando: acordar el orden de antemano, enviar solo el delta, dejar que cada lado reconstruya el estado a partir de lo que ya tiene. Guarda el mundo entero para que nadie más tenga que hacerlo, di solo lo que la persona del otro lado puede ver de verdad, y dilo en la menor cantidad de bytes que aún digan la verdad. El primer juego .io que construí para aprender eso es ceniza. La pestaña que me lo volvió a enseñar sigue estando, hasta donde le concierne a devtools, casi completamente vacía.