Decisions

Behaviour trees that decide what game characters do, with optional utility scores to pick the best choice.

Commands

Import blitz.decisions As AI gives your program these, with AI. in front. Each name leads to its part of the reference.

Functions

Types

Interfaces

Enums

blitz.decisions.utility

An optional part. Import blitz.decisions.utility As Utility as well to use these.

Functions

Types

Interfaces

Guide

Decisions helps the characters in your game decide what to do. You give a character a few things it can do, such as patrol, chase or rest, and a few questions it can ask, such as "Can I see the player?". Then you arrange them in a tree that says what to try first, and what to do instead when that doesn't work. Each frame the tree picks what the character should be doing, and keeps it doing that until something more important comes along. Game makers call this a behaviour tree.

A red guard on a blue floor chasing a yellow ball inside its ring of sight

Try it

Open patrol.bb and run it. The guard walks to and fro, resting for two seconds at each end. Move the yellow ball into its ring with the arrow keys and it chases you. Get far enough away and it gives up and goes back to its patrol.

; A guard patrols to and fro, resting at each end, and chases you when you
; come inside its ring. Arrow keys move the yellow ball. Escape quits.
Dialect "modern"
Import blitz.decisions As AI

Global guard:Int, you:Int, post:Int ; The entities the guard's actions use.

; An action: walk to the end of the patrol. It succeeds on getting there.
Type Patrol Implements AI.Action
    Field x:Float = 5 ; Where the end is.
    Method Tick:Int(seconds:Double)
        PositionEntity post, Self.x, 0, 0
        PointEntity guard, post
        MoveEntity guard, 0, 0, 2 * seconds
        If EntityDistance(guard, post) > 0.1 Then Return AI.Result.Running
        Self.x = -Self.x ; Next time, go to the other end.
        Return AI.Result.Success
    End Method
    Method Abort() ; It stopped early. There's nothing to tidy up.
    End Method
End Type

; An action: run after you. It fails, so the guard gives up, if you get away.
Type ChaseYou Implements AI.Action
    Method Tick:Int(seconds:Double)
        If EntityDistance(guard, you) > 6 Then Return AI.Result.Failure
        PointEntity guard, you
        If EntityDistance(guard, you) > 1 Then MoveEntity guard, 0, 0, 3 * seconds
        Return AI.Result.Running
    End Method
    Method Abort()
    End Method
End Type

; A condition: is the guard close enough to see you?
Type CanSeeYou Implements AI.Condition
    Method Test:Int()
        Return EntityDistance(guard, you) < 4
    End Method
End Type

Graphics3D 800, 480, 0, 2
SetBuffer BackBuffer()
Local camera:Int = CreateCamera()
CameraClsColor camera, 18, 52, 79
CameraZoom camera, 1.6
PositionEntity camera, 0, 14, -14
RotateEntity camera, 45, 0, 0
Local light:Int = CreateLight()
RotateEntity light, 50, 30, 0
Local ground:Int = CreateCube()
ScaleEntity ground, 9, 0.1, 6
PositionEntity ground, 0, -0.1, 0
EntityColor ground, 40, 100, 140
guard = CreateCylinder(16)
ScaleMesh guard, 0.4, 0.8, 0.4
PositionMesh guard, 0, 0.8, 0 ; So that it stands on the ground.
EntityColor guard, 230, 90, 70
Local ring:Int = CreateCylinder(32, True, guard) ; How far the guard can see.
ScaleEntity ring, 4, 0.05, 4
EntityAlpha ring, 0.3
you = CreateSphere(12)
ScaleMesh you, 0.4, 0.4, 0.4
PositionMesh you, 0, 0.4, 0
EntityColor you, 255, 220, 0
PositionEntity you, 0, 0, -5
post = CreatePivot()

; The guard's mind. A selector tries its branches in order, and takes the first
; that works: chase you if it can see you, or else patrol. A sequence does its
; steps one after another.
Local tree:AI.Tree = AI.CreateTree()
Local root:AI.Node = AI.AddSelector(tree, Null)
Local hunt:AI.Node = AI.AddSequence(tree, root)
Local sight:CanSeeYou = New CanSeeYou
AI.AddCondition tree, hunt, sight
Local chase:ChaseYou = New ChaseYou
Local chasing:AI.Node = AI.AddAction(tree, hunt, chase)
Local rounds:AI.Node = AI.AddSequence(tree, root)
Local patrol:Patrol = New Patrol
AI.AddAction tree, rounds, patrol
Local resting:AI.Node = AI.AddWait(tree, rounds, 2D) ; Rest for two seconds.

