Parte: 7 — Multijugador y networking · Fuente: Valve, "Latency Compensating Methods" (Yahn Bernier, GDC) + documentación de GGPO / rollback ⏱️ Duración estimada: 55 min · Nivel: Avanzado
Resolver la injusticia que genera la latencia en dos escenarios clásicos. En shooters, la lag compensation: rebobinar el mundo al instante en que el cliente disparó para validar el impacto sobre lo que él realmente veía. En juegos de pelea, el rollback netcode: guardar estados, y al llegar un input remoto tardío, rebobinar y re-simular los ticks afectados. Ambos comparten idea: guardar historia y volver atrás. Verás por qué el determinismo es la base del rollback.
Al finalizar, el alumno podrá:
| # | Tema | Por qué importa |
|---|---|---|
| 1 | Injusticia por latencia | Con ping alto disparas a fantasmas del pasado |
| 2 | Historial del mundo | Guardar posiciones pasadas permite rebobinar |
| 3 | Rewind para hit-detection | Validar el tiro en lo que el cliente veía |
| 4 | Restaurar el presente | Tras validar, el mundo vuelve a "ahora" |
| 5 | Rollback netcode | Re-simular ticks al llegar inputs tardíos |
| 6 | Guardado y re-simulación | El coste real del rollback está aquí |
| 7 | Determinismo | Misma entrada + mismo estado = mismo resultado |
Godot 4.x. El servidor mantiene, por cada avatar, un historial de posiciones con marca de tick/tiempo. Para lag compensation trabajamos en el servidor; para rollback ilustramos el ciclo con pseudocódigo GDScript sobre una simulación determinista (usa enteros o pasos fijos, evita floats acumulativos donde puedas). Necesitas dos instancias para el disparo, y entender bien _physics_process como tick fijo. Revisa el paper de Valve "Latency Compensating Methods" y la introducción a rollback de GGPO.
1. El servidor guarda historial por avatar. En cada tick físico, el servidor archiva la posición con su tick.
# En el nodo servidor que gestiona a los jugadores
const MAX_HISTORIAL := 64 # ~1 s a 60 Hz
var historial: Dictionary = {} # id_jugador -> Array de {tick, pos}
var tick_servidor := 0
func _physics_process(_delta: float) -> void:
if not multiplayer.is_server():
return
tick_servidor += 1
for jugador in get_tree().get_nodes_in_group("jugadores"):
var id := jugador.name.to_int()
var buf: Array = historial.get(id, [])
buf.append({"tick": tick_servidor, "pos": jugador.position})
if buf.size() > MAX_HISTORIAL:
buf.pop_front()
historial[id] = buf
2. Recuperar el mundo en un tick pasado. Dada la marca temporal del disparo del cliente, buscamos la posición más cercana en el historial.
func pos_en_tick(id: int, tick_objetivo: int) -> Vector2:
var buf: Array = historial.get(id, [])
var mejor := Vector2.ZERO
var mejor_dif := 1 << 30
for e in buf:
var dif: int = abs(e["tick"] - tick_objetivo)
if dif < mejor_dif:
mejor_dif = dif
mejor = e["pos"]
return mejor
3. Validar el disparo con rewind. El cliente envía su disparo sellado con el tick en el que disparó. El servidor rebobina a los objetivos, comprueba el impacto y restaura.
const VENTANA_MAX_TICKS := 30 # límite anti-abuso (~0.5 s)
const RADIO_HIT := 24.0
@rpc("any_peer", "call_remote", "reliable")
func disparar(origen: Vector2, dir: Vector2, tick_cliente: int) -> void:
if not multiplayer.is_server():
return
# No permito rebobinar más allá de la ventana
if tick_servidor - tick_cliente > VENTANA_MAX_TICKS:
tick_cliente = tick_servidor - VENTANA_MAX_TICKS
var tirador := multiplayer.get_remote_sender_id()
for jugador in get_tree().get_nodes_in_group("jugadores"):
var id := jugador.name.to_int()
if id == tirador:
continue
# Rebobino ESTE objetivo al instante que vio el tirador
var pos_pasada := pos_en_tick(id, tick_cliente)
if _rayo_impacta(origen, dir, pos_pasada, RADIO_HIT):
aplicar_dano.rpc_id(id, 20) # hit validado en el tiempo del cliente
# No hace falta "restaurar" porque no movimos los nodos:
# trabajamos con las posiciones del historial, no con los nodos vivos.
func _rayo_impacta(o: Vector2, dir: Vector2, centro: Vector2, radio: float) -> bool:
var d := dir.normalized()
var t := (centro - o).dot(d)
if t < 0.0:
return false
var punto := o + d * t
return punto.distance_to(centro) <= radio
Nota: aquí validamos contra las posiciones guardadas en el historial, que es más seguro y barato que mover los nodos reales. Si necesitaras física real (raycast del motor), moverías temporalmente los cuerpos a su pasado y los restaurarías tras el
PhysicsDirectSpaceState2Dquery.
4. El ciclo del rollback (pseudocódigo determinista). Para fighting games, el cliente predice el input remoto; al llegar el real, si difería, rebobina y re-simula.
# Estado guardado por tick: estados[tick] = snapshot serializable
var estados: Dictionary = {}
var inputs_confirmados: Dictionary = {} # tick -> {local, remoto}
func avanzar_tick(t: int) -> void:
estados[t] = capturar_estado() # guardo ANTES de simular
var ent := inputs_confirmados.get(t, {"local": leer_local(), "remoto": predecir_remoto(t)})
simular(ent["local"], ent["remoto"]) # paso determinista
func llega_input_remoto(t: int, real) -> void:
var previsto = inputs_confirmados.get(t, {}).get("remoto", null)
inputs_confirmados[t] = {"local": estados_input_local(t), "remoto": real}
if previsto != real:
# ROLLBACK: restauro el estado del tick t y re-simulo hasta ahora
restaurar_estado(estados[t])
for tt in range(t, tick_actual + 1):
avanzar_tick(tt) # re-simulación con el input correcto
5. Compruébalo. Para la lag compensation, con dos instancias y latencia simulada, dispara a un objetivo que se mueve: sin compensación fallarías (el objetivo ya no está donde lo veías); con pos_en_tick el hit se valida donde el tirador lo veía. Para el rollback, imprime cuándo ocurre un rollback y cuántos ticks re-simula.
VENTANA_MAX_TICKS y demuestra que un tick de disparo demasiado viejo se recorta.Time.get_ticks_msec().randf() en simular) y observa cómo el rollback diverge.Implementa lag compensation para un disparo: el servidor guarda el historial de posiciones, recibe el disparo sellado con el tick del cliente, rebobina a los objetivos y valida el impacto, con una ventana máxima anti-abuso.
Criterio de aceptación: con latencia simulada, disparar a un objetivo en movimiento acierta cuando el tirador lo veía sobre la mira, y falla si apuntaba a vacío; un tick de disparo más antiguo que la ventana se recorta al límite; los hits se resuelven en el servidor, nunca en el cliente.
| Síntoma | Causa y arreglo |
|---|---|
| Los tiros fallan pese a apuntar bien | No rebobinas: validas contra la posición actual, no la que veía el cliente |
| Jugadores reciben daño "imposible" | Ventana de compensación sin límite; añade VENTANA_MAX_TICKS |
| El rollback produce estados distintos cada vez | La simulación no es determinista (floats/aleatoriedad). Usa pasos fijos y semillas controladas |
| Caídas de rendimiento con rollback | Re-simulas demasiados ticks; limita la profundidad de rollback |
| Historial vacío al validar | Guardas el historial en el cliente en vez del servidor, o no agrupas los avatares en "jugadores" |
¿Lag compensation y rollback son lo mismo? Comparten la idea de rebobinar. La compensación es del servidor para validar acciones (shooters); el rollback re-simula toda la partida al llegar inputs tardíos (fighting games).
¿No es injusto para el que recibe el disparo? Es un compromiso: se favorece al tirador ("favor the shooter"). La ventana máxima acota el abuso. Casi todos los shooters lo aceptan.
¿Por qué el rollback necesita determinismo? Porque re-simula los mismos ticks; si el mismo input diera resultados distintos, cliente y servidor divergirían irremediablemente.
¿Puedo hacer rollback en Godot? Sí, pero requiere serializar y restaurar todo el estado de juego cada tick y una simulación estrictamente determinista, lo que es exigente. Para muchos juegos, predicción + interpolación bastan.
Clase 146 - Interpolación y extrapolación de entidades