All skills
gamedev-skills avatar

/ai-behavior-trees-utility-ai

@3f65124

Build a production behavior-tree runtime (Blackboard, action/condition leaves, sequence/selector/parallel composites, decorators) and a Utility AI system (response curves — linear, exponential, sigmoid, quadratic — considerations, and action evaluators), plus hybrid BT-drives-Utility agents. Use when implementing a reusable behavior-tree or utility-based decision system, or tuning enemy/NPC decisions beyond a simple FSM, or when the user mentions behavior tree, blackboard, decorator, selector, sequence, tick status, utility AI, response/scoring curve, or consideration. For choosing between FSM/BT/steering or for pathfinding, use game-ai; for Unreal's BehaviorTree/Blackboard assets, use unreal-behavior-trees.

Use this Skill: https://skilld.dev/gh/gamedev-skills/awesome-gamedev-agent-skills/ai-behavior-trees-utility-ai

This session only. Nothing lands on disk.

referencesbehavior-tree-core.md

≈3.6k tokens on demand. Your agent reads this file only when SKILL.md points to it.

Behavior Tree core — Blackboard, nodes, leaves, composites, decorators

Depth for ai-behavior-trees-utility-ai. A complete, allocation-conscious C# behavior-tree runtime. The code targets plain C# (no engine types) so it drops into Unity 6 or Godot 4 C# unchanged — bind the leaves to your engine's transform/navigation in the concrete actions.

Contract in one line: every node's Tick returns Success, Failure, or Running, and a parent decides what that means. Build the tree once at spawn; keep per-tick work allocation-free.

Blackboard — the shared state system

The Blackboard is the agent's working memory. Leaves read and write it; no node holds a reference to another. That indirection is what lets the same MoveTo action serve chase, patrol, and flee subtrees, and what makes subtrees reusable across enemy types.

using System.Collections.Generic;

// A typed key/value store. Keys are strings (or use enum/int keys to avoid hashing cost).
public sealed class Blackboard
{
    private readonly Dictionary<string, object> _values = new();

    public void Set<T>(string key, T value) => _values[key] = value!;

    public T Get<T>(string key, T fallback = default!)
        => _values.TryGetValue(key, out var v) && v is T t ? t : fallback;

    public bool TryGet<T>(string key, out T value)
    {
        if (_values.TryGetValue(key, out var v) && v is T t) { value = t; return true; }
        value = default!;
        return false;
    }

    public bool Has(string key) => _values.ContainsKey(key);
    public void Remove(string key) => _values.Remove(key);
}

For hot agents, prefer a struct-of-fields blackboard (public fields on a class) over a string dictionary — it removes hashing and boxing entirely. Use the dictionary form when designers add keys at runtime or you serialize the board.

// Zero-allocation alternative: a plain data object shared by every node on this agent.
public sealed class AgentContext
{
    public Vector2 Position;
    public Transform Target;      // null when no target
    public Vector2 Home;
    public float LastSeenTime;
    public readonly List<Vector2> Path = new();
}

The node base and status

public enum Status { Success, Failure, Running }

// Every node in the tree derives from Node. dt is the decision-step delta (may differ from frame dt).
public abstract class Node
{
    public abstract Status Tick(Blackboard bb, float dt);

    // Called when a parent stops running this node before it finished (aborted branch).
    // Override to release timers, animations, or reservations.
    public virtual void Reset() { }
}

Reset() is the half of the contract people forget. When a Selector switches from a running low-priority branch to a higher-priority one, it must Reset() the abandoned branch so a half-finished action (a playing attack animation, a reserved cover point) is cleaned up.

Base classes: leaf, composite, decorator

Three structural node kinds cover every tree:

// A leaf does the actual work; it has no children.
public abstract class Leaf : Node { }

// A composite has many children and defines how their statuses combine (see Composites below).
public abstract class Composite : Node
{
    protected readonly List<Node> Children = new();
    protected int Current;                       // resume index for Running composites

    public Composite Add(Node child) { Children.Add(child); return this; }

    public override void Reset()
    {
        Current = 0;
        foreach (var c in Children) c.Reset();
    }
}

// A decorator wraps exactly one child and transforms its status or gates it (see Decorators below).
public abstract class Decorator : Node
{
    protected Node Child = default!;
    public Decorator Wrap(Node child) { Child = child; return this; }
    public override void Reset() => Child.Reset();
}

Condition leaves — instantaneous predicates

A condition reads the blackboard and returns Success or Failure in the same tick. It never returns Running and never mutates game state. Express reusable predicates as one small class with an injected test, or subclass for named conditions.

// Generic condition: succeed when a predicate over the blackboard holds.
public sealed class Condition : Leaf
{
    private readonly System.Func<Blackboard, bool> _predicate;
    public Condition(System.Func<Blackboard, bool> predicate) => _predicate = predicate;