Local previous:Int = MilliSecs()
While Not KeyDown(1)
    ; Move yourself, then tell the guard how much time has passed. It decides
    ; what to do, and does it.
    Local now:Int = MilliSecs()
    Local seconds:Double = Double(now - previous) / 1000D
    previous = now
    MoveEntity you, (KeyDown(205) - KeyDown(203)) * 5 * seconds, 0, (KeyDown(200) - KeyDown(208)) * 5 * seconds
    AI.UpdateTree tree, seconds

    ; Ask which action or wait is running, to show what the guard is doing.
    Local doing:Str = "Patrolling"
    If AI.ActiveNode(tree) = chasing Then doing = "Chasing you!"
    If AI.ActiveNode(tree) = resting Then doing = "Resting"
    RenderWorld
    CameraProject camera, EntityX(guard), 2.6, EntityZ(guard)
    Text ProjectedX(), ProjectedY(), doing, True, True
    Text 10, 10, "Arrow keys move the yellow ball. Don't let the guard see you!"
    Flip
Wend
Delete tree ; Delete the tree first, then the actions and the condition.
Delete sight
Delete chase
Delete patrol
End

The tree has two branches. The first is a sequence: a question, "Can it see you?", and then an action, chasing you. When the answer is no, that branch fails, and the selector tries the next one: walk to the end of the patrol, then wait. The actions and the question are Types of your own, and your actions do the moving. The module only decides which of them runs, and passes on the seconds you give it.

How do I...

Write an action?

Make a Type that Implements AI.Action, with two methods. Tick does one frame's worth of the action and says how it's going: Running until it's finished, then Success, or Failure if it can't be done. Abort is called if the tree stops it part way through, because something more important came along.

Type Eat Implements AI.Action
    Field eaten:Double
    Method Tick:Int(seconds:Double)
        Self.eaten = Self.eaten + seconds
        If Self.eaten < 3D Then Return AI.Result.Running
        Self.eaten = 0D
        Return AI.Result.Success ; Three seconds later, dinner's over.
    End Method
    Method Abort()
        Self.eaten = 0D ; Stopped early, so start afresh next time.
    End Method
End Type

Ask a question first?

A condition is a Type that Implements AI.Condition, with a Test method that returns True or False. Put it first in a sequence, and the steps after it only happen when it says True.

Type Hungry Implements AI.Condition
    Method Test:Int()
        Return hunger > 50 ; hunger is a Global of the game's.
    End Method
End Type

Local hungry:Hungry = New Hungry
Local eat:Eat = New Eat
Local dinner:AI.Node = AI.AddSequence(tree, root)
AI.AddCondition tree, dinner, hungry
Local eating:AI.Node = AI.AddAction(tree, dinner, eat)

Wait a while?

AddWait adds a step that does nothing for a number of seconds, so there's no action to write. Here the pet has a nap after its dinner:

AI.AddWait tree, dinner, 1.5D

Find out what a character is doing?

ActiveNode gives you the action or wait that's running, or Null. Keep the nodes you want to check when you build the tree, as eating is above, and compare:

If AI.ActiveNode(tree) = eating Then Text 10, 10, "Eating"

Start again from the top?

ResetTree stops whatever is running, calling its Abort, and the next update starts again from the top, as when a character respawns.

AI.ResetTree tree

Give a character lots of choices?

An update does at most 64 small steps of work, so that a big tree can't stall your game. A selector with eleven or more branches that each start with a question can run out of steps before it decides, and then it carries on in the next frame. If TreeSuspended often says so, allow it more:

AI.UpdateTree tree, seconds, 256
If AI.TreeSuspended(tree) Then DebugLog "Still deciding"

Choose by scores instead?

Some choices are a matter of degree: a pet that is very hungry and a little sleepy should eat first. blitz.decisions.utility asks each option for a score from 0 to 1, and picks the highest. It doesn't flip-flop: it keeps its choice for at least a quarter of a second, and after that another option has to score more than 0.1 higher to take over. A score of 0 means "not now", and a choice whose score drops to 0 gives way at once.

