Why Behavior Trees for Game AI?

If you’ve ever built game AI using finite state machines, you’ve likely hit their limitations: rigid transitions, difficulty composing behaviors, and maintenance nightmares. Behavior trees offer a more flexible, modular approach that’s become the industry standard for game AI.

In this post, I’ll show you how to implement behavior trees in Go using interfaces, creating reusable AI components that can power everything from enemy NPCs to complex AI companions.

What is a Behavior Tree?

A behavior tree is a hierarchical structure that controls AI decision-making. Unlike state machines that focus on states and transitions, behavior trees focus on tasks and their execution flow. The tree is evaluated from root to leaves, with nodes returning success, failure, or running status.

graph TD Root[Selector: Root]:::lightBlue Patrol[Sequence: Patrol]:::lightGreen Combat[Sequence: Combat]:::lightGreen HasEnemy{Has Enemy?}:::lightYellow InRange{In Range?}:::lightYellow Attack[Attack]:::lightPurple Chase[Chase]:::lightPurple FindPoint[Find Patrol Point]:::lightPurple MoveTo[Move To Point]:::lightPurple Wait[Wait]:::lightPurple Root --> Combat Root --> Patrol Combat --> HasEnemy HasEnemy -->|Yes| InRange InRange -->|Yes| Attack InRange -->|No| Chase Patrol --> FindPoint FindPoint --> MoveTo MoveTo --> Wait classDef lightBlue fill:#87CEEB,stroke:#4682B4,stroke-width:2px,color:#000 classDef lightGreen fill:#90EE90,stroke:#228B22,stroke-width:2px,color:#000 classDef lightYellow fill:#FFFFE0,stroke:#FFD700,stroke-width:2px,color:#000 classDef lightPurple fill:#DDA0DD,stroke:#9370DB,stroke-width:2px,color:#000

Core Behavior Tree Interface

Let’s start with the foundational interface:

package main

import (
    "fmt"
    "math"
    "math/rand"
    "time"
)

// Status represents the result of a behavior execution
type Status int

const (
    Success Status = iota
    Failure
    Running
)

func (s Status) String() string {
    return [...]string{"Success", "Failure", "Running"}[s]
}

// Behavior is the core interface for all behavior tree nodes
type Behavior interface {
    Tick(ctx *Context) Status
    Reset()
}

// Context holds shared data for behavior execution
type Context struct {
    Blackboard map[string]interface{}
    DeltaTime  float64
    Entity     *Entity
}

// Entity represents a game entity (enemy, NPC, etc.)
type Entity struct {
    ID       string
    Position Vector2
    Target   *Entity
    Health   float64
    MaxHealth float64
    Speed    float64
    AttackRange float64
    DetectionRange float64
    LastAttackTime time.Time
    AttackCooldown time.Duration
}

type Vector2 struct {
    X, Y float64
}

func (v Vector2) Distance(other Vector2) float64 {
    dx := v.X - other.X
    dy := v.Y - other.Y
    return math.Sqrt(dx*dx + dy*dy)
}

func NewContext(entity *Entity) *Context {
    return &Context{
        Blackboard: make(map[string]interface{}),
        Entity:     entity,
    }
}

Composite Nodes: Sequence and Selector

These control flow nodes determine how child behaviors are executed:

// Sequence executes children in order until one fails
type Sequence struct {
    children []Behavior
    current  int
}

func NewSequence(children ...Behavior) *Sequence {
    return &Sequence{children: children}
}

func (s *Sequence) Tick(ctx *Context) Status {
    for s.current < len(s.children) {
        status := s.children[s.current].Tick(ctx)

        switch status {
        case Failure:
            s.Reset()
            return Failure
        case Running:
            return Running
        case Success:
            s.current++
        }
    }

    s.Reset()
    return Success
}

func (s *Sequence) Reset() {
    s.current = 0
    for _, child := range s.children {
        child.Reset()
    }
}

// Selector executes children until one succeeds
type Selector struct {
    children []Behavior
    current  int
}

func NewSelector(children ...Behavior) *Selector {
    return &Selector{children: children}
}

func (s *Selector) Tick(ctx *Context) Status {
    for s.current < len(s.children) {
        status := s.children[s.current].Tick(ctx)

        switch status {
        case Success:
            s.Reset()
            return Success
        case Running:
            return Running
        case Failure:
            s.current++
        }
    }

    s.Reset()
    return Failure
}

func (s *Selector) Reset() {
    s.current = 0
    for _, child := range s.children {
        child.Reset()
    }
}

Decorator Nodes

Decorators modify the behavior of their child node:

