DevLog #1: ChangeSet

He decidido, durante lo que aguante mi motivación, escribir un DevLog enfocado en la exploración y explicación de mecánicas e ingeniería de software aplicada a motores gráficos y motores de videojuegos.

En este primer DevLog, hablaré de los desafíos de sistemas vivos de alta actualización y la representación reactiva de su estado en la interfaz de usuario. Como de costumbre, el objetivo es la separación de responsabilidades en busca de la testabilidad y sostenibilidad.

Reactividad en sistemas complejos de alta frecuencia de modificación no determinista

Imagina que un sistema vivo muta su estado cada 16ms (60 veces por segundo). Podrías diseñar un sistema de representación que en cada actualización del estado, renderice (dibuje) el nuevo estado. No tiene mucho problema, pero añadamos dificultades:

¿Y si cambia el estado 300 veces por segundo?, ¿o de forma irregular?

Esto es muy poquito para un procesador. Pero en un sistema de vista reactiva, cada cambio de estado es registrado. Esto no significa que cada cambio sea inmediatamente actualizado en vista, pintado en pantalla. No se puede dibujar 300 veces por segundo ni tiene sentido, eso sí sería muy caro, especialmente hablando de elementos web.

Pero aunque no se dibuje, sigue teniendo un precio. Seguimos registrando ese cambio. React, Vue, Svelte, etc. tienen un sistema de microtareas y microbatching que junta todos los cambios finales y solo pinta una vez por fotograma, adaptado a demanda del navegador, pero sigue teniendo en cuenta el cambio.

Aunque el DOM no sufra, el motor de reactividad sí. Cada mutación obliga al framework a marcar variables como “sucias”, evaluar dependencias y encolar microtareas. Ejecutar este ciclo cientos de veces por segundo para valores intermedios que el usuario jamás llegará a ver en pantalla es lo que conocemos como over-triggering.

¿Y si solo cambian unas pocas partes y no todo el sistema?

Podrías pensar: “pero Álex, en un sistema reactivo no nos importa, suelen tener granularidad a nivel de variable, exceptuando React que apesta por todos lados con su programación funcional, sus modelos inmutables y renderizados completos por defecto…”

Dificultad: ¿Dónde tienes esa reactividad? Los frameworks de vista, son frameworks de vista, valga la redundancia. Si quieres aprovechar sus mecanismos de reactividad para controlar el estado del sistema, estarás acoplando tu sistema a un framework de vista.

(Hago un inciso para recordar que el sistema de reactividad de Vue, @vue/reactivity, está desacoplado y puede usarse con otros fines más allá de la vista)

El principal problema, como siempre, cae en la testabilidad. Quieres poder probar tu sistema sin necesidad de estar cargando dependencias de terceros con una responsabilidad que ni siquiera estás testando (representación).

Ilustración

Este ejemplo no usa ninguna solución de las planteadas en este artículo, es una mera “ilustración”. Tenemos algo que cambia su valor muy rápidamente. Además de una representación mucho más costosa, también podría ser completamente innecesaria. De hecho, en este caso destruye la experiencia de usuario, ni siquiera puede leerse correctamente los valores con esa tasa de refresco.

Tiempo real

Actualización controlada

Ambos usan los mismos datos, solo se “pintan” a intervalos diferentes. Uno a tiempo real y otro cada dos segundos. Ambos tienen en cuenta su valor anterior según su intervalo para determinar si es subida o bajada. El lento no actualiza su valor si el valor es el mismo que hace dos segundos (sin cambios relativos).

ChangeSet

Un ChangeSet, en este contexto entendido como estructura de datos, representa un conjunto de cambios. Quizás simples etiquetas o IDs de cosas que han cambiado. En forma más básica, no importa cuántas veces desde la última vez que se revisó el ChangeSet, solo se anota si ha cambiado, mantiene una naturaleza idempotente frente a múltiples cambios.

Es en definitiva, un set, de strings habitualmente. Pueden ser emitidos por un sistema de publicación/subscripción (pub/sub), siendo tratados como ValueObject o el mismo ChangeSet ser en sí mismo dicho sistema, siendo un objeto mutable, subscribíble y publisher. Cada cual con la sobrecarga de responsabilidad que le guste más, ahí no me meto…

No tiene por qué almacenar detalles (payloads), no es un sistema de eventos al uso, ni un command bus, ni cosas raras que puedas asociar a una arquitectura basada en eventos. No tiene temporalidad, no tiene por qué saber cuándo se mutó.

En esta forma primitiva normalmente no es apto para sistemas de información y en red. Tampoco es ideal en sistemas asíncronos. Cuando se “emite” un ChangeSet, se asume que todos los interesados tienen acceso al estado real, a la única fuente de verdad (que en realidad puede ser una copia de la fuente de verdad, a fin de evitar modificaciones).