; At the top of the program:
Import blitz.decisions.utility As Utility

Type Need Implements Utility.Evaluator
    Field amount:Double ; From 0 to 1. The game changes it.
    Method Score:Double()
        Return Self.amount
    End Method
End Type

Local appetite:Need = New Need
Local sleepiness:Need = New Need
Local needs:Utility.UtilitySelector = Utility.CreateUtility(Null)
Utility.AddOption needs, "eat", appetite
Utility.AddOption needs, "sleep", sleepiness
; Then, each frame:
Select Utility.UpdateUtility(needs, seconds)
    Case "eat"
        ; Head for the food bowl.
    Case "sleep"
        ; Head for the basket.
End Select

It gives back the name of its choice, or "" when every score is 0. UtilityOptions changes the quarter of a second and the 0.1. Delete the selector before the Need objects it uses.

Blip's tip

A selector asks its questions again every frame, so a more important branch can always take over. A sequence doesn't: once one of its actions is running, it goes straight back to that action each frame, without asking its question again. So an action has to notice for itself when it should stop, as ChaseYou does when you get away.

Good to know

More examples

Four more complete programs come with the module:

The reference below has every function and rule.

Reference

Needs
No other modules
Optional parts
blitz.decisions.utility
Examples
budgeted_agents.bb, guard.bb, quick_start.bb, utility_choices.bb

Unpublished 0.1.0 preview. Extended object-lifecycle-v1 (runtime 1114, module ABI 5 or newer) is required. No package dependencies. Main-thread only.

Blitz3D+ comes with this module, so any Extended program can Import blitz.decisions. Open an example and run it. No binary assets or network connection is needed.

Three complete paths

Each has a finite --verify mode that checks the advertised interaction. The application captures input, updates decisions with seconds, applies movement inside its own Action, then draws. The library never polls input or clocks.

Tree surface and ownership

Result.Success, Result.Failure, Result.Running are Int enum constants. Action supplies Tick(seconds:Double):Int and Abort(); Condition supplies Test():Int (zero is false, nonzero true). Callbacks must return promptly. Actions and conditions are borrowed and must outlive their trees. Per-agent state belongs in per-agent behaviour instances; explicitly stateless behaviours can be shared. Neither navigation nor movement is built into the library.

CreateTree():Tree returns an owned tree. Build it with AddSequence(tree,parent), AddSelector(tree,parent), AddAction(tree,parent,behaviour), AddCondition(tree,parent,condition) and AddWait(tree,parent,seconds). All return borrowed Nodes. Pass explicit Null for the single root: object optional defaults are not supported by the compiler. Only sequences/selectors accept children. Each addition creates a fresh node, so cycles/moves are not representable. Maximum depth is 4096 nodes including the root. Empty composites are rejected at first Update. Structure freezes permanently at that Update.

TreeSuspended(tree):Int is True when the latest UpdateTree used its whole work budget before reaching a decision; its Running result then means "not decided yet" rather than "an action is running". A guarded branch Seq[Cond, Action] that fails costs about six steps, so a selector with eleven or more such branches exceeds the default budget of 64; raise work if TreeSuspended is True every frame.

UpdateTree(tree,seconds,work=64):Int returns Result. A completed traversal can start again on the next call; a game may stop calling after Success/Failure. ActiveNode(tree):Node returns the currently selected running action/wait or Null. Compare identity with your stored nodes; there is no mutable public graph. ResetTree(tree) aborts running work and clears execution/time, keeping the frozen definition. Delete tree aborts and releases owned nodes/collections. Never delete a borrowed node. Delete behaviours after the tree. Null/deleted tree operations, invalid input and callback misuse report contextual RuntimeError. There is no fallible I/O, LastError, Save or Commit API. Reset is an execution operation, not a disposal alias; cleanup never completes gameplay actions.

Scheduling contract

Sequences remember their running child, including when budget suspension occurs while returning through ancestors. Selectors start at their highest priority child on each new traversal. A suspended traversal resumes before starting a new one: a tiny budget does not repeatedly test the first condition forever.

