Behavior Trees: IA Modular e Escalável para Videojogos
As Behavior Trees (Árvores de Comportamento) tornaram-se na arquitetura padrão para IA de NPCs em videojogos modernos, desde AAA até projetos indie. Oferecem modularidade, reutilização e legibilidade superiores às tradicionais Finite State Machines (FSM). Neste artigo, exploramos a anatomia de uma BT, construímos passo-a-passo uma árvore para um NPC guarda, comparamos com FSM e Utility AI, e listamos ferramentas, boas práticas e erros comuns.
Neste artigo
- O que são Behavior Trees
- Vantagens das BT sobre FSM
- Anatomia de uma Behavior Tree
- Construir uma BT passo-a-passo: NPC Guarda
- Implementação em C# para Unity
- BT vs FSM vs Utility AI
- Ferramentas populares
- Boas práticas
- Erros comuns e pitfalls
- Checklist de implementação
O que são Behavior Trees
Uma Behavior Tree é uma estrutura de dados hierárquica usada para modelar o comportamento de agentes inteligentes — tipicamente NPCs em videojogos. A árvore é percorrida de cima para baixo e da esquerda para a direita, avaliando nós (nodes) que devolvem um de três estados possíveis: Success, Failure ou Running.
O conceito foi adaptado para videojogos a partir de robótica e popularizado por títulos como Halo 2 (Bungie, 2004) e Crysis (Crytek, 2007). Desde então, tornou-se quase ubíquo em motores AAA — Unreal Engine inclui um sistema nativo de Behavior Trees desde a versão 4.
A ideia central é simples: em vez de codificar transições entre estados como numa FSM, definimos comportamentos como nós numa árvore. Nós de controlo (Sequence, Selector) determinam a ordem de execução. Nós folha (Action, Condition) executam acções ou verificam condições. A árvore é reavaliada a cada tick, permitindo comportamento reativo e dinâmico.
ℹ Dica: Uma Behavior Tree não substitui o pathfinding ou o perception system — trabalha em conjunto com eles. A BT decide o que fazer; o NavMesh e o sensor de visão fornecem os dados para decidir.
Vantagens das BT sobre FSM
As Finite State Machines foram o padrão durante anos, mas escalam mal. Num FSM, cada estado precisa de saber para quais estados pode transitar, criando uma teia de transições que cresce exponencialmente com o número de estados. Numa BT, os nós são modulares e independentes — adicionar um novo comportamento não requer modificar os existentes.
| Aspecto | FSM | Behavior Tree |
|---|---|---|
| Escalabilidade | Transições crescem O(n²) | Adicionar nó = O(1) |
| Modularidade | Estados acoplados entre si | Nós independentes e reutilizáveis |
| Reutilização | Difícil — lógica inline | Subtrees reutilizáveis entre NPCs |
| Legibilidade | Código esparguete de transições | Estrutura visual hierárquica |
| Debugging | Difícil rastrear transições | Visualização da árvore em tempo real |
| Prioridade | Sem prioridade nativa | Selector implementa prioridade natural |
A principal vantagem práctica é a prioridade implícita: num Selector, os nós à esquerda têm prioridade sobre os da direita. Isto significa que “fugir” pode estar à esquerda de “patrulhar” — se a condição de fuga for verdadeira, o NPC foge automaticamente, sem precisar de uma transição explícita de “patrulhar → fugir”.
Anatomia de uma Behavior Tree
Uma BT é composta por vários tipos de nós, cada um com uma função específica. Os nós dividem-se em duas categorias: nós de controlo (compostos e decoradores) e nós folha (acções e condições).
Sequence (Sequência)
Executa os filhos da esquerda para a direita. Devolve Success apenas se todos os filhos devolverem Success. Se qualquer filho devolver Failure, a Sequence interrompe e devolve Failure. Se um filho devolver Running, pausa e retoma no mesmo nó no próximo tick.
Equivalente a um AND lógico — todos os passos têm de succeed.
Selector (Seletor)
Executa os filhos da esquerda para a direita. Devolve Success assim que o primeiro filho succeed. Se um filho devolver Failure, tenta o próximo. Se todos falharem, devolve Failure. Se um filho devolver Running, pausa e retoma no mesmo nó.
Equivalente a um OR lógico — tenta comportamentos por prioridade até um funcionar.
ℹ Mnónica: Sequence = “E” (tudo tem de passar). Selector = “OU” (basta um passar). A ordem da esquerda para a direita define a prioridade.
Decorator (Decorador)
Tem um único filho e modifica o seu resultado. Decoradores comuns:
- Inverter — troca Success por Failure e vice-versa.
- Repetir — executa o filho N vezes ou indefinidamente.
- Timeout — falha se o filho não terminar em X segundos.
- Cooldown — impede reexecução durante um intervalo.
- Force Success/Failure — converte qualquer resultado num estado fixo.
Action (Acção)
Nó folha que executa uma acção concreta: mover-se, atacar, reproduzir uma animação, disparar um som. Devolve Success quando a acção termina, Failure se não for possível, ou Running enquanto está em execução.
Condition (Condição)
Nó folha que avalia uma condição booleana — “inimigo à vista?”, “saúde \< 30%?”, “tem munições?”. Devolve Success ou Failure imediatamente (nunca Running). As condições são os “sensores” da árvore.
Estados de retorno
| Estado | Significado | Comportamento |
|---|---|---|
| Success | Acção/condição concluída com êxito | Avança para o próximo nó |
| Failure | Acção/condição falhou | Sequence para; Selector tenta próximo |
| Running | Acção em curso | Pausa a árvore; retoma neste nó |
Construir uma BT passo-a-passo: NPC Guarda
Vamos modelar um guarda que patrulha entre pontos, persegue o jogador quando o avista, e ataca quando está ao alcance. Se a saúde estiver baixa, foge para se curar.
Passo 1 — Identificar comportamentos
Listamos os comportamentos por prioridade descendente:
- Fugir (saúde \< 20%) — prioridade máxima
- Atacar (inimigo ao alcance de ataque)
- Perseguir (inimigo à vista mas longe)
- Patrulhar (comportamento por defeito)
Passo 2 — Estruturar a árvore
A raiz é um Selector. Cada comportamento é um ramo. O ramo de maior prioridade fica à esquerda:
Selector (Raiz)
├── Sequence [Fugir]
│ ├── Condition: Saúde < 20%?
│ └── Action: Mover para ponto de cura
├── Sequence [Atacar]
│ ├── Condition: Inimigo ao alcance?
│ └── Action: Atacar
├── Sequence [Perseguir]
│ ├── Condition: Inimigo à vista?
│ └── Action: Mover para inimigo
└── Action [Patrulhar]
└── Mover para próximo waypoint
Passo 3 — Adicionar modularidade com subtrees
Cada Sequence pode ser uma subtree independente, reutilizável noutros NPCs. Por exemplo, a subtree “Perseguir” pode ser partilhada entre o guarda e um cão de guarda — apenas as condições de percepção diferem.
Passo 4 — Refinar com decoradores
Adicionamos um Decorator Cooldown à acção de ataque para impedir que o guarda ataques em cada frame:
├── Sequence [Atacar]
│ ├── Condition: Inimigo ao alcance?
│ └── Decorator: Cooldown(2.0s)
│ └── Action: Atacar
Passo 5 — Adicionar reacção a estímulos
Podemos inserir um ramo de “investigar ruído” entre Fugir e Atacar. Como a BT é reavaliada a cada tick, se o guarda ouvir um ruído durante a patrulha, interrompe-a e investiga — sem precisar de transições explícitas.
Selector (Raiz)
├── Sequence [Fugir]
│ ├── Condition: Saúde < 20%?
│ └── Action: Mover para ponto de cura
├── Sequence [Investigar Ruído]
│ ├── Condition: Ouviu ruído?
│ └── Action: Mover para origem do ruído
├── Sequence [Atacar]
│ ├── Condition: Inimigo ao alcance?
│ └── Decorator: Cooldown(2.0s)
│ └── Action: Atacar
├── Sequence [Perseguir]
│ ├── Condition: Inimigo à vista?
│ └── Action: Mover para inimigo
└── Action [Patrulhar]
Implementação em C# para Unity
Abaixo apresentamos uma implementação mínima do núcleo de uma Behavior Tree em C# para Unity. O código define os estados, a classe base Node, os nós compostos (Sequence, Selector), e as acções/condições do NPC guarda.
Estados e classe base
using System.Collections.Generic;
using UnityEngine;
public enum NodeState { Success, Failure, Running }
public abstract class Node
{
protected List<Node> children = new List<Node>();
public NodeState State { get; protected set; }
public abstract NodeState Evaluate();
public void AddChild(Node child) => children.Add(child);
}
Nós compostos: Sequence e Selector
public class Sequence : Node
{
private int currentChild = 0;
public override NodeState Evaluate()
{
while (currentChild < children.Count)
{
var state = children[currentChild].Evaluate();
switch (state)
{
case NodeState.Success:
currentChild++;
continue;
case NodeState.Failure:
currentChild = 0;
State = NodeState.Failure;
return State;
case NodeState.Running:
State = NodeState.Running;
return State;
}
}
currentChild = 0;
State = NodeState.Success;
return State;
}
}
public class Selector : Node
{
private int currentChild = 0;
public override NodeState Evaluate()
{
while (currentChild < children.Count)
{
var state = children[currentChild].Evaluate();
switch (state)
{
case NodeState.Success:
currentChild = 0;
State = NodeState.Success;
return State;
case NodeState.Failure:
currentChild++;
continue;
case NodeState.Running:
State = NodeState.Running;
return State;
}
}
currentChild = 0;
State = NodeState.Failure;
return State;
}
}
Decorator: Inverter e Cooldown
public class Inverter : Node
{
public Inverter(Node child) { children.Add(child); }
public override NodeState Evaluate()
{
var state = children[0].Evaluate();
if (state == NodeState.Success) State = NodeState.Failure;
else if (state == NodeState.Failure) State = NodeState.Success;
else State = NodeState.Running;
return State;
}
}
public class Cooldown : Node
{
private float cooldown;
private float lastExecution = -999f;
public Cooldown(float seconds, Node child)
{
cooldown = seconds;
children.Add(child);
}
public override NodeState Evaluate()
{
if (Time.time - lastExecution < cooldown)
{
State = NodeState.Failure;
return State;
}
var state = children[0].Evaluate();
if (state == NodeState.Success)
lastExecution = Time.time;
State = state;
return State;
}
}
Condições e Acções do guarda
public class ConditionLowHealth : Node
{
private GuardController guard;
public ConditionLowHealth(GuardController g) => guard = g;
public override NodeState Evaluate()
{
State = guard.Health < 20f
? NodeState.Success
: NodeState.Failure;
return State;
}
}
public class ConditionEnemyVisible : Node
{
private GuardController guard;
public ConditionEnemyVisible(GuardController g) => guard = g;
public override NodeState Evaluate()
{
State = guard.CanSeeEnemy()
? NodeState.Success
: NodeState.Failure;
return State;
}
}
public class ActionAttack : Node
{
private GuardController guard;
public ActionAttack(GuardController g) => guard = g;
public override NodeState Evaluate()
{
guard.Attack();
State = NodeState.Success;
return State;
}
}
public class ActionMoveToEnemy : Node
{
private GuardController guard;
public ActionMoveToEnemy(GuardController g) => guard = g;
public override NodeState Evaluate()
{
float dist = guard.DistanceToEnemy();
if (dist <= guard.AttackRange)
{
State = NodeState.Success;
return State;
}
guard.MoveTo(guard.EnemyPosition);
State = NodeState.Running;
return State;
}
}
public class ActionPatrol : Node
{
private GuardController guard;
public ActionPatrol(GuardController g) => guard = g;
public override NodeState Evaluate()
{
guard.Patrol();
State = NodeState.Running;
return State;
}
}
Montar e executar a árvore
public class GuardAI : MonoBehaviour
{
private Node root;
private GuardController guard;
void Start()
{
guard = GetComponent<GuardController>();
// Construir a árvore
root = new Selector();
// Ramo: Fugir
var flee = new Sequence();
flee.AddChild(new ConditionLowHealth(guard));
flee.AddChild(new ActionMoveToHeal(guard));
// Ramo: Atacar (com cooldown de 2s)
var attack = new Sequence();
attack.AddChild(new ConditionEnemyInRange(guard));
attack.AddChild(new Cooldown(2f, new ActionAttack(guard)));
// Ramo: Perseguir
var chase = new Sequence();
chase.AddChild(new ConditionEnemyVisible(guard));
chase.AddChild(new ActionMoveToEnemy(guard));
// Ramo: Patrulhar
var patrol = new ActionPatrol(guard);
root.AddChild(flee);
root.AddChild(attack);
root.AddChild(chase);
root.AddChild(patrol);
}
void Update()
{
root.Evaluate(); // Tick da árvore a cada frame
}
}
ℹ Nota: Esta implementação é minimalista para fins didácticos. Em produção, usa-se um framework completo (NodeCanvas, Behavior Designer) ou o sistema nativo do Unreal Engine, que incluem visualização, debugging e optimizações.
BT vs FSM vs Utility AI
Behavior Trees não são a única arquitetura de IA para jogos. As duas alternativas principais são Finite State Machines (FSM) e Utility AI. Cada abordagem tem pontos fortes e fracos consoante o tipo de NPC.
| Critério | FSM | Behavior Tree | Utility AI |
|---|---|---|---|
| Complexidade | Simples para poucos estados | Moderada, escala bem | Elevada — curvas de scoring |
| Decisão | Transições explícitas | Prioridade hierárquica | Pontuação ponderada de acções |
| Reactividade | Requer transição manual | Reavalia a cada tick | Reavalia scores continuamente |
| Previsibilidade | Alta | Alta | Baixa — comportamento emergente |
| Melhor para | NPCs simples, menus, cutscenes | NPCs com comportamentos múltiplos | Criaturas simuladas, Sims-like |
| Exemplo em jogos | Pac-Man, inimigos 8-bit | Halo 2, Crysis, F.E.A.R. | The Sims, F.E.A.R. (combinado) |
Na prática, muitos jogos combinam abordagens. Por exemplo, F.E.A.R. usa GOAP (uma variante de planning) para decisões tácticas e FSM para animações. The Sims usa Utility AI para escolher acções baseadas em necessidades. Um padrão comum é usar BT para a estrutura de decisão principal e Utility scoring dentro de nós Selector para desempatar comportamentos de prioridade similar.
Ferramentas populares
Existem várias ferramentas e frameworks para criar Behavior Trees sem escrever o núcleo do zero. As mais populares:
| Ferramenta | Motor/Plataforma | Licença | Destaque |
|---|---|---|---|
| NodeCanvas | Unity | Paga (Asset Store) | Editor visual completo, FSM + BT + Dialogue |
| Behavior Designer (Opsive) | Unity | Paga (Asset Store) | O mais popular para Unity; integração com movement packs |
| Unreal Behavior Tree | Unreal Engine | Nativo (grátis) | Integrado com Blackboard e EQS; standard AAA |
| BehaviorTree.CPP | C++ / ROS2 | Open-source (MIT) | Robótica e jogos; Groot2 para visualização |
| Groot2 | Standalone (C++) | Grátis / Pro | Editor visual para BehaviorTree.CPP; debugging em tempo real |
Para projetos em Unity, Behavior Designer é a escolha mais comum pela vasta documentação e integração com outros assets da Opsive. Para Unreal Engine, o sistema nativo de Behavior Trees é poderoso e não tem custo adicional — inclui Blackboard (armazenamento de dados do NPC) e EQS (Environment Query System) para avaliar posições tacticalmente. Para robótica ou motores próprios em C++, BehaviorTree.CPP com Groot2 oferece uma solução open-source robusta.
Boas práticas
1. Modularidade e reutilização de subtrees
Estrutura a árvore em subtrees independentes. A subtree “Patrulhar” não deve depender de variáveis da subtree “Atacar”. Isto permite reutilizar subtrees entre NPCs diferentes — um soldado e um cão podem partilhar a subtree de patrulha.
2. Condições leves e acções com Running
Nós de condição devem ser instantâneos (O(1) idealmente) — verificação de variáveis, distance checks simples. Acções longas (mover, animar) devem devolver Running para não bloquear a árvore.
3. Prioridade à esquerda
Nos Selectors, colocar os comportamentos de maior prioridade (sobrevivência, fuga) à esquerda. Os comportamentos por defeito (patrulhar, idle) ficam à direita. A ordem substitui as transições explícitas das FSM.
4. Debugging e visualização
Usar sempre a visualização da árvore em tempo real. No Unreal, o Behavior Tree editor mostra o nó ativo em cada frame. No Behavior Designer, o Behaviour Tree window faz o mesmo. Para implementações próprias, adicionar Debug.Log ou OnDrawGizmos para rastrear a execução.
5. Blackboard em vez de variáveis espalhadas
Centralizar os dados do NPC num Blackboard — um dicionário partilhado de variáveis (posição do inimigo, saúde, waypoint actual). Todos os nós leem e escrevem no Blackboard em vez de aceder directamente ao transform ou componentes. Isto desacopla a lógica da implementação e facilita testes.
6. Evitar árvores demasiado profundas
Uma BT com 7+ níveis de profundidade torna-se difícil de ler e depurar. Se a árvore cresce demasiado, dividir em subtrees separadas e usar um Selector de alto nível para escolher entre elas.
Erros comuns e pitfalls
| Problema | Causa | Solução |
|---|---|---|
| NPC fica parado, sem reagir | Acção devolve Running infinitamente; árvore nunca prossegue | Adicionar Decorator Timeout; garantir que acções terminam |
| NPC alterna rapidamente entre comportamentos (flickering) | Condição oscila a cada tick (ex: inimigo no limite do campo de visão) | Adicionar histerese ou Cooldown nas condições; usar Blackboard com timestamp |
| Árvore cresce incontrolavelmente | Cada novo comportamento adicionado inline sem refactorização | Extrair subtrees; usar Selector de alto nível com subtrees dedicadas |
| Prioridade errada — NPC patrulha em vez de fugir | Ordem dos ramos no Selector incorrecta | Colocar comportamentos críticos (fuga, sobrevivência) à esquerda |
| Nós de condição pesados causam lag | Condição faz raycasts ou queries físicas a cada tick | Cache de resultados; actualizar perception em intervalos (ex: 0.2s) |
| Sequence recomeça do início a cada tick | Implementação sem memória de estado (stateless) | Manter índice currentChild; retomar no nó Running (implementação com memória) |
| Subtree não reage a interrupção externa | Sequence bloqueada em Running não avalia ramos de maior prioridade | Usar Selector com memória ou Reactive Selector que reavalia ramos à esquerda |
⚠ Atenção: O erro mais comum em iniciantes é esquecer o estado Running. Se uma acção de movimento devolver Success imediatamente (em vez de Running), o NPC “teleporta” para o destino ou a árvore entra em ciclo infinito. Acções contínuas devem devolver Running até concluírem.
Checklist de implementação
- Listar comportamentos do NPC por prioridade descendente (fuga \> combate \> patrulha).
- Desenhar a árvore no papel ou editor visual antes de codificar — Selector na raiz, cada comportamento como Sequence.
- Implementar ou escolher framework — Behavior Designer (Unity), Unreal BT nativo, ou BehaviorTree.CPP (C++).
- Criar o Blackboard com as variáveis partilhadas (saúde, posição do inimigo, waypoint actual, estado de alerta).
- Implementar condições como nós folha leves — apenas leituras de variáveis, sem lógica pesada.
- Implementar acções com retorno correcto: Success ao terminar, Running durante execução, Failure se impossível.
- Adicionar decoradores onde necessário — Cooldown para ataques, Timeout para movimentos, Inverter para condições negadas.
- Validar prioridade — comportamentos críticos à esquerda do Selector; comportamento por defeito à direita.
- Testar com visualização — seguir o nó ativo em tempo real no editor; confirmar que a árvore reage a estímulos.
- Adicionar histerese em condições oscilatórias (ex: campo de visão no limite) para evitar flickering.
- Extrair subtrees reutilizáveis — se um ramo tem 4+ nós, considerar extraí-lo como subtree independente.
- Otimizar frequency — nem todos os NPCs precisam de tick a cada frame; usar tick interval de 0.1–0.3s para NPCs secundários.
- Documentar a árvore — comentários em cada subtree explicando o propósito e as condições de entrada.
Conclusão
Behavior Trees oferecem uma arquitetura modular, escalável e visualmente clara para IA de NPCs em videojogos. A separação entre controlo (Sequence, Selector, Decorator) e execução (Action, Condition) permite adicionar comportamentos sem modificar os existentes — algo impossível nas FSM tradicionais. Com as ferramentas certas (Behavior Designer, Unreal BT, Groot2) e as boas práticas de modularidade e debugging, é possível criar NPCs complexos e reactivos sem cair no caos de transições esparguete.
Para projetos simples (2-3 estados), uma FSM ainda é suficiente. Mas assim que o NPC precisa de 5+ comportamentos com prioridades e reacção a estímulos, as Behavior Trees são a escolha padrão da indústria — e a razão pela qual motores como Unreal Engine as incluem nativamente.