Sistemas Avanzado 21 min de lectura

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.Instance por 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:

EnemyController.cs
~640 líneas · 12 responsabilidades · 0 reuso
  • 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

CasoSin compositeCon composite
Crear un enemigo “patrullero pacífico” (sin combate)Heredar de EnemyController, override Update, copiar 70% del códigonew Agent(MovementModule, PerceptionModule) — listo
Desactivar combate en tiempo real (modo cinemática)Bandera disableCombat, ifs por todos ladosagent.GetModule<CombatModule>().enabled = false
Añadir un módulo de stealth nuevoTocar EnemyController, agregar campos, recompilar todoCrear 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:

  • HealthModule baja el HP.
  • AnimationModule debe disparar el “hurt”.
  • CombatModule debe interrumpir el ataque actual.
  • AudioModule debe 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.

Último evento:

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

  1. 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)
    
  2. Definir eventos como structs:

    struct OnDamage  { amount: int; source: GameObject }
    struct OnDeath   { killer: GameObject }
    
  3. Suscribir desde cada módulo en su Init:

    class AnimationModule
        function Init(bus: EventBus)
            bus.Subscribe<OnDamage>(e => PlayHurt())
            bus.Subscribe<OnDeath>(e  => PlayDeath())
    
  4. 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.

GameManager
.Instance
global mutable
Combat
→ GameManager.Instance.X()
Health
→ GameManager.Instance.X()
Movement
→ GameManager.Instance.X()
AI
→ GameManager.Instance.X()
UI
→ GameManager.Instance.X()
  • 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:

  1. Servicios de sistema operativo que sí son únicos (Application.persistentDataPath ya es singleton de hecho).
  2. Configuración inmutable cargada en startup (GameConfig.Instance.MasterVolume si nunca cambia post-load).
  3. 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

  1. Pre-allocar el pool en startup con N objetos dormidos.
  2. Spawn: tomar uno del pool, resetear estado, marcar como activo.
  3. Despawn: marcar como inactivo, devolverlo al pool.
  4. Si el pool se agota: o creces (ralloc) o ignoras el spawn (cap visual).

6.4 Qué poolear y qué no

Buen candidatoMal 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 flotantesAudio 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.

Blackboard (compartido por el escuadrón)
  • lastSeenPlayerPos(12.3, 0, 8.1)pos
  • alarmRaisedfalsebool
  • nextFlankSlot0int
Squad-1
leyendo lastSeenPlayerPos
(12.3, 0, 8.1)
Squad-2
leyendo alarmRaised
false
Squad-3
leyendo nextFlankSlot
0
Squad-4
leyendo lastSeenPlayerPos
(12.3, 0, 8.1)

Cuando 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 BusBlackboard
Notificaciones puntualesEstado persistente
”Algo pasó ahora""Esta es la verdad actual”
Si nadie escucha, se pierdeSigue ahí hasta que se sobreescribe
Comunicación dentro de un agenteComunicació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:

  1. Sense — los módulos de percepción (vista, oído) actualizan el blackboard.
  2. Think — el FSM/BT decide qué hacer en función del blackboard.
  3. 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étricaGod ControllerComposición
Líneas64080 (Agent) + 5 × 80 (módulos) ≈ 480 — pero distribuidas
Responsabilidades por archivo121
Tests unitariosimposibles sin escenatrivial por módulo
Reuso entre tipos de enemigo0%~80% (módulos compartidos)
Tiempo de modificar un sistema (combat)1-2h con miedo10 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 Node hijo del Agent. Las señales de Godot (signal) son el Event Bus de fábrica.
  • Unreal: UActorComponent por módulo. Los UE Delegates (DECLARE_DYNAMIC_MULTICAST_DELEGATE) son tu Event Bus. USubsystem para Service Locator.
  • JavaScript / TypeScript: el patrón es directo. ES6 classes + un EventEmitter de 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.

  1. 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?

  2. ¿Cuándo Singleton SÍ es aceptable?

  3. Estás disparando 30 balas/seg en mobile. Notas stutters periódicos. ¿Solución más probable?

  4. F.E.A.R. (2005) era famoso por la 'inteligencia' de sus soldados. ¿Qué patrón fue clave para esa coordinación?

  5. Tu HealthModule baja HP. Quieres que AnimationModule, AudioModule y FxModule reaccionen. ¿Mejor diseño?

  6. ¿Para qué patrón es CONCEPTUALMENTE más adecuado un Subsumption Architecture en vez de un weighted controller?

  7. ¿Cuál es la diferencia conceptual clave entre Event Bus y Blackboard?