    public override Status Tick(Blackboard bb, float dt)
        => _predicate(bb) ? Status.Success : Status.Failure;
}

// Named condition when the check is non-trivial or reused across trees.
public sealed class CanSeeTarget : Leaf
{
    private readonly float _sightRange;
    public CanSeeTarget(float sightRange) => _sightRange = sightRange;

    public override Status Tick(Blackboard bb, float dt)
    {
        if (!bb.TryGet<Vector2>("targetPos", out var target)) return Status.Failure;
        var self = bb.Get<Vector2>("position");
        // Real games also raycast for line of sight; keep the check cheap and cache the result.
        return (target - self).sqrMagnitude <= _sightRange * _sightRange
            ? Status.Success : Status.Failure;
    }
}

Note the pre-allocated System.Func delegate: create conditions once when the tree is built, not per tick, so no closure is allocated during evaluation.

Action leaves — multi-frame work that returns Running

An action performs work and returns Running until it completes, then Success (or Failure if it can't). Returning Running is what lets a walk, an animation, or a timed wait span many ticks without the parent restarting it.

// Move toward a blackboard target; Running until within arrive radius, then Success.
public sealed class MoveToTarget : Leaf
{
    private readonly float _speed, _arriveRadius;
    public MoveToTarget(float speed, float arriveRadius)
    { _speed = speed; _arriveRadius = arriveRadius; }

    public override Status Tick(Blackboard bb, float dt)
    {
        if (!bb.TryGet<Vector2>("targetPos", out var target)) return Status.Failure;
        var pos = bb.Get<Vector2>("position");
        var offset = target - pos;
        if (offset.magnitude <= _arriveRadius) return Status.Success;   // arrived

        pos += offset.normalized * _speed * dt;
        bb.Set("position", pos);          // in an engine, drive the NavMeshAgent / CharacterBody here
        return Status.Running;            // keep going next tick
    }
}

// A timed wait — the canonical Running action, useful for patrol pauses and cooldown holds.
public sealed class Wait : Leaf
{
    private readonly float _duration;
    private float _elapsed;
    public Wait(float duration) => _duration = duration;

    public override Status Tick(Blackboard bb, float dt)
    {
        _elapsed += dt;
        if (_elapsed < _duration) return Status.Running;
        return Status.Success;
    }

    public override void Reset() => _elapsed = 0f;   // restart cleanly if the branch is re-entered
}

Wait shows why Reset() matters: its _elapsed accumulator must be cleared when the branch is abandoned and later re-entered, or the second wait finishes instantly.

The Sequence/Selector/Parallel execution logic and the decorator library build directly on these base classes — see the composites and decorators sections below.

Composites — how child statuses combine

Composites are the control flow of a tree. The two you use constantly are Sequence (AND) and Selector (OR/fallback); Parallel covers concurrent branches.

// Sequence = AND: tick children in order; stop at the first that is not Success.
// Resumes at the Running child next tick ("memory" variant).
public sealed class Sequence : Composite
{
    public override Status Tick(Blackboard bb, float dt)
    {
        for (; Current < Children.Count; Current++)
        {
            var s = Children[Current].Tick(bb, dt);
            if (s == Status.Running) return Status.Running;   // resume here next tick
            if (s == Status.Failure) { Current = 0; return Status.Failure; }
        }
        Current = 0;
        return Status.Success;                                // every child succeeded
    }
}

// Selector = OR / fallback: return the first child that is not Failure.
public sealed class Selector : Composite
{
    public override Status Tick(Blackboard bb, float dt)
    {
        for (; Current < Children.Count; Current++)
        {
            var s = Children[Current].Tick(bb, dt);
            if (s == Status.Running) return Status.Running;   // resume here next tick
            if (s == Status.Success) { Current = 0; return Status.Success; }
        }
        Current = 0;
        return Status.Failure;                                // every child failed
    }
}

Memory vs reactive. The versions above remember the Running child and resume there. That is efficient but does not let a higher-priority sibling interrupt. For a reactive selector — the common case for combat AI — re-check children from index 0 every tick and abort the running branch when an earlier child changes its mind:

// Reactive selector: earlier (higher-priority) children can preempt a lower running branch.
public sealed class ReactiveSelector : Composite
{
    private int _running = -1;
    public override Status Tick(Blackboard bb, float dt)
    {
        for (int i = 0; i < Children.Count; i++)
        {
            var s = Children[i].Tick(bb, dt);
            if (s == Status.Failure) continue;
            if (_running != -1 && _running != i) Children[_running].Reset();  // abort old branch
            _running = s == Status.Running ? i : -1;
            return s;                                       // Success or Running
        }
        _running = -1;
        return Status.Failure;
    }

    public override void Reset() { base.Reset(); _running = -1; }
}

Parallel ticks every child each step and resolves with a policy — succeed when any (or all) succeed, fail when any (or all) fail. Use it for "attack while strafing" or "play VFX while moving".

public enum ParallelPolicy { RequireOne, RequireAll }

public sealed class Parallel : Composite
{
    private readonly ParallelPolicy _success, _failure;
    public Parallel(ParallelPolicy success, ParallelPolicy failure)
    { _success = success; _failure = failure; }

    public override Status Tick(Blackboard bb, float dt)
    {
        int successes = 0, failures = 0;
        foreach (var child in Children)
        {
            var s = child.Tick(bb, dt);
            if (s == Status.Success) successes++;
            else if (s == Status.Failure) failures++;
        }
        if (_failure == ParallelPolicy.RequireOne && failures > 0) return Status.Failure;
        if (_failure == ParallelPolicy.RequireAll && failures == Children.Count) return Status.Failure;
        if (_success == ParallelPolicy.RequireOne && successes > 0) return Status.Success;
        if (_success == ParallelPolicy.RequireAll && successes == Children.Count) return Status.Success;
        return Status.Running;
    }
}

Decorators — wrap one child to change its meaning

Decorators add policy without new leaves: invert a result, force a status, gate on a cooldown, or repeat. Each wraps exactly one child.

// Inverter: Success <-> Failure (Running passes through). "NOT".
public sealed class Inverter : Decorator
{
    public override Status Tick(Blackboard bb, float dt) => Child.Tick(bb, dt) switch
    {
        Status.Success => Status.Failure,
        Status.Failure => Status.Success,
        _              => Status.Running,
    };
}

// ForceSuccess: swallow a child's failure so a Sequence keeps going (optional steps).
public sealed class ForceSuccess : Decorator
{
    public override Status Tick(Blackboard bb, float dt)
        => Child.Tick(bb, dt) == Status.Running ? Status.Running : Status.Success;
}

// Repeat: run the child up to n times (n <= 0 = forever), restarting on each Success.
public sealed class Repeat : Decorator
{
    private readonly int _count;
    private int _done;
    public Repeat(int count) => _count = count;

    public override Status Tick(Blackboard bb, float dt)
    {
        var s = Child.Tick(bb, dt);
        if (s == Status.Running) return Status.Running;
        if (s == Status.Failure) { _done = 0; return Status.Failure; }
        _done++;
        if (_count > 0 && _done >= _count) { _done = 0; return Status.Success; }
        Child.Reset();                        // loop again
        return Status.Running;
    }

    public override void Reset() { base.Reset(); _done = 0; }
}

// Cooldown: gate the child so it can only run once per interval (attack/ability rate-limiting).
public sealed class Cooldown : Decorator
{
    private readonly float _seconds;
    private float _readyAt;
    public Cooldown(float seconds) => _seconds = seconds;

    public override Status Tick(Blackboard bb, float dt)
    {
        float now = bb.Get<float>("time");
        if (now < _readyAt) return Status.Failure;     // still cooling down
        var s = Child.Tick(bb, dt);
        if (s == Status.Success) _readyAt = now + _seconds;
        return s;
    }

    public override void Reset() { base.Reset(); _readyAt = 0f; }
}

Running the tree

Wrap the root and tick it on your decision cadence. A behavior tree does not need to tick every render frame — 5–15 Hz is plenty for most NPCs and slashes CPU cost.

public sealed class BehaviorTree
{
    private readonly Node _root;
    private readonly Blackboard _bb;
    public BehaviorTree(Node root, Blackboard bb) { _root = root; _bb = bb; }

    public Status Tick(float dt)
    {
        _bb.Set("time", _bb.Get<float>("time") + dt);   // keep a clock for Cooldown/Wait
        return _root.Tick(_bb, dt);
    }
}

// Fluent assembly — build once at spawn, then tick.
var tree = new BehaviorTree(
    new ReactiveSelector()
        .Add(new Sequence()                              // combat branch (highest priority)
            .Add(new CanSeeTarget(sightRange: 12f))
            .Add(new MoveToTarget(speed: 6f, arriveRadius: 1.5f))
            .Add(new Cooldown(0.8f).Wrap(new AttackTarget())))
        .Add(new Patrol(waypoints)),                     // fallback
    blackboard);

The Patrol and AttackTarget leaves, and full agents that assemble these pieces, are in references/practical-examples.md.

Source: SKILL.md on GitHub

No alerts1mo3 checks · Risk SAFE
  • Gen Agent Trust Hub1mo

    This skill provides safe and standard implementations for Behavior Trees and Utility AI systems in C#. No malicious patterns, data exfiltration, or obfuscation were detected.

  • Socket1mo

    No alerts

  • Snyk1mo

    Risk: LOW · No issues

Signed by skilld at 3f65124. This ties the file your Agent reads to that commit on GitHub. It does not review the instructions.

Last checked against GitHub 3 days ago.

Activeupdated last month

README badge

README badge for gamedev-skills/awesome-gamedev-agent-skills/ai-behavior-trees-utility-ai