// Inverter reverses the success/failure status
type Inverter struct {
    child Behavior
}

func NewInverter(child Behavior) *Inverter {
    return &Inverter{child: child}
}

func (i *Inverter) Tick(ctx *Context) Status {
    status := i.child.Tick(ctx)
    switch status {
    case Success:
        return Failure
    case Failure:
        return Success
    default:
        return status
    }
}

func (i *Inverter) Reset() {
    i.child.Reset()
}

// Repeater runs the child N times or until failure
type Repeater struct {
    child      Behavior
    count      int
    maxRepeats int
}

func NewRepeater(child Behavior, maxRepeats int) *Repeater {
    return &Repeater{
        child:      child,
        maxRepeats: maxRepeats,
    }
}

func (r *Repeater) Tick(ctx *Context) Status {
    for r.count < r.maxRepeats {
        status := r.child.Tick(ctx)

        if status == Failure {
            r.Reset()
            return Failure
        }

        if status == Running {
            return Running
        }

        r.count++
        r.child.Reset()
    }

    r.Reset()
    return Success
}

func (r *Repeater) Reset() {
    r.count = 0
    r.child.Reset()
}

// UntilFail runs child until it fails
type UntilFail struct {
    child Behavior
}

func NewUntilFail(child Behavior) *UntilFail {
    return &UntilFail{child: child}
}

func (u *UntilFail) Tick(ctx *Context) Status {
    status := u.child.Tick(ctx)
    if status == Failure {
        u.Reset()
        return Success
    }
    return Running
}

func (u *UntilFail) Reset() {
    u.child.Reset()
}

Condition Nodes

Conditions check the state of the world:

// HasTarget checks if entity has a target
type HasTarget struct{}

func (h *HasTarget) Tick(ctx *Context) Status {
    if ctx.Entity.Target != nil {
        return Success
    }
    return Failure
}

func (h *HasTarget) Reset() {}

// IsInRange checks if target is within specified range
type IsInRange struct {
    rangeDistance float64
}

func NewIsInRange(rangeDistance float64) *IsInRange {
    return &IsInRange{rangeDistance: rangeDistance}
}

func (i *IsInRange) Tick(ctx *Context) Status {
    if ctx.Entity.Target == nil {
        return Failure
    }

    distance := ctx.Entity.Position.Distance(ctx.Entity.Target.Position)
    if distance <= i.rangeDistance {
        return Success
    }
    return Failure
}

func (i *IsInRange) Reset() {}

// HealthBelow checks if health is below threshold
type HealthBelow struct {
    threshold float64
}

func NewHealthBelow(threshold float64) *HealthBelow {
    return &HealthBelow{threshold: threshold}
}

func (h *HealthBelow) Tick(ctx *Context) Status {
    if ctx.Entity.Health < h.threshold {
        return Success
    }
    return Failure
}

func (h *HealthBelow) Reset() {}

// CanAttack checks if attack cooldown has passed
type CanAttack struct{}

func (c *CanAttack) Tick(ctx *Context) Status {
    if time.Since(ctx.Entity.LastAttackTime) >= ctx.Entity.AttackCooldown {
        return Success
    }
    return Failure
}

func (c *CanAttack) Reset() {}

Action Nodes

Actions perform actual game logic:

// FindTarget searches for nearby enemies
type FindTarget struct {
    searchRadius float64
    enemies      []*Entity
}

func NewFindTarget(searchRadius float64, enemies []*Entity) *FindTarget {
    return &FindTarget{
        searchRadius: searchRadius,
        enemies:      enemies,
    }
}

func (f *FindTarget) Tick(ctx *Context) Status {
    var closest *Entity
    minDistance := f.searchRadius

    for _, enemy := range f.enemies {
        if enemy == ctx.Entity {
            continue
        }

        distance := ctx.Entity.Position.Distance(enemy.Position)
        if distance < minDistance {
            closest = enemy
            minDistance = distance
        }
    }

    if closest != nil {
        ctx.Entity.Target = closest
        fmt.Printf("[%s] Found target: %s at distance %.2f\n",
            ctx.Entity.ID, closest.ID, minDistance)
        return Success
    }

    return Failure
}

func (f *FindTarget) Reset() {}

// MoveToTarget moves entity toward its target
type MoveToTarget struct {
    minDistance float64
}

func NewMoveToTarget(minDistance float64) *MoveToTarget {
    return &MoveToTarget{minDistance: minDistance}
}

