Determinismo desde el día uno: por qué mi motor de juego usa matemática entera
Rebobinar, guardar y cargar, los replays y los tests reproducibles no son cuatro funcionalidades. Son la misma decisión de arquitectura tomada antes de escribir la primera línea de simulación.
ArquitecturaTypeScriptThree.jsVideojuegosTesting
Tengo dos proyectos que comparten un mismo núcleo raro: un juego de lucha 1v1 en 2.5D y un motor de novelas gráficas. Nada que ver el uno con el otro, salvo la decisión que los define a los dos.
La simulación es determinista desde el primer día. Timestep fijo, matemática entera, estado serializable. Y no como optimización posterior, sino como restricción de partida, antes de que existiera nada que simular.
Es una decisión que parece académica hasta que ves lo que compra.
Qué significa en concreto
Tres reglas, y son inflexibles:
Timestep fijo. La simulación avanza a 60 ticks por segundo, siempre. No hay variable entrando en la física. El bucle de render puede ir a la frecuencia que quiera; la simulación va a la suya.
deltaTime
Matemática entera. Sin coma flotante en el estado de la simulación. Las posiciones, las velocidades y los temporizadores son enteros en unidades fijas. Nada de 0.1 + 0.2.
Estado serializable. El estado completo de la simulación es un dato que se puede volcar, comparar y hashear. Sin referencias a objetos de Three.js, sin closures, sin nada que no sobreviva a un structuredClone.
Lo que sale de ahí es una propiedad muy fuerte: la misma secuencia de comandos produce el mismo estado, bit a bit. Siempre. En cualquier máquina.
Lo que se te regala
Aquí está el retorno, y por eso pago el precio.
Rebobinar. En el motor de novelas gráficas hay una barra de tiempo que se arrastra. No es un "volver a la línea anterior" implementado a mano: es scrubbing continuo a cualquier tick, con snapshots periódicos y un ring buffer. Puedes ir a un punto arbitrario del pasado y seguir desde ahí. Implementar eso sobre una simulación no determinista es, en la práctica, imposible: tendrías que guardar todo el estado en cada frame porque no puedes reconstruirlo.
Guardar y cargar. Si el estado es serializable, el guardado es JSON.stringify. No hay que escribir código de serialización por cada sistema nuevo que añadas, ni acordarse de actualizarlo.
Replays. Si guardas los inputs en vez del estado, el replay de una partida entera pesa unos kilobytes. Y como es determinista, reproducirlo es volver a jugarlo.
Tests reproducibles. Esto es lo que más uso en el día a día. Puedes escribir un test que diga "con esta secuencia de inputs, tras 240 ticks, el estado debe hashear a esto". Ambos proyectos tienen un script golden:update para regenerar esos valores esperados. Cuando un cambio altera el comportamiento, el test falla con un hash distinto y sabes exactamente en qué tick empezó a divergir. Nada de "a veces falla en CI".
Y la puerta abierta al netcode con rollback. Los juegos de lucha online serios funcionan así: predices los inputs del rival, y cuando llegan los reales rebobinas y vuelves a simular. Eso exige determinismo. Si lo dejas para después, no es una funcionalidad que añades: es una reescritura. En el roadmap está como fase futura y sin comprometer, pero no está descartada, y eso es precisamente el valor de haberlo decidido al principio.
Cinco cosas. Y son la misma decisión, no cinco features.
La separación que lo hace posible
Para que esto se sostenga hay una regla de organización que no se negocia: la simulación no conoce la capa de presentación.
En el juego de lucha, el motor es TypeScript puro. No importa Three.js. No importa React. Recibe inputs, avanza ticks, produce estado. Encima va la capa de presentación, que lee ese estado y dibuja. Y el overlay de interfaz es React, pero el game loop vive fuera de React: React no gobierna el tiempo del juego, solo pinta lo que la simulación ya decidió.
En el motor de novelas gráficas la misma idea llevada a un monorepo: packages/engine no depende de packages/renderer. Nunca al revés.
Esa frontera es la que mantiene el determinismo honesto. En el momento en que la simulación pregunta algo al renderer —una altura de texto, un tamaño de viewport, un Date.now()— has metido una entrada no reproducible y lo has perdido todo. Y no lo notas ese día: lo notas semanas después, cuando un test empieza a fallar una vez de cada veinte.
Hay una prueba de esto que me gusta especialmente: en el playground del motor de novelas se puede cambiar el idioma del texto sin que el hash del estado se mueva un bit. El guion guarda claves de traducción, no cadenas; los textos viven en catálogos por idioma que consume la presentación. Si cambiar de español a inglés moviera el hash, sabría que hay texto filtrado dentro de la simulación.
Y hay un botón, "Verificar replay", que vuelca la partida y la reproduce desde cero comprobando que todos los hashes coinciden. Es el determinismo comprobándose a sí mismo, disponible en cualquier momento.
El precio
No es gratis, y conviene ser honesto:
La matemática entera es incómoda. No puedes escribir pos += vel * 0.016. Necesitas unidades fijas y decidir la escala de antemano. Las divisiones hay que pensarlas.
Todo lo no reproducible queda prohibido dentro de la simulación.Math.random() se sustituye por un generador con semilla que forma parte del estado. Date.now() no entra. Nada de I/O.
Necesitas disciplina de fronteras. La tentación de leer "solo un dato" del renderer aparece constantemente.
Cuesta más al principio. Para llegar al primer prototipo jugable hay más andamiaje que si simplemente multiplicas por deltaTime y tiras.
Cuándo lo haría y cuándo no
Lo haría cuando el proyecto necesite cualquiera de estas: rebobinado, replays, multijugador con predicción, o tests de simulación fiables. Si necesitas una, casi seguro vas a acabar queriendo otra.
No lo haría en un juego lineal sin estado que merezca la pena reproducir, ni en algo cuyo bucle sea básicamente animación.
Lo que no haría nunca es dejarlo para más adelante. El determinismo no es una capa que se añade encima: es una propiedad de la que se hereda o no se hereda. Se decide antes de la primera línea de simulación, o no se decide.
De hecho el motor de novelas gráficas heredó esta decisión del juego de lucha directamente. Dos géneros que no tienen nada en común, un mismo cimiento. Eso me dice que el cimiento era bueno.