Parte: 3 — Física y matemáticas de juegos aplicadas · Fuente: Gaffer On Games — "Deterministic Lockstep" y Godot 4.x
_physics_process⏱️ Duración estimada: 60 min · Nivel: Intermedio
Entender por qué la física de un juego en red o con replays debe ser determinista —mismas entradas producen exactamente el mismo resultado en cualquier máquina— y cómo lograrlo en la práctica. Verás cómo el paso variable, el orden de actualización y los floats rompen el determinismo, y cómo el paso fijo (_physics_process), un orden de actualización estable y una semilla fija de aleatoriedad lo restauran. Cerrarás grabando y reproduciendo entradas (un replay simple).
Al finalizar, el alumno podrá:
_process) y paso fijo (_physics_process) y sus efectos.| # | Tema | Por qué importa |
|---|---|---|
| 1 | Qué es determinismo | Base de lockstep, replays y anti-cheat |
| 2 | Paso variable vs. paso fijo | El delta inconsistente rompe la simulación |
| 3 | Orden de actualización | Si cambia, el resultado cambia |
| 4 | Floats y su fragilidad | Difieren entre CPUs/compiladores |
| 5 | Enteros / punto fijo | Alternativa determinista al float |
| 6 | Semillas de aleatoriedad | Reproducir el "azar" |
| 7 | Grabación y replay de inputs | Depuración, demos, netcode |
_physics_process(delta) corre a intervalos constantes (p. ej. 60 Hz). Clave: delta es siempre el mismo._process(delta) depende de los FPS. Clave: nunca lo uses para lógica que deba ser reproducible.Necesitas Godot 4.x (godotengine.org). Crea un proyecto 2D con un Node2D raíz. Trabajaremos con la lógica en _physics_process (paso fijo) y compararemos contra hacerlo en _process. Para la aleatoriedad usaremos una instancia propia de RandomNumberGenerator con seed explícita, en vez de las funciones globales, para controlar el estado. Ten a mano la doc de _physics_process. El tick de física por defecto es 60 Hz (Project Settings → Physics → Common → Physics Ticks per Second).
Provocaremos no-determinismo a propósito y luego lo eliminaremos con paso fijo, semilla fija y replay de inputs.
Paso 1 — Demostrar no-determinismo con paso variable. Este script mueve un punto usando _process; el resultado depende de los FPS.
extends Node2D
var pos_variable := 0.0
func _process(delta: float) -> void:
# delta cambia con los FPS: acumulación distinta en cada equipo
pos_variable += 100.0 * delta
# Con floats y delta irregular, dos máquinas divergen con el tiempo
Observable: si limitas los FPS (Project Settings → max_fps) a valores distintos y comparas pos_variable tras muchos frames, los totales difieren por acumulación de error. La lógica de juego no debe vivir aquí.
Paso 2 — Paso fijo determinista. Mueve la misma lógica a _physics_process con un contador de ticks entero como reloj.
var tick := 0
var pos_fija := 0
func _physics_process(_delta: float) -> void:
tick += 1
# Trabajar con enteros (punto fijo) elimina la deriva de floats.
# 100 px/s a 60 Hz = 100/60 -> usamos milésimas de pixel como entero
pos_fija += 1667 # milipixeles por tick (100 px/s aprox.)
if tick % 60 == 0:
print("Tick %d -> pos %d milipx" % [tick, pos_fija])
Observable: independientemente de los FPS de render, tras 60 ticks el valor es exactamente el mismo en cualquier máquina, porque el paso y la aritmética entera son idénticos.
Paso 3 — Semilla fija para aleatoriedad reproducible. Usa un RNG propio sembrado; la secuencia se repite exactamente.
var rng := RandomNumberGenerator.new()
func _ready() -> void:
rng.seed = 12345 # misma semilla -> misma secuencia
var secuencia := []
for i in 5:
secuencia.append(rng.randi_range(0, 99))
print(secuencia) # SIEMPRE imprime los mismos 5 numeros
Observable: ejecutar el juego dos veces imprime idéntica lista. Cambia la semilla y cambia toda la secuencia; sin semilla fija (randomize()) cada ejecución diverge.
Paso 4 — Grabar y reproducir inputs (replay simple). Guarda la entrada por tick y luego re-simula leyéndola en vez del teclado.
enum Estado { GRABANDO, REPRODUCIENDO }
@export var estado: Estado = Estado.GRABANDO
var _replay: Array[Dictionary] = []
var _idx := 0
var jugador := Vector2i(0, 0) # posicion en enteros
func _leer_input() -> Vector2i:
if estado == Estado.GRABANDO:
var dir := Vector2i(
int(Input.is_action_pressed("ui_right")) - int(Input.is_action_pressed("ui_left")),
int(Input.is_action_pressed("ui_down")) - int(Input.is_action_pressed("ui_up")))
_replay.append({"tick": tick, "dir": dir})
return dir
else:
if _idx < _replay.size():
var dir: Vector2i = _replay[_idx]["dir"]
_idx += 1
return dir
return Vector2i.ZERO
func _physics_process(_delta: float) -> void:
tick += 1
jugador += _leer_input() # misma lógica al grabar y al reproducir
Observable: grabas moviéndote unos segundos; al cambiar a REPRODUCIENDO y reiniciar el estado, el jugador repite exactamente el mismo recorrido sin tocar el teclado. Esa es la prueba de determinismo.
FileAccess (JSON) y cárgalo en otra ejecución.checksum (suma de posiciones por tick) y verifica que grabación y reproducción coinciden.randomize() por RNG sembrado en un spawner y comprueba que los enemigos aparecen igual cada partida.id entero antes de procesarlas.Implementa un mini-sistema lockstep local: dos entidades que reciben la misma cola de inputs por tick y deben terminar en posiciones idénticas tras 600 ticks. Incluye un checksum por tick que aborte con un mensaje si las dos entidades divergen.
Criterio de aceptación: tras 600 ticks ambas entidades reportan el mismo checksum en todos los ticks, el sistema usa _physics_process y aritmética entera (o RNG sembrado si hay azar), y al introducir a propósito un randf() sin semilla el checksum diverge y el sistema lo detecta.
| Síntoma | Causa y arreglo |
|---|---|
| El replay se desincroniza con el tiempo | Lógica en _process; muévela a _physics_process |
| Los enemigos aparecen distinto cada partida | Usaste randomize(); siembra un RNG propio con seed fija |
| Dos clientes divergen lentamente | Acumulación de floats; usa enteros/punto fijo en la lógica crítica |
| El orden de daño cambia el resultado | Iteras entidades en orden no fijo; ordénalas por id |
| El replay salta o duplica frames | Grabas por frame de render, no por tick; graba en el paso fijo |
¿Los floats son siempre no deterministas? No siempre, pero pueden diferir entre CPUs, compiladores y órdenes de operación. Para simulaciones que deben coincidir bit a bit entre máquinas, el punto fijo es más seguro.
¿Determinismo significa que no puede haber azar? No: el azar sembrado es determinista. Con la misma semilla y el mismo número de llamadas, la secuencia es idéntica en todos lados.
¿Por qué lockstep envía inputs y no posiciones? Porque los inputs son diminutos y, si la simulación es determinista, cada cliente reconstruye el mismo estado. Ahorra ancho de banda y habilita replays.
¿La física de Godot (RigidBody) es determinista? No se garantiza entre plataformas. Para lockstep estricto se suele implementar una simulación propia y determinista en vez de confiar en el motor de rígidos.
_physics_process): https://docs.godotengine.org/en/stable/tutorials/physics/physics_introduction.htmlClase 083 - Física de partículas y telas (soft bodies)