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.
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
- Modularity: Compose complex behaviors from simple building blocks
- Reusability: Share behaviors across different AI agents
- Readability: Tree structure mirrors decision-making logic
- Debuggability: Easy to visualize and trace execution
- 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!