Programación Defensiva en Videojuegos
“Programación defensiva” no es más que una forma de diseñar el software de modo tal que se tengan en cuenta la mayor cantidad de circunstancias imprevistas posibles, permitiendo que el software se comporte de forma correcta y que el programa nunca se pare.
Sin embargo, acá ya empezamos con los problemas porque ¿qué es “comportarse correctamente” en el contexto de los videojuegos?
Antes de entrar al agujero de conejo un breve disclaimer: no voy a incluir el manejo de excepciones (algunos lo ponen como una forma de programación defensiva separada, otros lo incluyen como un todo) simplemente porque en videojuegos no usamos esas estructuras de control, básicamente por una cuestión de performance (todas esas estructuras son agujeros negros para los recursos) y nunca hay que perder de vista que en este rubro no solo alcanza con hacerlo bien… hay que hacerlo con la mejor performance posible. Tampoco voy a referirme al caso particular de programación defensiva en el contexto de seguridad en juegos multijugador (porque da para un tema aparte y porque el concepto de fondo que quiero tratar es totalmente diferente).
Volvemos entonces al ¿que es “comportarse correctamente”?. Veamos 2 ejemplos con codigo (en GDScript)
1er ejemplo:
#Enemy script
...
var target: Player
...
func _process(delta: float) -> void:
look_at(target.global_position)
2do ejemplo:
#Player script
...
export var projectile_class: Projectile = null
...
func shoot() -> void:
var new_projectile:Projectile = projectile_class.instance()
add_child(new_projectile)
En el primer script tenemos a un enemigo que rota constantemente (cuadro a cuadro) hacia el player (target). En el segundo script tenemos un método del player qué instancia un objeto de la clase Projectile (la del proyectil) la almacena en una variable y la añade al mundo del juego (add_child).
Está claro que ambos códigos tienen formas de fallar, de forma imprevista, y que el programa (juego) se detenga con un error.
-
En el primer script: si el player se destruye (al perder una vida, por ejemplo) la referencia “target” va a ser nula hasta que vuelva a respawnear. Eso daría un error (referencia nula).
-
En el segundo script: si por algún motivo alguien se olvidó de asignar el valor a “projectile_class” desde el editor (para eso el export, el equivalente a un SerializeField en Unity o un TSubclassOf con un UPROPERTY en Unreal) esa variable miembro va a ser nula y, otra vez, vamos a tener un error.
¿Cuál es la solución? Ya sé, lo primero que están pensando es: las famosas y queridas “guard clauses”. Que no son más que un chequeo de precondiciones (funcionan muy bien con retorno temprano también).
Vamos a probar esto: 1er ejemplo:
#Enemy script
...
func _process(delta: float) -> void:
if target:
look_at(target.global_position)
2do ejemplo:
#Player script
...
func shoot() -> void:
if projectile_class:
var new_projectile:Projectile = projectile_class.instance()
add_child(new_projectile)
Perfecto, si target es nulo (por ejemplo cuando se destruye al player) el enemigo va a dejar de ejecutar el código que daría error. Así mismo, si alguien olvido darle valor a projectile_class el player no va a ejecutar la porción de código con error. ¿Listo entonces? ¿El juego ya se comporta “correctamente”? Y no… la verdad que no lo hace.
En el primer caso, sí, pero en el segundo, ¿qué va a pasar cuando la persona que esté jugando el juego presione el botón de disparar? El juego no va a detenerse, pero no se va a producir ningún disparo. Imaginen un FPS donde no se pueda disparar… ahora imaginen al pobre programador intentando debuggear el error (que así a simple vista puede ir desde que el proyectil está colisionando indebidamente contra quien lo instancia, hasta que existe un problema de binding con los inputs, y un montón de “tal vez” en el medio). Esta última situación es MAL código defensivo, y se ve mucho.
¿Por qué me atrevo a decirlo tan asertivamente? Porque por definición el código defensivo dice “…se comporte de forma correcta…” y no es correcto que el juego siga funcionando bajo esas circunstancias, “si se tiene que romper.. que se rompa” me gusta decir a mí. Y si no van a tomar mi palabra tomen la de Ari Arnbjörnsson (que de esto sabe un poco) y en este video habla de “fallar mejor”
También me gusta decir que si llegaste hasta el lanzamiento del juego con un error de ese tipo, el menor de tus problemas es el código defensivo (me sale la parte QA de adentro).
¿Entonces? ¿Nos entregamos mansamente a no defendernos? Claro que no, siempre hay formas de mejorar la calidad de nuestro código y de nuestro software en general. El problema es que no hay una “silver bullet” porque a veces depende de nuestra herramienta, y comprender como funciona. Acá viene la parte que a muchos programadores no les gusta: hay que leer la documentación.
Una de las formas principales de defenderse correctamente de problemas como el que mencione son las aserciones. Pero estas, por supuesto, no tienen la misma sintaxis en todos los lenguajes (o en la API que nos provee un motor). Por solo dar un ejemplo en los 3 motores más conocidos
- Godot utiliza una sola aserción
- Unity tiene una clase dedicada a estas
- Unreal toda una gama/familia de distintos tipos de aserciones (en particular la de “Ensure” es la que más utilizo).
De todas formas, el espíritu de las aserciones son siempre el mismo, y con la documentación en manos podemos entender exactamente que podemos esperar al utilizarlas.
Como el ejemplo problemático estaba con código de Godot, volvamos a él.
2do ejemplo:
#Player script
...
func shoot() -> void:
if assert(projectile_class, "No se cargo una escena proyectil"):
var new_projectile:Projectile = projectile_class.instance()
add_child(new_projectile)
Si leemos la documentación vamos a saber que:
- Si la condición es falsa se genera un error.
- Si lo anterior sucede vamos a tener un mensaje de error customizado.
- Cuando estamos corriendo el juego desde el editor, este se va a pausar, nos va a mostrar el error, pero nos permite seguir probando el juego (continuar). Si estamos corriendo una versión Debug va a lanzar el error.
- MUY IMPORTANTE: los asserts “cuestan” por lo tanto, es código que NO corre en versiones para release. Si incluimos código que tiene efectos secundarios (por ejemplo, toda una configuración depende del éxito de la aserción) podemos tener comportamientos indeseados. Esto es muy común con las aserciones (en Unreal es similar) y por eso tampoco la solución es andar poniendo “assert” por todos lados. Tampoco hay que asustarse si en chequeos de aserciones tenemos “tirones” al probar el juego en el editor, como ya dije, son costosas y se pierde performance, pero en versión release ese código no se ejecuta.
Entonces, ante la misma situación planteada antes (alguien olvido cargar la escena del proyectil) esta vez no vamos a tener un juego donde al presionar “disparar” el personaje simplemente no va a disparar o el juego se va a romper sin más. Vamos a tener la posibilidad de que el juego se pare automáticamente, nos muestre un error muy claro y evidente, pero nos permita continuar la ejecución por si estábamos testeando otras cosas.
Lo que se dice “win-win”.
Conclusión: la programación defensiva no es un diseño tan sencillo como decir “pongo chequeo de precondiciones por todos lados”, hay que hacer un esfuerzo de diseño; sin embargo, el código resultante es de mejor calidad y (en gran parte) a prueba de errores una vez en producción/lanzado. Si no tenemos ningún tipo de programación defensiva estamos expuestos, nuestro juego va a correr de forma correcta hasta que encuentre un error y allí simplemente vamos a estar ante la situación más indeseada: un error crítico que detenga todo.
Hay que mirar qué herramientas nos provee el lenguaje que estamos utilizando para mejorar nuestra programación defensiva. Las aserciones son muy importantes, pero tampoco son un martillo de oro ni podemos utilizarlas indiscriminadamente.