El ChangeSet primitivo almacena cambios, sin más, y los emite de golpe (y se limpia), cuando decidamos, a un ritmo que decidamos. ¿Qué tiene de diferente de los dirty flags o sistemas similares de seguimientos de cambios como los que tenemos en frameworks de vista? En esencia, nada. La única diferencia clave es que lo controlamos nosotros, y esto, entre otras, nos obliga a registrar los cambios de forma explícita.

Esto de por sí, a pesar de ser otra responsabilidad y una complejidad añadida, es un fuerte plus: nos obliga a definir y controlar muy bien todos los puntos de mutación del estado. Cuando es un rollo mutar cosas, te aseguro que nadie quiere estar duplicando ese rollo en otros lados, evita muchas sorpresas.

export const addResource = (resource: ResourceID, amount: number) => {
    const previousValue = resourcesState.resources[resource]
    const capacity = resourcesState.baseCapacity[resource]
    const newValue = clamp(previousValue + amount, 0, capacity)
    if (previousValue == newValue) { return }
    
    resourcesState.resources[resource] = newValue
    resourcesChangeSet.addChange('altered')
    resourcesChangeSet.addChange(`altered.${resource}`)
}
Código primitivo y muy explícito para añadir cambios a un ChangeSet. Se puede añadir mucha magia para que automáticamente al mutar el estado, el ChangeSet también lo registre (mediante proxy mismo), pero aumenta la sorpresa (acciones ocultas) y además, ya no daría miedo mutar fuera de este método, y el miedo previene daños :).

Podemos tener numerosos ChangeSets, quizás por temáticas (fomentando el slicing). Ayuda a que un componente de vista solo tenga que subscribirse al ChangeSet que le interesa. Nos permite ajustar la granularidad a nuestro gusto en lugar de tener una granularidad completa por defecto.

Quizás también te preguntes por qué querríamos tener menos granularidad, si al final necesitaríamos tirar de preguntas (if) en muchos casos para ver qué ha cambiado. Con el tiempo, verás que las preguntas son una herramienta increíblemente poderosa para dejar muy claro la intención del código. Más que verlas como un coste computacional o un error de novato, empieza a verlas como una gran pista de lo que se pretende hacer y una drástica disminución de la sorpresa, de la sobreingeniería o de la complejidad accidental.

Caso de uso en idles

No tiene sentido un DevLog sin Dev. Estoy en medio del desarrollo de un motor custom para un juego tipo idle con gestión de recursos y muchos otros sistemas complejos. Puede tener cientos de módulos independientes que se actualizan cientos de veces por segundo, y debe ser completamente testable sin dependencias de la capa de vista.

En un primer momento utilizaba Svelte como vista, y todo el estado del juego era reactivo mediante la propia reactividad del framework. No funcionaba mal, pero estaba tomando cada vez más decisiones de diseño que no quería tomar, basadas en el hecho de que no tengo control sobre el ciclo de actualización de los elementos del DOM, y por supuesto, acoplando el núcleo del motor a una biblioteca externa.

Pero soy un yonqui de Svelte y su sistema de runas. Quería una vista en Svelte y seguir disfrutando de diseño basado en componentes reactivos, así que tenía que envolver de alguna manera el estado del motor en alguna capa reactiva.

En una store✨ (estado centralizado, único y accesible globalmente, en una palabra bonita que se han inventado los ingenieros frontend para que nadie les tire piedras por llamarlo por lo que realmente es: un singleton de manual, con sus mismos problemas), decidí que tendría todo el estado reactivo que alimentaría a la vista. Los datos de la store se alimentan cuando el sistema emite los cambios.

Diagrama mostrando uso de un ChangeSet
En este caso, el ChangeSet es también publisher, aunque no emite nunca de forma automática.

El flush de los ChangeSet, es decir, emitir notificaciones de cambios y limpiarse, es controlado, nunca automático. Se necesita una tercera pieza que asuma el rol de dispatcher. Lo normal, quien se encarga de iniciar el loop de actualización del juego, también se encarga de este proceso.

Captura del idle con ChangeSets
Una primera versión de pruebas del sistema con unos pocos recursos y acciones. Todo es reactivo de cara a la vista, pero los datos reactivos surgen de manera controlada del estado del juego mediante ChangeSets

Alternativas

Como siempre, existen muchas otras técnicas, mecanismos y patrones. Recientemente he implementado un sistema con cola de eventos. Algo muy similar, pero con temporalidad. Simplemente todos los eventos que van ocurriendo (con su payload) en un sistema de un juego por turnos, se encolan para luego ser procesados por la vista en el orden y al ritmo adecuado. Un Event Queue mezclado con Command pattern, por si a alguien le interesan nombres conocidos.

Ya cada cual con su tema, pero yo últimamente me estoy centrando más en soluciones muy fáciles de entender a largo plazo, por lo explícitas que suelen ser.