func (m *MoveToTarget) Tick(ctx *Context) Status {
    if ctx.Entity.Target == nil {
        return Failure
    }

    distance := ctx.Entity.Position.Distance(ctx.Entity.Target.Position)
    if distance <= m.minDistance {
        return Success
    }

    // Calculate direction and move
    dx := ctx.Entity.Target.Position.X - ctx.Entity.Position.X
    dy := ctx.Entity.Target.Position.Y - ctx.Entity.Position.Y
    length := math.Sqrt(dx*dx + dy*dy)

    if length > 0 {
        dx /= length
        dy /= length

        speed := ctx.Entity.Speed * ctx.DeltaTime
        ctx.Entity.Position.X += dx * speed
        ctx.Entity.Position.Y += dy * speed

        fmt.Printf("[%s] Moving toward %s (distance: %.2f)\n",
            ctx.Entity.ID, ctx.Entity.Target.ID, distance)
    }

    return Running
}

func (m *MoveToTarget) Reset() {}

// Attack performs an attack
type Attack struct{}

func (a *Attack) Tick(ctx *Context) Status {
    if ctx.Entity.Target == nil {
        return Failure
    }

    if time.Since(ctx.Entity.LastAttackTime) < ctx.Entity.AttackCooldown {
        return Failure
    }

    damage := 10.0 + rand.Float64()*5.0
    ctx.Entity.Target.Health -= damage
    ctx.Entity.LastAttackTime = time.Now()

    fmt.Printf("[%s] Attacked %s for %.2f damage (health: %.2f/%.2f)\n",
        ctx.Entity.ID, ctx.Entity.Target.ID, damage,
        ctx.Entity.Target.Health, ctx.Entity.Target.MaxHealth)

    if ctx.Entity.Target.Health <= 0 {
        fmt.Printf("[%s] Defeated %s!\n", ctx.Entity.ID, ctx.Entity.Target.ID)
        ctx.Entity.Target = nil
        return Success
    }

    return Success
}

func (a *Attack) Reset() {}

// Patrol moves to random positions
type Patrol struct {
    patrolRadius float64
    targetPos    *Vector2
    arrived      bool
}

func NewPatrol(patrolRadius float64) *Patrol {
    return &Patrol{patrolRadius: patrolRadius}
}

func (p *Patrol) Tick(ctx *Context) Status {
    if p.targetPos == nil || p.arrived {
        // Find new patrol point
        angle := rand.Float64() * 2 * math.Pi
        distance := rand.Float64() * p.patrolRadius

        p.targetPos = &Vector2{
            X: ctx.Entity.Position.X + math.Cos(angle)*distance,
            Y: ctx.Entity.Position.Y + math.Sin(angle)*distance,
        }
        p.arrived = false

        fmt.Printf("[%s] New patrol point: (%.2f, %.2f)\n",
            ctx.Entity.ID, p.targetPos.X, p.targetPos.Y)
    }

    // Move toward patrol point
    distance := ctx.Entity.Position.Distance(*p.targetPos)
    if distance < 1.0 {
        p.arrived = true
        return Success
    }

    dx := p.targetPos.X - ctx.Entity.Position.X
    dy := p.targetPos.Y - ctx.Entity.Position.Y
    length := math.Sqrt(dx*dx + dy*dy)

    if length > 0 {
        dx /= length
        dy /= length

        speed := ctx.Entity.Speed * ctx.DeltaTime * 0.5 // Patrol slower
        ctx.Entity.Position.X += dx * speed
        ctx.Entity.Position.Y += dy * speed
    }

    return Running
}

func (p *Patrol) Reset() {
    p.targetPos = nil
    p.arrived = false
}

// Wait action
type Wait struct {
    duration time.Duration
    startTime time.Time
    started   bool
}

func NewWait(duration time.Duration) *Wait {
    return &Wait{duration: duration}
}

func (w *Wait) Tick(ctx *Context) Status {
    if !w.started {
        w.startTime = time.Now()
        w.started = true
        fmt.Printf("[%s] Waiting for %v\n", ctx.Entity.ID, w.duration)
    }

    if time.Since(w.startTime) >= w.duration {
        w.Reset()
        return Success
    }

    return Running
}

func (w *Wait) Reset() {
    w.started = false
}

Building Complete AI Behaviors

Now let’s compose these nodes into complete AI behaviors:

