HTN: Hierarchical Task Networks
El planificador jerárquico que usa Horizon Zero Dawn. Descompone tareas compuestas en primitivas; mezcla la riqueza de GOAP con la estructura editable de un BT.
Publicado: · Por Juanjo "Banyo" López
0. Introducción
Tu Behavior Tree funciona, pero cada vez que el diseñador pide una variante táctica nueva acabas añadiendo otra rama. Migras a GOAP y ganas flexibilidad, pero el equipo de diseño se queja: “el NPC hizo algo raro y nadie sabe por qué”. HTN vive justo en ese hueco. Es un planificador, como GOAP, pero el plan se construye descomponiendo tareas que el diseñador escribió a mano, no buscando libremente en el espacio de estados.
Guerrilla Games lo usó en Killzone 2/3 y volvió a apostar por él en Horizon Zero Dawn: máquinas de combate con varios cientos de tareas y métodos, todas auditables por un diseñador. Square Enix lo usa en Final Fantasy XV para los compañeros. Es la opción cuando GOAP es demasiado opaco y un BT es demasiado rígido.
¿Qué problema concreto resuelve HTN?
- Estructura editable por diseñadores. El plan no aparece de la nada: cada compound task tiene métodos numerados que el diseñador escribió. Si una decisión falla, hay un punto exacto que revisar.
- Look-ahead barato. A diferencia de un BT, HTN planifica varios pasos por adelantado en una sola pasada. El NPC sabe de antemano que su plan es:
cubrirse → recargar → flanquear → disparar. - Reutilización jerárquica. Una compound task
Attackse enchufa a cinco enemigos distintos; cada uno ajusta sus métodos sin reescribir la lógica. - Más rápido que GOAP en plan típico. GOAP explora con A*; HTN reduce el espacio guiado por la estructura que tú escribiste. Cuesta menos CPU.
¿Cuándo NO usar HTN?
- El comportamiento es muy simple (3-5 acciones): un BT plano sigue ganando.
- No tienes diseñador que escriba métodos. HTN brilla cuando alguien estructura el dominio. Sin esa inversión, GOAP da más por menos.
- Las acciones se interrumpen constantemente. HTN se desincroniza si no replanificas a tiempo; mira la sección 2.4.
Lo que vas a ver en este tutorial:
- Los cuatro conceptos del modelo: world state, primitive task, compound task, method.
- El algoritmo de decomposition paso a paso, con rollback.
- Cuándo HTN gana frente a GOAP y BT, con una tabla honesta.
- Snippet en Unity / C# de un planificador minimal usable.
1. Demo
2. Concepto
HTN (Hierarchical Task Network) es un planificador jerárquico que produce un plan descomponiendo una tarea de alto nivel en sub-tareas hasta llegar a acciones ejecutables, eligiendo en cada paso el método cuyas precondiciones se cumplen en el world state. Lo formalizaron Erol, Hendler y Nau en HTN Planning: Complexity and Expressivity (1994).
La idea clave: en lugar de buscar libremente “qué acción me acerca al goal” (eso es GOAP), partes de una descripción jerárquica del dominio escrita por el diseñador. El planner solo elige qué método usar en cada nivel.
2.1 Los cuatro conceptos
Un HTN tiene exactamente cuatro piezas. Si las dominas, todo lo demás es notación.
World state — el “qué sabe el agente del mundo” en este instante. Un dict de hechos:
hasRifle: true
ammoCount: 7
enemyVisible: true
enemyDistance: 18
inCover: false
hp: 80
Igual que en GOAP. Puede ser booleano, numérico o enum. El planner lo simula internamente al expandir el plan: aplica los effects de cada primitive como si la acción ya hubiese ocurrido.
Primitive Task — una acción concreta y ejecutable: MoveTo(cover), ShootRifle, Reload. Tiene tres cosas:
preconditions: qué debe cumplirse en el world state para poder ejecutarla.operator: la acción real en runtime (mover, disparar, animación).effects: cómo modifica el world state cuando termina. El planner los usa para encadenar el plan sin esperar al runtime.
Compound Task — una tarea abstracta que no se ejecuta directamente: Attack, TravelTo(pos), EngageEnemy. No tiene operator. En vez de eso, contiene una lista ordenada de Methods.
Method — una alternativa de implementación de una Compound Task. Tiene:
preconditions: cuándo este método es aplicable.subtasks: lista de sub-tareas (mezcla de compound y primitive) que descomponen la tarea madre.
Un mismo Attack puede tener tres métodos: “ataque a distancia con rifle”, “melee si estás cerca”, “huir si HP bajo”. El planner prueba métodos en orden hasta encontrar uno cuyas precondiciones se cumplan.
2.2 ¿Cómo se descompone una tarea compuesta?
El algoritmo se llama decomposition. Lo hace el planner en una sola pasada, antes de que el NPC ejecute nada.
Partes de la root task (típicamente una compound task como BeSoldier). Recorres en profundidad:
- Si la tarea actual es primitive → la añades al plan final. Aplicas sus
effectsal world state simulado (no al real, todavía no se ejecutó nada). - Si la tarea es compound → recorres sus métodos en orden. Para cada método:
- Si sus
preconditionsse cumplen en el world state actual → expandes sussubtasksy recurses. - Si la expansión recursiva tiene éxito → terminamos esta compound.
- Si la expansión falla en algún sub-paso → rollback: descartas lo añadido al plan, restauras el world state, pruebas el siguiente método.
- Si sus
- Si ningún método del compound funciona → la propia tarea falla, propagas el fallo hacia arriba.
El resultado final es una lista plana de primitive tasks: el plan que el NPC ejecutará en orden.
Ejemplo concreto. Compound Attack con dos métodos:
La compound task Attack se descompone vía 'ranged' o 'melee'. Cada método tiene precondiciones y una lista de primitives (rellenos). El método 'ranged' está pulsando porque sus precondiciones se cumplen primero.
World state: hasRifle: true, ammoCount: 3, enemyDistance: 18.
El planner prueba ranged: precondiciones OK → expande. AimAt es primitive → al plan. ShootRifle es primitive → al plan. Vuelve hacia arriba: el compound Attack se resuelve. Plan final: [AimAt, ShootRifle].
Si ammoCount: 0, ranged falla → rollback → prueba melee. Como enemyDistance: 18 no es < 2, también falla → la compound Attack no produce plan en este estado. El planner subiría a la tarea padre y probaría otro método ahí.
2.3 World State, preconditions y effects
Esto es lo más sutil del modelo, y donde la gente nueva tropieza.
El planner simula la ejecución del plan en su cabeza. Cuando una primitive entra al plan, sus effects modifican el world state interno del planner, no el world state real del juego. El runtime aún no ha hecho nada.
Mira esta secuencia mientras se planifica Engage:
Cada primitive aplica sus effects al world state interno del planner. El runtime aún no ejecuta nada; solo se simula para encadenar el plan.
El plan resultante es [MoveToCover, AimAt, ShootRifle]. Esa simulación es lo que permite encadenar acciones que se habilitan mutuamente sin esperar al runtime.
2.4 ¿Cuándo HTN gana frente a GOAP y BT?
La pregunta no es académica: las tres técnicas resuelven el mismo problema con compromisos distintos.
| Necesidad | BT | GOAP | HTN |
|---|---|---|---|
| Pocos comportamientos, fijos | Ideal | Overkill | Overkill |
| Look-ahead de N pasos | No (decide cada tick) | Sí (A* completo) | Sí (decomposition) |
| Coste por replanificación | N/A | Alto (A* sobre estados) | Medio (recorrido jerárquico) |
| Estructura editable por diseñador | Alta (árbol visual) | Baja (effects/preconds emergen) | Alta (métodos numerados) |
| Capacidad de “sorprender” | Baja (todo está escrito) | Alta (A* combina) | Media (limitado a métodos definidos) |
| Reutilización entre agentes | Subárboles | Acciones compartidas | Compound tasks |
| Comportamiento en estados no anticipados | Falla a idle | Encuentra ruta si existe | Falla si ningún método cubre el caso |
| Casos reales famosos | Halo 2+ | F.E.A.R., Shadow of Mordor | Killzone 2/3, Horizon Zero Dawn, FFXV |
Regla práctica:
- Si el diseñador piensa en términos de “primero esto, después aquello, salvo cuando X” → HTN.
- Si el diseñador piensa en términos de “el NPC quiere lograr X” y no le importa el cómo → GOAP.
- Si el comportamiento es reactivo y de pocos pasos → BT.
HTN es más rápido que GOAP porque tú le diste el “mapa” del dominio. También es menos creativo: solo combina lo que el diseñador estructuró. En Horizon, las máquinas tienen cientos de métodos precisamente para que el espacio se sienta amplio. Sin esa inversión, HTN parece un BT con pasos extra.
2.5 Replanificación: ¿cuándo y cómo?
Aquí está el talón de Aquiles. El planner simuló el world state asumiendo que el mundo era estático. En el juego real:
- El jugador se mueve mid-plan.
- Un compañero del NPC mata al enemigo objetivo.
- Un explosivo cambia el terreno.
El plan original se vuelve inválido. Hay tres estrategias:
Validación por step. Antes de ejecutar la próxima primitive, comprobar que sus precondiciones siguen cumpliéndose en el world state real. Si no, replanificar. Es lo más común. Cuesta poco.
Sensores que disparan replan. Eventos del juego (OnEnemyKilled, OnLostSightOfPlayer, OnLowHP) invalidan el plan actual. Replanificar desde cero.
Replan periódico. Cada N ms, replanificas. Bruto pero efectivo en simulaciones muy dinámicas.
function tick():
if currentPlan is empty:
currentPlan = decompose(rootTask, currentWorldState)
if currentPlan is empty: return # nada que hacer este frame
return
next = currentPlan.peek()
if not preconditionsMet(next, currentWorldState):
currentPlan = [] # invalidar; el próximo tick replanifica
return
status = execute(next)
if status == done:
currentPlan.pop()
2.6 ¿Por qué los métodos se prueban en orden?
Detalle de diseño importante. Los métodos de una compound task tienen orden explícito: el primero que la cumpla, gana.
Esto te da control fino. Si quieres que el NPC prefiera disparar a distancia antes que pelear cuerpo a cuerpo, pones ranged antes que melee. Ambos pueden ser aplicables a la vez, pero el primero corta.
Es el espejo del Selector de un BT: preferencia por orden. La diferencia es que aquí el “match” se hace contra precondiciones del world state, no contra el resultado de tick de un hijo.
Si todos los métodos son aplicables siempre y solo se diferencian por orden, has reinventado un BT con pasos extra. La gracia de HTN está en que las precondiciones filtran: distintos contextos disparan distintos métodos.
3. Pseudocódigo
type Task = Primitive | Compound
function plan(root: Task, ws: WorldState) -> List<Primitive>?
output = []
if decompose(root, ws, output):
return output
return null
function decompose(task: Task, ws: WorldState, plan: List<Primitive>) -> Bool
if task is Primitive:
if not preconditionsMet(task, ws): return false
plan.append(task)
applyEffects(task, ws) # mutamos el ws simulado
return true
# task es Compound: probar métodos en orden
for each method in task.methods:
if not preconditionsMet(method, ws): continue
# snapshot para rollback
wsBackup = clone(ws)
planLen = plan.length
success = true
for each subtask in method.subtasks:
if not decompose(subtask, ws, plan):
success = false
break
if success: return true
# rollback: restaurar ws y truncar plan
ws = wsBackup
plan.truncate(planLen)
return false # ningún método aplicó
El algoritmo es depth-first con rollback. Cada compound task abre un punto de decisión; cada método es una alternativa. Si una rama falla, deshacemos el plan parcial y probamos la siguiente.
Si te recuerda a búsqueda con backtracking, es porque lo es. La diferencia con A* de GOAP es que aquí el grafo de búsqueda es el árbol del dominio, no el grafo de estados del mundo.
4. Implementación en Unity / C#
Una implementación minimal sin dependencias. El asset la amplía con editor visual, sensores y replanificación dirigida por eventos.
using System.Collections.Generic;
public class WorldState : Dictionary<string, object> {
public WorldState Clone() => new WorldState(this);
public WorldState() {}
public WorldState(WorldState other) : base(other) {}
}
public abstract class Task {
public string id;
public abstract bool PreconditionsMet(WorldState ws);
}
public abstract class PrimitiveTask : Task {
public abstract void ApplyEffects(WorldState ws); // simulación
public abstract IEnumerator<bool> Execute(IAgent a); // runtime real
}
public class Method {
public System.Func<WorldState, bool> Preconditions;
public List<Task> Subtasks;
}
public class CompoundTask : Task {
public List<Method> Methods = new();
public override bool PreconditionsMet(WorldState ws) => true;
}
public class HTNPlanner {
public Queue<PrimitiveTask> Plan(Task root, WorldState ws) {
var output = new List<PrimitiveTask>();
var working = ws.Clone();
return Decompose(root, working, output)
? new Queue<PrimitiveTask>(output)
: null;
}
bool Decompose(Task task, WorldState ws, List<PrimitiveTask> plan) {
if (task is PrimitiveTask p) {
if (!p.PreconditionsMet(ws)) return false;
plan.Add(p);
p.ApplyEffects(ws);
return true;
}
var c = (CompoundTask)task;
foreach (var m in c.Methods) {
if (!m.Preconditions(ws)) continue;
var backup = ws.Clone();
int planMark = plan.Count;
bool ok = true;
foreach (var sub in m.Subtasks) {
if (!Decompose(sub, ws, plan)) { ok = false; break; }
}
if (ok) return true;
// rollback
ws.Clear();
foreach (var kv in backup) ws[kv.Key] = kv.Value;
plan.RemoveRange(planMark, plan.Count - planMark);
}
return false;
}
}
Uso típico para una compound Attack:
var attack = new CompoundTask {
id = "Attack",
Methods = {
new Method { // ranged
Preconditions = ws => (bool)ws["hasRifle"] && (int)ws["ammo"] > 0,
Subtasks = { new AimAt(), new ShootRifle() }
},
new Method { // melee
Preconditions = ws => (float)ws["enemyDistance"] < 2f,
Subtasks = { new DrawKnife(), new StabEnemy() }
}
}
};
var queue = new HTNPlanner().Plan(attack, agent.WorldState);
if (queue != null) agent.ExecutePlan(queue);
5. En otros engines
- Godot: HTN no depende del engine, es algoritmo puro. Una
Noderaíz con_processque llame al planner y ejecute la cola de primitives es suficiente. El addon LimboAI (que trae BT a Godot) tiene experimentos en su issue tracker para HTN. - Unreal: la opción más conocida es el plugin Fluid HTN (open source, hay forks en C++ para Unreal). Coexiste con el
BehaviorTreenativo; lo típico es BT como skeleton macro y HTN dentro de las hojas tácticas. Guerrilla compartió slides técnicos de su HTN para Horizon en GDC; muy recomendables. - JavaScript / cualquier engine: igual que GOAP, son ~150 líneas de algoritmo. Si trabajas en web o engine custom, lo implementas en una tarde.
6. Quiz
Pon a prueba lo que entendiste
Responde una por una. La explicación aparece al elegir, correcta o no.
Tu HTN nunca encuentra plan aunque sabes que existe una solución posible. ¿Qué revisas primero?
¿Cuál es la diferencia clave entre HTN y GOAP?
Un compound `Attack` tiene dos métodos: 'ranged' (precond: hasRifle && ammo > 0) y 'melee' (precond: enemyDistance < 2). World state: hasRifle=true, ammo=0, enemyDistance=1. ¿Qué hace el planner?
Tu NPC está a mitad del plan [MoveToCover, AimAt, Shoot] cuando otro NPC mata al enemigo objetivo. ¿Mejor approach?
¿En qué se parece más un Method de HTN a una pieza de un Behavior Tree?