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

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:

  1. Fugir (saúde \< 20%) — prioridade máxima
  2. Atacar (inimigo ao alcance de ataque)
  3. Perseguir (inimigo à vista mas longe)
  4. 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

  1. Listar comportamentos do NPC por prioridade descendente (fuga \> combate \> patrulha).
  2. Desenhar a árvore no papel ou editor visual antes de codificar — Selector na raiz, cada comportamento como Sequence.
  3. Implementar ou escolher framework — Behavior Designer (Unity), Unreal BT nativo, ou BehaviorTree.CPP (C++).
  4. Criar o Blackboard com as variáveis partilhadas (saúde, posição do inimigo, waypoint actual, estado de alerta).
  5. Implementar condições como nós folha leves — apenas leituras de variáveis, sem lógica pesada.
  6. Implementar acções com retorno correcto: Success ao terminar, Running durante execução, Failure se impossível.
  7. Adicionar decoradores onde necessário — Cooldown para ataques, Timeout para movimentos, Inverter para condições negadas.
  8. Validar prioridade — comportamentos críticos à esquerda do Selector; comportamento por defeito à direita.
  9. Testar com visualização — seguir o nó ativo em tempo real no editor; confirmar que a árvore reage a estímulos.
  10. Adicionar histerese em condições oscilatórias (ex: campo de visão no limite) para evitar flickering.
  11. Extrair subtrees reutilizáveis — se um ramo tem 4+ nós, considerar extraí-lo como subtree independente.
  12. Otimizar frequency — nem todos os NPCs precisam de tick a cada frame; usar tick interval de 0.1–0.3s para NPCs secundários.
  13. 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.

Artigos Relacionados

Referências