Reinforcement Learning con ML-Agents: enemigo que aprende
Define observación, acción y reward. ML-Agents entrena con PPO en horas. Exportas .onnx, lo cargas en Sentis. Cuándo RL gana a BT y cuándo te quema tiempo.
Publicado: · Por Juanjo "Banyo" López
0. Introducción
Quieres un enemigo que se adapte al estilo del jugador. Si el jugador esquiva todo, el enemigo aprende a forzar combate cercano. Si el jugador se queda lejos disparando, el enemigo aprende a flanquear. Un Behavior Tree lo puede hacer, pero tienes que diseñar cada caso a mano. Cuando el espacio de decisiones se vuelve grande, el árbol explota.
El Reinforcement Learning (RL) le da la vuelta al problema: tú no escribes la lógica, tú escribes la recompensa. Defines qué quieres que maximice el agente, lo dejas jugar millones de veces contra sí mismo o contra bots, y un algoritmo (típicamente PPO) entrena una red neuronal que mapea lo que ve a lo que hace. El resultado a veces te sorprende: el agente descubre estrategias que tú no programaste.
Casos reales: los Drivatars de Forza combinan RL con imitación de partidas humanas. AlphaStar de DeepMind aprendió StarCraft 2 con self-play. OpenAI Five llegó a tier pro en Dota 2. En indie, el repositorio de ejemplos de ML-Agents incluye el Karting Microgame y el Crawler entrenable en minutos.
Este tutorial es el punto de entrada. Vas a ver:
- Qué es un agente RL y cuándo gana a un BT.
- Las tres piezas que defines tú: observation, action y reward.
- Cómo se monta en Unity con ML-Agents, se entrena con PPO y se exporta a
.onnx.
1. Demo
2. Concepto y arquitectura
El RL entrena una policy que mapea observation → action maximizando reward acumulado. ML-Agents es el toolkit de Unity para hacerlo: el editor monta el ambiente, Python entrena con PPO, exportas .onnx y lo corres con Sentis. La parte difícil no es el algoritmo: es diseñar la reward.
El loop es siempre el mismo, desde el “Hello World” hasta AlphaStar:
El agente emite una acción; el ambiente devuelve la nueva observación y el reward. El ciclo se repite millones de pasos.
2.1 ¿Cuándo RL gana a un Behavior Tree?
RL gana cuando se cumplen, al menos, dos de estas tres condiciones:
- El comportamiento óptimo no es obvio para ti. Si supieras qué debe hacer el enemigo en cada situación, un BT lo expresa más rápido y barato.
- Hay muchas dimensiones de decisión. Combat con movimiento continuo, gestión de cobertura, posicionamiento y timing de skills tiene un espacio combinatorio enorme. El BT crece exponencial; la red neuronal solo crece linealmente con el tamaño del input.
- Quieres adaptación. El agente RL puede seguir aprendiendo contra distintos jugadores o detectar patrones (curriculum, self-play continuo).
Si tu enemigo tiene 4 estados claros (Patrullar, Perseguir, Atacar, Huir) y los disparadores son evidentes, un BT lo resuelve en una tarde. RL te tomaría días y se sentiría igual o peor. Usa la herramienta correcta.
2.2 Anatomía de un agente RL
Tres piezas las defines tú y son tu único trabajo creativo:
- Observations: el vector de números que el agente ve cada paso. Posición propia, posición del jugador, vida, cooldowns, distancia a paredes (raycasts). Todo lo que un humano necesitaría para decidir.
- Actions: lo que el agente puede hacer. Discretas (4 direcciones, atacar sí/no) o continuas (eje X de movimiento entre -1 y 1, ángulo de apuntado).
- Reward function: el escalar que el agente intenta maximizar. La parte difícil.
El resto (algoritmo, tamaño de red, hyperparámetros, optimizador) es ingeniería estandarizada que ML-Agents te trae con valores por defecto razonables.
2.3 ¿Por qué la reward function es la parte difícil?
Si premias “matar al jugador”, el agente aprende a kamikaze: corre de frente y muere mientras conecta un golpe. Si premias “sobrevivir”, se esconde detrás de una columna y no hace nada en toda la partida. Si premias ambas con pesos al ojo, descubre un bug del nivel y se queda en un punto donde el jugador no puede llegar pero el contador de tiempo sí le da reward.
Esto se llama reward hacking: el agente optimiza la recompensa, no tu intención. Y siempre encuentra atajos que tú no viste.
Las dos herramientas para domar esto:
- Dense rewards: pequeños premios y castigos en cada paso (
+0.001por estar cerca del jugador,-0.001por estar de espaldas a él). Guían el aprendizaje temprano cuando el agente está ciego. - Sparse rewards: premios y castigos grandes en eventos clave (
+1.0por matar,-1.0por morir). Son los que reflejan tu intención real, pero al inicio el agente no los ve nunca.
La receta práctica: empieza con dense rewards para que el agente aprenda algo, después los reduces y dejas que los sparse rewards moldeen el comportamiento final. Esto es el llamado reward shaping, y en la práctica es arte negro: pruebas, ves qué hace el agente, ajustas.
2.4 PPO en una línea
PPO (Proximal Policy Optimization, Schulman et al. 2017) es un algoritmo de policy gradient que limita cuánto puede cambiar la policy en cada update. Suena técnico, importa poco: es el estándar de facto en juegos porque entrena estable, escala bien y ML-Agents lo trae out-of-the-box. Si no sabes por dónde empezar, PPO es la respuesta.
2.5 Curriculum learning: empieza fácil
Tirar al agente al combate completo desde el minuto cero es como pedirle a alguien que aprenda a manejar en una autopista en hora pico. No aprende, choca, se frustra. Curriculum learning entrena por fases:
- Fase 1: el jugador está quieto. El agente solo aprende a acercarse y a apuntar.
- Fase 2: el jugador se mueve lento. El agente aprende a anticipar.
- Fase 3: el jugador esquiva activamente. El agente aprende a flanquear.
- Fase 4: arena completa con obstáculos.
ML-Agents lo gestiona con un .yaml de curriculum que sube parámetros del ambiente al alcanzar umbrales de reward. Acelera el entrenamiento 5–10× en problemas complejos.
2.6 Self-play: cuando el agente se entrena contra sí mismo
Para combate competitivo, el oponente ideal es otro agente al mismo nivel. Self-play clona el agente cada N pasos y guarda copias antiguas en un pool. El agente entrena contra muestras aleatorias de ese pool. Resultado: una escalera de versiones cada vez mejores, donde cada una supera a la anterior.
Es así como AlphaStar y OpenAI Five descubrieron estrategias que humanos no usaban. En tu juego no necesitas tanto. Pero si tu enemigo es simétrico al jugador (mismo movimiento, mismas armas), self-play te ahorra diseñar un bot oponente.
2.7 ¿Cuánto tiempo de entrenamiento esperar?
Cifras realistas con una GPU decente (RTX 3060 o equivalente):
| Tarea | Tiempo aproximado |
|---|---|
| Crawler / Walker de ejemplos ML-Agents | 10–30 minutos |
| Combat 1v1 simple (10 observations, 4 actions) | 2–6 horas |
| Combat con cobertura, raycasts, varias armas | 12–48 horas |
| Self-play multi-agente | 2–7 días |
Si tu plan dice “lo entreno la noche antes del demo”, elige otra herramienta. RL exige presupuesto temporal real y muchísima iteración: el primer entrenamiento siempre sale mal y el reward hay que retocarlo.
2.8 Sim-to-game gap: cuando lo que funcionó en training falla en shipping
El agente entrena en un ambiente controlado: misma arena, mismos obstáculos, jugador con comportamiento limitado. En el juego real se encuentra escenarios que nunca vio: niveles distintos, latencia de input, jugadores que hacen cosas raras (camping, exploits del nivel). Resultado: el enemigo brillante en training se comporta como un tonto en shipping.
La mitigación se llama domain randomization: durante el entrenamiento, varías al azar el tamaño de la arena, el tipo de obstáculos, la velocidad del jugador simulado, los parámetros de las armas. El agente aprende una policy más robusta porque no se sobre-ajusta a un único ambiente.
3. Pseudocódigo
class EnemyAgent extends MLAgent
function onEpisodeBegin()
# reset al inicio de cada episodio
self.position = randomSpawn()
self.hp = maxHp
target.position = randomSpawn()
target.hp = maxHp
function collectObservations(out sensor: VectorSensor)
# qué ve el agente cada paso
sensor.addObservation(self.position)
sensor.addObservation(target.position)
sensor.addObservation(self.hp / maxHp)
sensor.addObservation(target.hp / maxHp)
sensor.addObservation(self.velocity)
function onActionReceived(actions: ActionBuffers)
# actions[0,1] = movement (continuo)
# actions[2] = attack flag (discreto)
applyMovement(actions.continuous[0], actions.continuous[1])
if actions.discrete[0] == 1: tryAttack()
# reward shaping inline
addReward(-0.0005) # tick penalty: incentiva acción
if dealtDamage: addReward(+0.1)
if tookDamage: addReward(-0.05)
if killedTarget: addReward(+1.0); endEpisode()
if died: addReward(-1.0); endEpisode()
Tres ideas: el episodio se reinicia para que el agente vea muchas situaciones distintas, las observations son números normalizados (entre -1 y 1 idealmente), y el reward combina señales densas (-0.0005 por tick, +0.1 por damage) con sparse (±1 al matar/morir).
4. Implementación en Unity / C#
ML-Agents te da la clase base Agent. Heredas, implementas tres callbacks y montas un BehaviorParameters en el GameObject. Esto es un enemigo 1v1 con movimiento continuo y un ataque discreto.
using Unity.MLAgents;
using Unity.MLAgents.Actuators;
using Unity.MLAgents.Sensors;
using UnityEngine;
public class EnemyAgent : Agent {
[Header("Refs")]
public Transform target;
public float maxHp = 100f;
public float moveSpeed = 4f;
public float attackRange = 1.5f;
public float attackDamage = 10f;
float hp;
Vector3 spawnPoint;
Rigidbody rb;
public override void Initialize() {
rb = GetComponent<Rigidbody>();
spawnPoint = transform.position;
MaxStep = 5000; // safety: corta episodios infinitos
}
public override void OnEpisodeBegin() {
// reset del mundo al inicio de cada episodio
hp = maxHp;
transform.position = spawnPoint + Random.insideUnitSphere * 3f;
transform.position = new Vector3(transform.position.x, spawnPoint.y, transform.position.z);
rb.linearVelocity = Vector3.zero;
target.GetComponent<DummyTarget>()?.Reset();
}
public override void CollectObservations(VectorSensor sensor) {
// 8 observations: pos relativa (3), velocidad (3), hp normalizado (1), distancia (1)
var toTarget = target.position - transform.position;
sensor.AddObservation(toTarget / 20f); // normalizado a tamaño de arena
sensor.AddObservation(rb.linearVelocity / moveSpeed);
sensor.AddObservation(hp / maxHp);
sensor.AddObservation(toTarget.magnitude / 20f);
}
public override void OnActionReceived(ActionBuffers actions) {
// movimiento continuo
float mx = Mathf.Clamp(actions.ContinuousActions[0], -1f, 1f);
float mz = Mathf.Clamp(actions.ContinuousActions[1], -1f, 1f);
rb.linearVelocity = new Vector3(mx, 0f, mz) * moveSpeed;
// ataque discreto
bool wantsAttack = actions.DiscreteActions[0] == 1;
if (wantsAttack && Vector3.Distance(transform.position, target.position) < attackRange) {
target.GetComponent<DummyTarget>()?.TakeDamage(attackDamage);
AddReward(+0.1f); // dense: recompensa por daño aplicado
}
// tick penalty: el agente paga por existir → fuerza acción
AddReward(-0.0005f);
// chequeos terminales
if (target.GetComponent<DummyTarget>().hp <= 0f) {
AddReward(+1.0f);
EndEpisode();
}
if (hp <= 0f) {
AddReward(-1.0f);
EndEpisode();
}
}
public void TakeDamage(float dmg) {
hp -= dmg;
AddReward(-0.05f); // dense: castigo por daño recibido
}
}
Y el .yaml del trainer, simplificado:
behaviors:
EnemyAgent:
trainer_type: ppo
hyperparameters: { batch_size: 1024, buffer_size: 10240, learning_rate: 3.0e-4 }
network_settings: { hidden_units: 128, num_layers: 2 }
max_steps: 2000000
Lanzas el entrenamiento con mlagents-learn config.yaml --run-id=enemy_v1, abres Unity y le das play. Cuando converge, ML-Agents te escupe enemy_v1.onnx, lo arrastras al campo Model del BehaviorParameters, cambias el modo a Inference Only y tu enemigo corre la policy entrenada en runtime.
5. En otros engines
- Godot: GodotRL replica la API de ML-Agents (Agent + Brain + trainer en Python). Si vienes de ML-Agents la curva es plana; mismos conceptos, distinta sintaxis.
- Unreal: el Learning Agents de Epic existe pero está más verde que ML-Agents. Muchos equipos AAA entrenan fuera de Unreal (con PyTorch puro) y cargan el modelo vía ONNX Runtime o LibTorch.
- JavaScript / web: para entrenamientos chicos hay TensorFlow.js Reinforce y librerías como
rl.js. Útil para prototipar la idea de reward antes de mover el costo a Unity.
El patrón es siempre el mismo: tú defines observation, action y reward; un trainer externo aprende; exportas un modelo y lo cargas. La parte que cambia entre engines es la API del editor y el formato de exportación.
6. Quiz
Pon a prueba lo que entendiste
Responde una por una. La explicación aparece al elegir, correcta o no.
Tu agente RL hace algo no deseado pero técnicamente consigue mucho reward. ¿Qué pasó probablemente?
Tras 1M de pasos de entrenamiento, el agente no aprende nada. ¿Qué chequeas primero?
Tienes que decidir entre RL y Behavior Tree para un enemigo. ¿Cuándo NO usar RL?
Tu agente RL funciona perfecto en training y se comporta como tonto en el juego shipping. ¿Qué te olvidaste?