Arquitectura de IA: composición sobre herencia, patrones que escalan
Aprende a organizar tu proyecto. Patrones: Composite, Factory, Event Bus, Service Locator, Object Pool, Blackboard, y caso práctico de refactor.
Publicado: · Por Juanjo "Banyo" López
0. Introducción
Este tutorial no enseña un algoritmo de IA. Enseña algo igual de importante y mucho menos común en español: cómo organizar el código de tu IA para que un proyecto de 6 meses no termine siendo imposible de mantener.
El síntoma típico: empiezas con un EnemyController.cs chiquito. A los 3 meses tiene 600 líneas, mezcla pathfinding, animación, combate, audio y daño. Tocar una función rompe otra. Cualquier enemigo nuevo es copiar/pegar el archivo entero. Probaste agregar un Boss y terminaste con 1200 líneas y miedo de hacer commit.
¿Qué problemas resuelve este tutorial?
- God Controllers que crecen sin límite.
- Acoplamiento global vía
Singleton.Instancepor todos lados. - Imposibilidad de testear la IA sin abrir Unity.
- Reuso cero entre tipos de agente: cada enemigo nuevo es 80% código duplicado.
- Garbage collection disparándose en escenas con muchos proyectiles/agentes.
- IA cooperativa rota: 4 enemigos no logran flanquear al jugador porque cada uno toma decisiones aisladas.
¿Cuándo aplicar estos patrones?
- Tienes 3+ tipos de agente que comparten lógica significativa.
- Quieres que el proyecto viva más de 3 meses y siga siendo modificable.
- Hay equipo (incluso de 2 personas): los patrones reducen el tiempo de onboarding.
- Tienes ambiciones de escala: no solo prototipar, lanzar a producción.
¿Cuándo NO?
- Game-jam de 48h: los patrones cuestan tiempo de bootstrap. Haz un God Controller, gana la jam, después refactorizas.
- Un solo enemigo simple: la simplicidad gana. No hay nada que componer.
- Estás aprendiendo Unity todavía. Aprende el motor primero, después la arquitectura.
En esta página vas a ver 6 patrones explicados con código antes/después y vizzes interactivas, 4 patrones complementarios mencionados con su caso de uso, y un caso práctico completo: refactorizar un EnemyController de 640 líneas a 5 módulos limpios.
1. El antipatrón: God Controller
Antes de los patrones, el problema concreto. Mira lo que pasa con un EnemyController que crece sin disciplina:
- Movement
- Pathfinding
- Perception (vision)
- Perception (audio)
- Combat (melee)
- Combat (ranged)
- Health / damage
- Animation triggers
- Audio triggers
- FX particles
- Loot drops
- Save/load state
Síntomas: tocar una cosa rompe otra. Cambiar la animación obliga a recompilar combate. Imposible reusar piezas entre tipos de enemigo. Pesadilla de mantenimiento.
Cambia entre las dos vistas. Lo que ves del lado “anti-patrón” es real: 12 responsabilidades, ~640 líneas, todas las dependencias a flor de piel. Cualquier cambio puede romper otra cosa. Ningún tipo de enemigo reusa nada.
Los 6 patrones que siguen son las herramientas para llegar al lado derecho.
2. Composite + Strategy — la columna vertebral
2.1 Recap de los tutoriales anteriores
Si seguiste Steering Behaviors III, ya viste el patrón composite en acción: un SteeringController con una lista de SteeringBehaviors que se suman. En Behavior Trees, también: Selector y Sequence son nodos compuestos que combinan hijos. En FSM/HFSM II, las transiciones son objetos Strategy componibles.
Composite = un objeto que contiene una lista de objetos del mismo tipo y los orquesta. Strategy = los objetos contenidos son intercambiables, todos cumplen la misma interfaz.
Juntos son la base de toda IA escalable.
2.2 Aplicación al agente
En vez de un EnemyController monolítico, el Agent es un composite de módulos:
class Agent
modules: List<IAgentModule>
function Tick(dt: float)
for each m in modules
if m.enabled
m.Tick(this, dt)
Y cada módulo es una Strategy intercambiable:
interface IAgentModule
enabled: bool
function Tick(agent: Agent, dt: float)
class MovementModule: IAgentModule ...
class CombatModule: IAgentModule ...
class PerceptionModule: IAgentModule ...
Cambiar el comportamiento de un agente = cambiar la lista de módulos. Sin tocar el Agent mismo. Sin herencia.
2.3 Beneficio práctico
| Caso | Sin composite | Con composite |
|---|---|---|
| Crear un enemigo “patrullero pacífico” (sin combate) | Heredar de EnemyController, override Update, copiar 70% del código | new Agent(MovementModule, PerceptionModule) — listo |
| Desactivar combate en tiempo real (modo cinemática) | Bandera disableCombat, ifs por todos lados | agent.GetModule<CombatModule>().enabled = false |
| Añadir un módulo de stealth nuevo | Tocar EnemyController, agregar campos, recompilar todo | Crear StealthModule : IAgentModule, registrar |
3. Factory / Abstract Factory con ScriptableObject
3.1 El problema
Después de definir tus módulos, tienes un nuevo dolor: ¿dónde y cómo creas los agentes? Si lo haces inline:
var enemy = new Agent();
enemy.AddModule(new MovementModule { speed = 3f });
enemy.AddModule(new CombatModule { damage = 10, range = 2f });
enemy.AddModule(new PerceptionModule { visionRadius = 8f });
Esa receta vive en código. Cambiar damage requiere recompilar. El level designer no puede tocarlo. Y peor: tienes esa receta repetida en 5 lugares (uno por tipo de enemigo).
3.2 La solución: factory con ScriptableObject
Un EnemyArchetype es un ScriptableObject que describe qué módulos lleva un enemigo y cómo se configuran. La EnemyFactory los construye:
class EnemyArchetype : ScriptableObject
name: string
moduleConfigs: List<ModuleConfig>
class EnemyFactory
function Create(archetype: EnemyArchetype) -> Agent
agent = new Agent()
for each config in archetype.moduleConfigs
agent.AddModule(config.Build())
return agent
El level designer crea Guard.asset, Scout.asset, Sniper.asset desde el editor de Unity. Cada uno es un archivo. Los modificas sin tocar código.
3.3 Abstract Factory para temas
¿Quieres que los enemigos del bioma “Volcán” tengan stats diferentes a los del bioma “Glaciar”? Una IEnemyFactory con dos implementaciones:
interface IEnemyFactory
function Create(name: string) -> Agent
class VolcanoFactory: IEnemyFactory → enemigos con resistencia a fuego
class GlacierFactory: IEnemyFactory → enemigos con resistencia a hielo
El sistema de spawneo solo conoce IEnemyFactory. Cambiar de bioma = cambiar la implementación registrada. Sin tocar nada más.
4. Observer / Event Bus interno por agente
4.1 El problema del acoplamiento entre módulos
Tienes HealthModule y AnimationModule. Cuando el agente recibe daño:
HealthModulebaja el HP.AnimationModuledebe disparar el “hurt”.CombatModuledebe interrumpir el ataque actual.AudioModuledebe reproducir el grunt.
Si HealthModule llama directo a animationModule.PlayHurt(), lo está acoplando. Y si añades un módulo nuevo (FxModule que debe spawnear partículas), tienes que tocar HealthModule para que también lo llame.
4.2 La solución: Event Bus interno
Cada Agent tiene un bus de eventos privado. Los módulos publican eventos; los módulos suscriben a los que les interesan. Los emisores nunca conocen a los receptores.
Cada módulo se suscribe a los eventos que le interesan. Cuando alguien publica, el bus despacha solo a los suscritos. Los emisores nunca conocen a los receptores.
Pulsa los botones de eventos. Mira cómo se propagan: del emitter al bus, y del bus solo a los suscriptores. El Health no sabe que existe Animation; solo emits.
4.3 Paso a paso
-
Definir el bus por agente:
class EventBus handlers: Map<Type, List<Action<Event>>> function Subscribe<T>(handler: Action<T>) handlers[T].Add(handler) function Publish<T>(event: T) for each h in handlers[T]: h(event) -
Definir eventos como structs:
struct OnDamage { amount: int; source: GameObject } struct OnDeath { killer: GameObject } -
Suscribir desde cada módulo en su
Init:class AnimationModule function Init(bus: EventBus) bus.Subscribe<OnDamage>(e => PlayHurt()) bus.Subscribe<OnDeath>(e => PlayDeath()) -
Publicar desde donde corresponda:
class HealthModule function ApplyDamage(amount: int, source: GameObject) hp -= amount bus.Publish(new OnDamage { amount, source }) if hp <= 0: bus.Publish(new OnDeath { killer = source })
4.4 Cuándo NO usar Event Bus
- Para respuestas síncronas con valor de retorno (ej: “calcúlame el daño final con todos los modificadores”). Usa llamada directa a un servicio.
- Para flujos transaccionales (varios pasos que deben suceder en orden). Usa Command pattern (lo verás en la próxima tanda).
- Para módulos que siempre necesitan a otro módulo. Inyéctalo por constructor; el bus es para opcionales.
5. Service Locator vs Singleton
5.1 El antipatrón Singleton
GameManager.Instance es el caso típico. Tu CombatModule lo necesita para acceder al DamageMultiplierService. Tu MovementModule también. Tu UI. Fin del juego: cambiar la implementación del multiplicador es imposible sin grep global.
.Instance
- Acoplamiento global: los 5 módulos dependen de UN tipo concreto.
- Imposible mockear: en tests, el Singleton sigue ahí (estático).
- Orden de inicialización frágil: ¿quién arranca primero?
- Imposible tener dos instancias (split-screen, escenas paralelas, replays).
Cambia las pestañas y compara. Lo del lado “Singleton” es lo que pasa en proyectos sin disciplina.
5.2 Cuándo Singleton sí está bien
Hay 3 casos legítimos para un Singleton:
- Servicios de sistema operativo que sí son únicos (
Application.persistentDataPathya es singleton de hecho). - Configuración inmutable cargada en startup (
GameConfig.Instance.MasterVolumesi nunca cambia post-load). - Loggers y similares (instancia única, sin estado mutable que afecte gameplay).
Cualquier otra cosa, especialmente todo lo que un test querría mockear, debe pasar por Service Locator o DI.
5.3 Service Locator simple
class ServiceLocator
services: Map<Type, object>
function Register<T>(impl: T)
services[T] = impl
function Get<T>() -> T
return services[T] as T
Uso:
// Startup
locator.Register<IPathfinding>(new NavMeshPathfinding());
locator.Register<IAudio>(new SpatialAudioService());
// En cualquier módulo
var path = locator.Get<IPathfinding>().FindPath(start, end);
5.4 Service Locator por escena
Si quieres split-screen o replays con dos “mundos” simultáneos, un Service Locator por escena es la solución:
class SceneServices : MonoBehaviour
locator: ServiceLocator
function Awake() { locator = new ServiceLocator(); RegisterAll(); }
Cada escena tiene sus propios servicios. Sin singletons globales que ensucien el otro mundo.
6. Object Pool
6.1 El problema del GC
En una escena de combate, disparas 30 balas/segundo, cada una vive 2 segundos. Eso son 60 alocaciones constantes y 60 destrucciones. Cada destrucción es una potencial pausa de Garbage Collection. En móviles esto causa stutters visibles.
6.2 La solución: reciclar
En vez de new y destruir, mantienes una lista de objetos dormidos. Cuando necesitas uno nuevo, lo despiertas. Cuando “muere”, lo duermes. Cero GC en hot-path.
El cañón dispara 15 balas/seg. Activa el pool: las balas que mueren regresan al pool y se reusan; las que se disparan después salen del pool sin new(). Mira la métrica alloc rate.
Activa el toggle. Mira el contador new() llamadas: con pool activo, solo se llama new() para “llenar” el pool al inicio; después es 0. Sin pool, crece sin parar.
6.3 Paso a paso
- Pre-allocar el pool en startup con N objetos dormidos.
- Spawn: tomar uno del pool, resetear estado, marcar como activo.
- Despawn: marcar como inactivo, devolverlo al pool.
- Si el pool se agota: o creces (ralloc) o ignoras el spawn (cap visual).
6.4 Qué poolear y qué no
| Buen candidato | Mal candidato |
|---|---|
| Proyectiles (cientos por segundo) | Bosses (1 instancia, vida larga) |
| Partículas (visibles) | Estructuras de datos List<T> mutables (mejor Clear()) |
| Enemigos respawneables (oleadas) | UI windows (raro abrir/cerrar) |
| Balas, casquillos, líneas de daño flotantes | Audio sources (Unity ya los poolea internamente) |
7. Blackboard pattern
7.1 El problema de la IA cooperativa
4 enemigos en un escuadrón. Quieres que se coordinen: si uno ve al jugador, los otros avanzan a flanquear. Si uno muere, los otros buscan cobertura. Sin compartir información, cada uno decide aislado y la “inteligencia de grupo” se rompe.
7.2 La solución: pizarra compartida
Un Blackboard es un diccionario tipado compartido entre los agentes del grupo. Cuando un agente “sabe” algo importante, lo escribe. Los demás lo leen en su próximo tick.
lastSeenPlayerPos(12.3, 0, 8.1)posalarmRaisedfalseboolnextFlankSlot0int
lastSeenPlayerPosalarmRaisednextFlankSlotlastSeenPlayerPosCuando un agente actualiza la pizarra, todos los demás agentes del escuadrón ven el cambio en el siguiente tick. Sin esto, cada agente tendría que preguntarle a cada uno de los demás (O(n²) de chismes), o existiría un manager central rígido.
Pulsa los botones de “update”. Mira cómo cada agente lee la key que le interesa y se actualiza al mismo tiempo.
7.3 Diferencia con Event Bus
| Event Bus | Blackboard |
|---|---|
| Notificaciones puntuales | Estado persistente |
| ”Algo pasó ahora" | "Esta es la verdad actual” |
| Si nadie escucha, se pierde | Sigue ahí hasta que se sobreescribe |
| Comunicación dentro de un agente | Comunicación entre agentes |
A menudo se usan juntos: un OnPlayerSpotted (event) hace que Squad-1 actualice lastSeenPlayerPos (blackboard); los demás agentes leen la pizarra en su próximo tick.
7.4 Implementación con ScriptableObject
Por equipo / por escena, un BlackboardSO es un ScriptableObject. Visible en el inspector durante runtime — debugging gratis.
class BlackboardSO : ScriptableObject
data: Map<string, object>
function Set<T>(key: string, value: T)
function Get<T>(key: string) -> T
function TryGet<T>(key: string, out value: T) -> bool
8. Patrones complementarios — mención + diagrama
Estos cuatro completan el toolkit pero no necesitan demo profunda. Conocelos como herramientas disponibles.
8.1 Subsumption Architecture (Brooks, 1986)
Capas de comportamiento ordenadas por prioridad. La capa más alta activa suprime a las inferiores. No hay mezcla — el output es de UNA sola capa.
Subsumption Architecture (Brooks, 1986): las capas están ordenadas por prioridad. La capa más alta activa suprime a todas las que están abajo. No hay "mezcla" — el output es el de UNA sola capa.
Activa los toggles. Subsumption no es composite (que mezcla); es selección dura por prioridad. Útil cuando quieres garantizar que “evitar choque” siempre gana sobre “atacar”, sin riesgos de cancelación de fuerzas.
8.2 Goal-Based Agents
El agente tiene una jerarquía de goals (SurviveGoal > CombatGoal > AttackGoal). En cada tick, evalúa qué goal es relevante y selecciona behaviors para perseguirlo. Es el puente conceptual hacia Utility AI y GOAP.
SurviveGoal (priority 1) ← si hp bajo, prioriza huir/curarse
└─ FleeBehavior, FindHealthBehavior
CombatGoal (priority 2) ← si hay enemigo visible, atacar
└─ AttackBehavior, ChaseBehavior
ExploreGoal (priority 3) ← por defecto
└─ PatrolBehavior, WanderBehavior
La diferencia con FSM: los goals son lo que el agente quiere; los behaviors cómo intenta conseguirlo. Eso facilita razonamiento abstracto. Lo verás en profundidad en la tanda 6 (Utility AI / GOAP).
8.3 Sense-Think-Act loop
El meta-patrón canónico que organiza tus módulos. Cada tick:
- Sense — los módulos de percepción (vista, oído) actualizan el blackboard.
- Think — el FSM/BT decide qué hacer en función del blackboard.
- Act — los módulos de movimiento/combate ejecutan la decisión.
[ Sense ] → [ Think ] → [ Act ]
↑ ↓
└──── (mundo) ←────────┘
No es un patrón de implementación, es una guía de orden de ejecución. Muchos bugs aparecen cuando se ejecutan fuera de orden (act antes de sense → reacciones a estado obsoleto).
8.4 Decorator (formalización)
Ya lo viste en BTs (Inverter, Cooldown, Repeater). Es un patrón GoF: un objeto que envuelve a otro y modifica su comportamiento sin que el envuelto lo sepa.
Usos comunes en arquitectura de IA:
- Cooldown decorator sobre cualquier acción que no debe spammearse.
- Logger decorator sobre módulos para tracear sin tocar el módulo.
- Conditional decorator sobre módulos para que solo tickeen si
condition().
9. Caso práctico: refactorizar un God Controller
Pongamos todo junto. Tienes EnemyController.cs con 640 líneas. Lo refactorizamos a 5 módulos siguiendo los patrones.
9.1 Antes (snippet)
// EnemyController.cs — 640 líneas, 12 responsabilidades
public class EnemyController : MonoBehaviour {
public int hp = 100;
public float visionRadius = 8f;
public float attackRange = 2f;
public float speed = 3f;
Animator animator;
NavMeshAgent agent;
AudioSource audio;
Transform player;
bool dead;
void Update() {
if (dead) return;
// pathfinding
agent.SetDestination(player.position);
// perception
var canSee = Vector3.Distance(transform.position, player.position) < visionRadius;
// combat
if (canSee && Vector3.Distance(transform.position, player.position) < attackRange) {
animator.SetTrigger("attack");
audio.PlayOneShot(attackSound);
// ... aplicar daño al jugador
}
// ... 600 líneas más
}
public void TakeDamage(int amount) {
hp -= amount;
animator.SetTrigger("hurt");
audio.PlayOneShot(hurtSound);
if (hp <= 0) {
dead = true;
animator.SetTrigger("die");
audio.PlayOneShot(deathSound);
// spawn loot, particles, ...
}
}
}
9.2 Después: 5 módulos + Agent + EventBus
// Agent.cs — composite, ~80 líneas
public class Agent : MonoBehaviour {
public EventBus bus = new EventBus();
readonly List<IAgentModule> modules = new();
public void AddModule(IAgentModule m) { m.Init(this); modules.Add(m); }
public T GetModule<T>() where T : class, IAgentModule => modules.OfType<T>().FirstOrDefault();
void Update() {
foreach (var m in modules) if (m.enabled) m.Tick(this, Time.deltaTime);
}
}
public interface IAgentModule {
bool enabled { get; set; }
void Init(Agent agent);
void Tick(Agent agent, float dt);
}
// HealthModule.cs — ~70 líneas
public class HealthModule : IAgentModule {
public bool enabled { get; set; } = true;
public int hp = 100;
EventBus bus;
public void Init(Agent agent) { bus = agent.bus; }
public void Tick(Agent agent, float dt) {} // pasiva, reacciona via bus
public void Apply(int amount) {
hp -= amount;
bus.Publish(new OnDamage { amount = amount });
if (hp <= 0) bus.Publish(new OnDeath());
}
}
// PerceptionModule.cs — ~80 líneas
public class PerceptionModule : IAgentModule {
public bool enabled { get; set; } = true;
public float visionRadius = 8f;
public Transform player;
EventBus bus;
bool wasVisible;
public void Init(Agent agent) { bus = agent.bus; }
public void Tick(Agent agent, float dt) {
var now = Vector3.Distance(agent.transform.position, player.position) < visionRadius;
if (now && !wasVisible) bus.Publish(new OnTargetSpotted { target = player });
if (!now && wasVisible) bus.Publish(new OnTargetLost());
wasVisible = now;
}
}
// MovementModule, CombatModule, PresentationModule — análogos
9.3 Comparativa
| Métrica | God Controller | Composición |
|---|---|---|
| Líneas | 640 | 80 (Agent) + 5 × 80 (módulos) ≈ 480 — pero distribuidas |
| Responsabilidades por archivo | 12 | 1 |
| Tests unitarios | imposibles sin escena | trivial por módulo |
| Reuso entre tipos de enemigo | 0% | ~80% (módulos compartidos) |
| Tiempo de modificar un sistema (combat) | 1-2h con miedo | 10 min localizado |
| Onboarding de un dev nuevo | ”leete el archivo" | "estos 5 módulos hacen X, Y, Z” |
9.4 Diagrama final
Agent
/ | \
┌───────┘ | └────────┐
MovementModule | PerceptionModule
|
EventBus ←──── modules suscriben
|
┌────────┴────────┐
CombatModule HealthModule PresentationModule
(animaciones, audio, VFX)
ServiceLocator (scene-level)
├── IPathfinding
├── IAudio
└── IBlackboard
10. Implementación en Unity / C# — el bus mínimo
using System;
using System.Collections.Generic;
public class EventBus {
readonly Dictionary<Type, List<Delegate>> handlers = new();
public void Subscribe<T>(Action<T> handler) {
if (!handlers.TryGetValue(typeof(T), out var list)) {
list = new List<Delegate>();
handlers[typeof(T)] = list;
}
list.Add(handler);
}
public void Unsubscribe<T>(Action<T> handler) {
if (handlers.TryGetValue(typeof(T), out var list)) list.Remove(handler);
}
public void Publish<T>(T evt) {
if (!handlers.TryGetValue(typeof(T), out var list)) return;
// copia por seguridad: si un handler se desuscribe a sí mismo, no rompe el foreach
foreach (var h in list.ToArray()) ((Action<T>)h).Invoke(evt);
}
}
// Uso
public struct OnDamage { public int amount; public GameObject source; }
public struct OnDeath { public GameObject killer; }
bus.Subscribe<OnDamage>(e => Debug.Log($"got hit: {e.amount}"));
bus.Publish(new OnDamage { amount = 10 });
11. En otros engines
- Godot: la arquitectura nodo/escena ya empuja a la composición. Cada módulo es un
Nodehijo delAgent. Las señales de Godot (signal) son el Event Bus de fábrica. - Unreal:
UActorComponentpor módulo. LosUE Delegates(DECLARE_DYNAMIC_MULTICAST_DELEGATE) son tu Event Bus.USubsystempara Service Locator. - JavaScript / TypeScript: el patrón es directo. ES6 classes + un
EventEmitterde Node y tienes todo.
12. Quiz
Pon a prueba lo que entendiste
Responde una por una. La explicación aparece al elegir, correcta o no.
Tienes un EnemyController de 600 líneas. Detectás que el módulo de animación se rompe cada vez que tocas combate. ¿Causa más probable?
¿Cuándo Singleton SÍ es aceptable?
Estás disparando 30 balas/seg en mobile. Notas stutters periódicos. ¿Solución más probable?
F.E.A.R. (2005) era famoso por la 'inteligencia' de sus soldados. ¿Qué patrón fue clave para esa coordinación?
Tu HealthModule baja HP. Quieres que AnimationModule, AudioModule y FxModule reaccionen. ¿Mejor diseño?
¿Para qué patrón es CONCEPTUALMENTE más adecuado un Subsumption Architecture en vez de un weighted controller?
¿Cuál es la diferencia conceptual clave entre Event Bus y Blackboard?