// BuildEnemyAI creates a complete enemy behavior tree
func BuildEnemyAI(enemies []*Entity) Behavior {
    // Combat behavior: find target, chase, attack
    combat := NewSequence(
        NewFindTarget(20.0, enemies),
        NewSelector(
            // Try to attack if in range
            NewSequence(
                NewIsInRange(3.0),
                &CanAttack{},
                &Attack{},
            ),
            // Otherwise chase
            NewMoveToTarget(2.0),
        ),
    )

    // Patrol behavior: wander around when no enemies
    patrol := NewSequence(
        NewPatrol(15.0),
        NewWait(2*time.Second),
    )

    // Flee behavior: run away when health is low
    flee := NewSequence(
        NewHealthBelow(30.0),
        NewInverter(NewMoveToTarget(15.0)), // Move away from target
    )

    // Root selector: try flee, then combat, then patrol
    root := NewSelector(
        flee,
        combat,
        NewUntilFail(patrol),
    )

    return root
}

func main() {
    fmt.Println("=== Behavior Tree AI Demo ===\n")

    // Create entities
    player := &Entity{
        ID:       "Player",
        Position: Vector2{X: 0, Y: 0},
        Health:   100,
        MaxHealth: 100,
    }

    enemy1 := &Entity{
        ID:             "Enemy-1",
        Position:       Vector2{X: 10, Y: 10},
        Health:         100,
        MaxHealth:      100,
        Speed:          5.0,
        AttackRange:    3.0,
        DetectionRange: 20.0,
        AttackCooldown: 1 * time.Second,
    }

    enemy2 := &Entity{
        ID:             "Enemy-2",
        Position:       Vector2{X: -8, Y: 12},
        Health:         100,
        MaxHealth:      100,
        Speed:          4.5,
        AttackRange:    3.0,
        DetectionRange: 20.0,
        AttackCooldown: 1 * time.Second,
    }

    entities := []*Entity{player, enemy1, enemy2}

    // Build AI for enemies
    ai1 := BuildEnemyAI(entities)
    ai2 := BuildEnemyAI(entities)

    ctx1 := NewContext(enemy1)
    ctx2 := NewContext(enemy2)

    // Simulation loop
    fmt.Println("--- Starting Simulation ---\n")

    for tick := 0; tick < 20; tick++ {
        fmt.Printf("\n=== Tick %d ===\n", tick)

        ctx1.DeltaTime = 0.1
        ctx2.DeltaTime = 0.1

        // Update AI
        status1 := ai1.Tick(ctx1)
        status2 := ai2.Tick(ctx2)

        fmt.Printf("Enemy-1 status: %v\n", status1)
        fmt.Printf("Enemy-2 status: %v\n", status2)

        // Simulate some delay
        time.Sleep(100 * time.Millisecond)

        // Stop if player is defeated
        if player.Health <= 0 {
            fmt.Println("\n=== Player Defeated! ===")
            break
        }
    }

    fmt.Println("\n=== Simulation Complete ===")
}

Advanced Patterns: Blackboard Communication

The blackboard allows behaviors to share data:

// SetBlackboardValue stores a value
type SetBlackboardValue struct {
    key   string
    value interface{}
}

func NewSetBlackboardValue(key string, value interface{}) *SetBlackboardValue {
    return &SetBlackboardValue{key: key, value: value}
}

func (s *SetBlackboardValue) Tick(ctx *Context) Status {
    ctx.Blackboard[s.key] = s.value
    return Success
}

func (s *SetBlackboardValue) Reset() {}

// CheckBlackboardValue checks if a value matches
type CheckBlackboardValue struct {
    key      string
    expected interface{}
}

func NewCheckBlackboardValue(key string, expected interface{}) *CheckBlackboardValue {
    return &CheckBlackboardValue{key: key, expected: expected}
}

func (c *CheckBlackboardValue) Tick(ctx *Context) Status {
    value, exists := ctx.Blackboard[c.key]
    if !exists {
        return Failure
    }

    if value == c.expected {
        return Success
    }
    return Failure
}

func (c *CheckBlackboardValue) Reset() {}

Benefits of Behavior Trees

  1. Modularity: Compose complex behaviors from simple building blocks
  2. Reusability: Share behaviors across different AI agents
  3. Readability: Tree structure mirrors decision-making logic
  4. Debuggability: Easy to visualize and trace execution
  5. Flexibility: Swap behaviors at runtime without code changes

When to Use Behavior Trees

Behavior trees excel when:

  • Building complex AI with multiple behaviors
  • Creating reusable AI components
  • Needing clear decision-making hierarchies
  • Working with designers who need visual tools
  • Implementing reactive AI that responds to changing conditions

Thank you

Behavior trees combined with Go’s interface system create powerful, modular AI systems. They’re perfect for game development where you need flexible, composable behaviors that can adapt to changing game states.

Please drop an email at [email protected] if you would like to share any feedback or suggestions. Peace!