Lesson 5

Choosing an architecture

Utility, behaviour tree and planner side by side on the same fight, how they compare on authoring, control, debugging and cost, the hybrids most shipped games end up with, and how to lay the whole thing out as ECS systems.

Three brains, one arena, the same seed. They see the same factors and use the same actions; only the way they decide differs.

Loading…

Under each arena is that brain’s reason for what it’s doing now: the top two scores and their considerations for utility, the branches tried for the tree, and the goal and plan for GOAP. Press Step to compare them a tick at a time, then run the batch. Over the 40 fights in the batch (fixed seeds, so your numbers will match):

Utility Behaviour tree GOAP
Average survival 168 s 162 s 171 s
Lasted the full 3 minutes 27 / 40 28 / 40 31 / 40
Kills per fight 21.8 19.9 21.4
Action switches / min 29 27 26
Work per tick 24 considerations 12 nodes 0.13 states expanded

They end up close, and that’s the honest result: in a small, well-understood problem, every technique works once it’s tuned. Each of these took some tuning to get here, and each failed in its own characteristic way first: the utility agent flip-flopped, the tree with memory composites ignored its health, and the planner needed its goals chosen for it. The differences that matter are in what it’s like to build and change them.

How they compare

Utility Behaviour tree GOAP
You write considerations and curves per behaviour the decision structure itself actions (needs, changes, cost) and goals
Control indirect: shape the curves direct: it does what the tree says indirect: it finds its own sequences
Adding a behaviour add it; it competes on score decide where it goes in the tree add the action; plans use it where it helps
Sequences awkward (each step is a behaviour, or an action runs its own script) natural (sequence nodes) natural (that’s what a plan is)
Nuance excellent: everything weighs in continuously thresholds and fixed priority coarse, through boolean facts
Debugging score breakdown per decision lit-up tree plan and search
Cost profile constant, every decision every tick (or on events) rare but spiky (a search)
Failure mode oscillation, opaque tuning magic numbers, sprawling trees odd plans, replanning churn

Hybrids

Most shipped games use more than one, each for what it’s good at:

  • Utility chooses, something else executes. Scores pick a high-level behaviour (“fight”, “flee”, “restock”), and each behaviour is a small behaviour tree or script that sequences the details. This keeps scoring for the decision that needs nuance and trees for the steps that need order.
  • A utility selector inside a tree. A selector node that scores its children instead of trying them in order. The tree keeps its structure and readability, and the one decision that’s genuinely a weighing of options gets curves.
  • Utility picks the goal, GOAP plans it. The planner is good at how, poor at what for. Scoring goals with considerations (“how urgent is healing?”) and planning the winner is a natural split, and close to what this arena’s GOAP agent does with its simple priority list.
  • HTN planners (hierarchical task networks) sit between trees and GOAP: designers write methods that decompose a task into subtasks, and the planner picks among them. They give back much of the control GOAP takes away, and several large action games have shipped with them.

Laying it out in an ECS

Whichever brain you use, the same three-stage pipeline works well in flecs:

// Sense: compute each agent's factors from the world. Reads many things, writes one component.
world.system<Factors, const Position, const Health, const Staff>("Sense")
    .kind(flecs::PreUpdate)
    .each([](flecs::entity e, Factors& f, const Position& p, const Health& h, const Staff& st) {
        f.health = h.value;
        f.charges = (float)st.charges;
        f.enemy_dist = nearest_enemy_distance(e.world(), p);   // via a spatial query
        // ...
    });

// Think: a few times a second, staggered. Reads factors and the shared behaviour set.
world.system<const Factors, Brain, CurrentAction>("Think")
    .kind(flecs::OnUpdate)
    .multi_threaded()
    .each([](flecs::iter& it, size_t i, const Factors& f, Brain& b, CurrentAction& act) {
        uint64_t frame = it.world().get_info()->frame_count_total;
        if ((it.entity(i).id() + frame) % ThinkInterval != 0) return;   // spread agents over frames
        act.id = b.decide(f, act);                               // utility, tree or planner
    });

// Act: every tick. One system per action reads CurrentAction and does the work.
world.system<const CurrentAction, Position, Staff>("Act")
    .kind(flecs::OnUpdate)
    .each([](flecs::entity e, const CurrentAction& a, Position& p, Staff& st) { run(a.id, e, p, st); });

Things that make this work well:

  • Thinking is per agent and read-only apart from the agent’s own brain state, so it parallelises (multi_threaded) without locks. Expensive sensing (line of sight, path queries) belongs in Sense or in async jobs, not inside considerations.
  • Decide less often than you act. Actions run every tick; decisions at 2–5 Hz, staggered by entity id, cost a fraction and also damp oscillation. Agents far from the player can think even less often (AI level of detail).
  • The current action is a value, not a tag per action, so changing your mind doesn’t move the entity between tables. If systems need to query “everyone who is attacking”, consider a DontFragment relationship to the action instead (see choosing storage).
  • Share the definitions. Behaviour sets, trees and action lists are per agent type: put them on a prefab and inherit them, so each agent stores only its own state.
  • Keep it deterministic. Random choices from per-agent, per-tick streams (hash(seed, entity, tick, purpose)), no wall-clock reads, ties broken by a stable order. Then any odd decision can be replayed and inspected. The determinism lesson has the details.

Takeaways

  • In a small problem every technique works once tuned; pick for how it is to build, change and debug.
  • Utility for nuanced choices, trees for readable control and sequences, planners for combining actions you don’t want to enumerate.
  • Hybrids are normal: utility to choose, trees or plans to execute.
  • Sense, think, act as separate systems; think less often than you act, in parallel, deterministically.
  • Whatever you build, build the debugger first.