Compensación de Lag: Por Qué Tu Disparo Perfecto No Contó
Cómo se implementa la compensación de lag del lado del servidor en shooters: el búfer de retroceso, los números que lo afinan y el punto donde la ventana debe detenerse.
Tres impactos que desaparecieron en la prueba de juego
En febrero realizamos una prueba interna de un prototipo de shooter para 32 jugadores. Un tester se conectó desde Frankfurt con un RTT de 78 ms, y después de cada ronda escribía la misma frase: dos disparos a la cabeza, ninguno contó. Los registros del servidor lo confirmaron. En el momento del disparo, el objetivo estaba justo en el centro de su pantalla, mientras que en el mundo el servidor lo conocía a 1.4 metros a la izquierda.
En multiplayer game development esto no es un error, es lo que hace la latencia. El jugador siempre ve el pasado, el servidor solo conoce el presente. Cualquier shooter que no cierre esa brecha castiga sistemáticamente a los jugadores con ping alto. La penalización escala con el ping, y el jugador lo interpreta como mala puntería en lugar de un problema de red.
Lo molesto es que nunca parece un defecto. Nadie envía un informe de crash, nadie escribe pasos de reproducción; los jugadores simplemente dicen que el juego se siente mal y se van. Hasta que lo mides, lo único que tienes es el texto de la queja.
Así que el primer trabajo fue convertir esa queja en un número. Añadimos un pequeño registro del lado del servidor que registra qué actores atravesó cada rayo de disparo. Para jugadores con más de 40 ms de latencia, el 12 por ciento de los disparos no intersectaron nada en el servidor; para jugadores en la red local la cifra era del 2 por ciento. Esos diez puntos eran latencia no compensada.
Por qué el servidor mantiene un historial de hitboxes
La solución es la lag compensation, basada en retroceso del servidor. Cada tick el servidor escribe las hitboxes de cada personaje en un anillo de búfer, un registro que llamamos FHitboxSnapshot y gestionamos dentro de LagCompensationComponent.cpp. Cuando llega un paquete de disparo, el servidor rebobina esas hitboxes al momento que la pantalla del jugador mostraba, ejecuta la prueba de rayo allí y restaura inmediatamente el presente.
El búfer contiene 64 ticks, lo que a una simulación de 60 Hz equivale a 1066 ms de historial. Un snapshot cuesta 18 cápsulas de 32 bytes, es decir, 576 bytes por jugador, y con 32 jugadores durante 64 ticks eso suma 1.1 MB. Un megabyte por instancia de servidor no es nada comparado con lo que cuestan los impactos perdidos.
Durante el retroceso no movemos todo el mundo, solo los actores cuyo volumen puede intersectar el rayo. Un pre‑filtro de FBoxSphereBounds barrido redujo el número de actores rebobinados en una partida de 32 jugadores a 2.3 en promedio. La primera versión, que restauraba a todos, gastaba 0.9 ms por disparo; después del filtro fueron 0.08 ms.
Cuándo tomas el snapshot importa tanto como lo que almacenas. Nuestra primera versión lo capturaba al inicio del tick, antes de que la animación se actualizara, de modo que en un objetivo que corría, las cápsulas del brazo y la cabeza quedaban un fotograma atrás del mesh. Mover la captura al grupo de tick PostUpdateWork eliminó un desfase sistemático de 6 a 9 cm en objetivos que se movían rápido.
La matemática del RTT y el retraso de interpolación
Cuánto retrocedes no lo decide el ping sino la suma de dos términos: la latencia de un solo sentido y el búfer de interpolación del cliente. La fórmula que usamos es rewind = RTT / 2 + interpolationDelay. Olvidar el segundo término es el error más común que encuentro aquí.
Un cliente retrasa deliberadamente los snapshots entrantes para poder interpolar entre ellos suavemente. Si envías snapshots a 20 Hz, el búfer seguro son dos paquetes, es decir, 100 ms. Para nuestro tester de 78 ms el retroceso correcto era 39 + 100 = 139 ms; la primera versión rebobinaba solo 39 ms, y eso explicaba por sí solo los impactos desaparecidos.
Nunca tomes ese número del cliente. Un cliente modificado puede inflar su propio interpolationDelay para exigir que un disparo se resuelva aún más en el pasado. Usamos el RTT que mide el propio servidor más un búfer fijo derivado de la tasa de snapshots a la que el cliente está suscrito; lo único que el cliente envía es el tick del disparo, y aun eso se limita.
El RTT tampoco es constante, es una serie ruidosa. En lugar de una única muestra promediamos del percentil 25 al 75 de los últimos 20 pongs; un pico solía empujar el retroceso a 300 ms y hacer que los resultados de impacto parecieran aleatorios. En conexiones con más de 15 ms de jitter redondeamos el resultado hacia abajo en lugar de arriba, porque el costo de sobre‑retroceder lo paga el jugador que está siendo disparado.
Dónde debe detenerse la ventana de rebobinado
La limitamos a 200 ms. Por debajo de ese umbral el registro de impactos se comporta como los jugadores esperan; por encima, la latencia se convierte en ventaja y empiezas a eliminar objetivos que ya habían doblado la esquina y están detrás de una pared.
La queja de morir detrás de la cobertura es el precio directo de la ganancia del jugador compensado. Cero compensación castiga al jugador de alto ping, compensación ilimitada castiga al de bajo ping. Para nosotros 200 ms era donde se cruzaban las dos curvas de queja: a 250 ms las quejas de cobertura se duplicaban, a 150 ms la tasa de impactos de jugadores del extranjero caía un 6 por ciento. Mantén el valor en la configuración, porque la escala del mapa y la velocidad del proyectil mueven ese punto de cruce.
La ventana también es una superficie de ataque. Si el tick proviene del cliente, un cliente tramposo puede enviar un tick de hace tres segundos y disparar a la posición antigua del objetivo. ClampRewindTick() verifica tanto el límite absoluto como un promedio móvil construido a partir de los últimos 32 paquetes de ese cliente; si la desviación supera los 60 ms la solicitud se rechaza y el evento se registra.
Luego está el disparo que llega después de la muerte. En el mundo rebobinado el objetivo está vivo, en el mundo presente murió hace 40 ms. Aceptamos eso: si el tirador estaba vivo cuando el paquete llegó al servidor, el impacto se mantiene. La regla opuesta convierte cada bala que dispara un jugador de alto ping en una tirada de dado invisible.
Cómo se relaciona con la predicción y la reconciliación
La compensación de lag no funciona de forma aislada; debe compartir una línea de tiempo con la predicción del cliente. Si el cliente está prediciendo su propio movimiento para el tick N mientras el servidor procesa el tick N‑8, el tick usado para el rebobinado debe tener en cuenta ese desfase. Incorpora ese desfase como una constante y la compensación se desvía en el momento en que la carga del servidor aumenta, sin que nadie pueda decir por qué.
En nuestra compilación ambos funcionan con un solo contador. UPredictedMovementComponent marca un número de tick en cada entrada, el mismo número viaja en el paquete del disparo, y durante la reconciliación del servidor el servidor lo usa para validar el movimiento y rebobinar los hitboxes. En un intento anterior con dos fuentes de tiempo separadas, la deriva de uno a dos ticks producía aproximadamente 30 cm de error de puntería durante strafes rápidos.
No confundas nada de esto con rollback netcode. El rollback rebobina y reproduce toda la simulación, lo cual es asequible en un juego de lucha. En un shooter solo retrocedemos los hitboxes; la física, los proyectiles y el estado del juego siguen avanzando. Cuando probamos rollback completo con 32 jugadores, el tick del servidor pasó de 4.1 ms a 19 ms, lo que puso fin a la discusión.
Cuando el servidor responde es parte de la misma cadena. No puedes deshacer un tracer que el cliente ya dibujó para un disparo que el servidor rechaza, así que enviamos la confirmación del impacto en su propio paquete pequeño y solo dibujamos el hitmarker una vez que el servidor ha aceptado. En una conexión de 78 ms eso significa que el marcador llega 40 ms tarde, lo que sigue generando muchas menos quejas que un marcador que miente.
Por dónde empezar y qué medir
Lo primero que hay que construir es un diagnóstico que puedas observar con tus propios ojos. Una capa DrawDebugCapsule que dibuja tanto los hitboxes presentes como los rebobinados en el momento de un impacto me mostró más que cualquiera de mis suposiciones: alrededor del 70 por ciento de los impactos perdidos provenían de ignorar el buffer de interpolación, y el resto de los hitboxes vinculados a la pose de animación con un cuadro de retraso.
Después de eso, prueba bajo latencia sintética. Con clumsy añadimos 60, 120 y 250 ms de retraso unidireccional y reproducimos el mismo escenario de disparo scriptado 200 veces; si la tasa de impactos se mantiene dentro de una banda del 3 por ciento en los tres perfiles, la compensación está cumpliendo su función. Incluye esa ejecución en la lista de verificación previa al lanzamiento, porque cualquier cambio en el código de movimiento puede romperla.
Almacena cada solicitud de rebobinado rechazada junto con la cuenta que la envió. Ese registro muestra cuatro o cinco cuentas al mes para nosotros, y ninguna de ellas había sido detectada por la detección del lado del cliente. Limitar la ventana no solo mantiene el juego justo, también convierte la detección de trampas en algo que lees de los datos en lugar de adivinar.
Una última nota: explicar la ventana de compensación a los jugadores funcionó mejor que ocultarla. Desde que añadimos una sola línea en la pantalla de muerte mostrando la latencia del asesino, las quejas de cobertura no disminuyeron, pero su tono cambió. La gente dejó de asumir que era trampa.