The old running leaf stays selected while a candidate's conditions are tested. Reaching a different action/wait commits replacement: detach the old leaf, deliver its single Abort if it was an action, unwind its abandoned memory path, then tick the replacement. A condition-only terminal traversal commits at its root. Candidates containing an action commit before that action's Tick even if it immediately fails. There is no rollback of callback side effects.

Work counts interpreter steps (entry, child dispatch, return propagation and abandoned-path unwind), so deep traversal/unwind can suspend. It excludes the callback's own work. No recursive traversal or per-visit frame objects. The Stack grows to the maximum visited depth and reuses that capacity. Initial structural validation and explicit Reset/Delete cost O(nodes). A normal update uses at most work interpreter steps and O(depth) retained stack space. Conditions sample the world when called; suspension does not snapshot game state.

Finite nonnegative seconds accumulate while traversal is suspended. Waits consume only their remaining duration and pass overshoot onward. An Action consumes all pending seconds because Tick does not report unused time. Successor actions in that same call receive zero seconds. New calls accumulate new time even when finishing the prior traversal. Completion discards unconsumed time; Reset intentionally discards it. Budget suspension never discards time. An accumulated-time overflow is a programming error rather than infinity. Binary64 rounding applies to time; fixed exactly representable deltas are useful in tests.

Recursive Update and owner Delete during Tick/Test/Abort are errors. Reset may be queued from Tick; it is applied after that callback returns and Update returns Running. Reset during Test/Abort is an error. Final Abort runs with the tree inactive and cannot restart/reset it. Read-only ActiveNode is available during callbacks; it reports Null during Abort. No callback runs while deleting nodes.

Optional utility selection

Import blitz.decisions.utility As Utility. It is independently usable, adds no dependency and does not import the tree core. Selection never invokes game actions.

Type Desire Implements Utility.Evaluator
    Field value:Double
    Method Score:Double()
        Return Self.value
    End Method
End Type
Local desire:Desire = New Desire
desire.value = 0.8D
Local selector:Utility.UtilitySelector = Utility.CreateUtility(Null)
Utility.AddOption selector, "heal", desire
Print Utility.UpdateUtility(selector, 0D) ; heal
Delete selector
Delete desire
DeclarationContract
Evaluator.Score():DoubleBorrowed callback, finite 0..1; zero means unavailable. Called once per update in registration order.
UtilityOptionsCaller-owned value record. switchMargin:Double=0.1D, finite 0..1; minimumHold:Double=0.25D, finite nonnegative seconds.
CreateUtility(options:UtilityOptions):UtilitySelectorExplicit Null selects defaults; validates and copies options. Caller owns the result.
AddOption(selector,name:Str,evaluator:Evaluator)Borrows evaluator; copies a nonempty, case-sensitive unique name. Add before the first update.
UpdateUtility(selector,seconds:Double):StrFinite nonnegative seconds. Returns a registered name or empty string when none is available. O(options); the only allocation is the returned name string.
UtilitySelectorPrivate factory construction and state. Delete releases registrations without Score calls or deleting evaluators.

The greatest positive score wins initially; equal scores use registration order. An available current option stays until its minimum hold has elapsed and a challenger strictly exceeds its score plus the switch margin. Equality retains the current option. Scores are capped at 1, so while the current option scores above 1 - switchMargin no challenger can displace it; it changes only when its own score falls. Use a smaller margin if top-scoring options must stay contestable. If the current score becomes zero, both rules are bypassed. All-zero scores clear selection. A new selection starts hold at zero; time before that selection does not count. Hold accumulation saturates, avoiding overflow. No random state or wall clock is involved. Very small positive scores remain available; all arithmetic follows binary64 precision.

Registration freezes at the first update, including an empty update. Evaluators can read changing game-owned state; they cannot add options, recursively update or delete their selector during Score. Score has no queued mutation facility. Return from the outer update before deleting. Share evaluators only when their state/lifetime contract makes that safe. Delete selectors before evaluators. See utility choices: H changes health, D changes danger, Space reloads the target, and the returned choice drives real state changes.

Deferrals

No parallel/concurrent nodes, decorators, generic blackboard, worker execution, graphical authoring, persistence or navigation/state adapters in v1. See licence, provenance and changelog.