Functional-style behavior tree in C#/Unity: simple, fast, debug-friendly, and memory-efficient behavior tree in C#/Unity.
-
Clear and Concise Behavior Tree Definition
Behavior trees are defined directly in code using functions calls and lambda expressions, resulting in clear and compact logic. -
Ease Debug
The tree definition and execution code are the same, which means you can place breakpoints inside code fragments, and they will behave as expected. No special complex "behaviour tree debugger" is required, you will use your favorite C# IDE. -
Zero memory allocation
No memory is allocated for the tree structure because it is embedded directly into the code.
No memory is allocated for delegate instances, thanks to the use of static anonymous delegates.
No memory is allocated to transfer arguments to functions due to the use functions with predefined argument sets (in earlier versions of C#) or 'params Collection' arguments (in C# 13) instead of 'params arrays'. -
High speed
No expensive features are used (e.g., garbage collection, hash tables, closures, etc.).
The implementation relies solely on function invocations, static delegates, conditional expressions, and loops. -
Minimal and highly readable code
The entire codebase consists of just a few .cs files, totaling a few hundred lines.
This example shows a simple behavior tree for an NPC on a 2D surface that can idle, approach the player, and attack.
public static class NpcFbt
{
public static void ExecuteBT(this NpcBoard b) =>
b.Sequencer( //Classic Sequencer node
static b => b.PreUpdate(), //The first child of Sequencer realized as a delegate Func<NpcBoard, Status>
static b => b.Selector( //The decond child of Sequencer is a Classic Selector node
static b => b.If( //The first child of Selector a Classic Conditional node
static b => b.PlayerDistance < 1f, //Condition
static b => b.Sequencer( //This Sequencer node is executed when the condition is true
static b => b.SetColor(Color.red),
static b => b.OscillateScale(1, 1.5f, 0.25f),
static b => b.AddForce(b.Config.baseClosePlayerForce))),
static b => b.ConditionalSequencer( //Using ConditionalSequencer instead of If + Sequencer (see above)
static b => b.PlayerDistance < 3f,
static b => b.SetColor(Color.magenta),
static b => b.SetScale(1f, 0.1f),
static b => b.AddForce(b.Config.baseClosePlayerForce)),
static b => b.ConditionalSequencer(
static b => b.PlayerDistance < 8f,
static b => b.SetColor(Color.yellow),
static b => b.SetScale(1f, 1f),
static b => b.AddForce(b.Config.baseDistantPlayerForce)),
static b => b.SetColor(Color.grey),
static b => b.SetScale(1f, 1f)));
}Key points to note:
- Classes
- NpcFbt is a custom class implementing NPC behavior tree. It is static and contains the only one extension function ExecuteBT().
- NpcBoard is a custom blackboard class created by the user that contains the data and methods related to NPC. NpcBoard instance is stored in an external container (e.g., a MonoBehaviour in Unity), which calls NpcBoard.Execute() during the update cycle.
- Methods
- Action(), Sequencer(), Selector(), and ConditionalAction() are extensions methods from this library, implementing different nodes.
- b.AddForce(), b.SetColor(), b.SetScale(), and b.PreUpdate() are defined by the user.
This implementation is simple, zero allocation and fast and focused purely on logic, making it easy to debug.
- Zero memory allocation
- static modifier before anonymous delegates guarantee avoiding closures, therefore no memory allocation required for every delegates call. Every lambda function uses the only a single internal variable, b, and there are no closures here.
- Functions with multiple arguments (Selector, Sequence, etc) avoid using params arrays definition that's why no memory allocated for these calls.
- You can set breakpoints on any anonymous delegate or tree node function. When the execution reaches these breakpoints, the debugger will pause correctly, allowing you to inspect the state at that point.
For detailed examples of using Functional Behavior Tree (FBT), see the FBT Example repository, which contains ready-to-run projects demonstrating common use cases.
- As usual, a special Status object is used as the return value for each node.
public enum Status
{
Success = 0,
Failure = 1,
Running = 2,
}- Every node is realized as a static extension function but not a class.
For example here is a full code of a Selector node:
public static Status Selector<T>(this T board,
Func<T, Status> f1,
Func<T, Status> f2,
Func<T, Status> f3 = null,
Func<T, Status> f4 = null,
Func<T, Status> f5 = null,
Func<T, Status> f6 = null,
Func<T, Status> f7 = null,
Func<T, Status> f8 = null)
{
var s = f1?.Invoke(board) ?? Status.Failure; if (s is Status.Running or Status.Success) return s;
s = f2?.Invoke(board) ?? Status.Failure; if (s is Status.Running or Status.Success) return s;
s = f3?.Invoke(board) ?? Status.Failure; if (s is Status.Running or Status.Success) return s;
s = f4?.Invoke(board) ?? Status.Failure; if (s is Status.Running or Status.Success) return s;
s = f5?.Invoke(board) ?? Status.Failure; if (s is Status.Running or Status.Success) return s;
s = f6?.Invoke(board) ?? Status.Failure; if (s is Status.Running or Status.Success) return s;
s = f7?.Invoke(board) ?? Status.Failure; if (s is Status.Running or Status.Success) return s;
s = f8?.Invoke(board) ?? Status.Failure; if (s is Status.Running or Status.Success) return s;
return s;
}- Action nodes require no implementation; any
Func<T, Status>delegate can serve as an action node. For example:
public Status AddForce(float playerForce)
{
var force = (_playerWorldPos - _body.worldCenterOfMass) * (playerForce * Time.deltaTime);
_body.AddForce(force, ForceMode.VelocityChange);
return Status.Success;
}You can install Functional Behavior Tree (FBT) in Unity using one of the following methods:
- Open your Unity project.
- Go to Window → Package Manager.
- Click the + button in the top-left corner and choose Add package from git URL....
- Enter the URL: https://github.com/dmitrybaltin/FunctionalBT.git
- Click Add. The package will be imported into your project.
- Open your Unity project.
- Go to Edit → Project Settings → Package Manager → Scoped Registries
- Add a new registry for OpenUPM:
- Name: OpenUPM
- URL:
https://package.openupm.com - Scopes:
com.baltin
- Open the Package Manager (
Window → Package Manager). - Click + → Add package from git URL... (or search in the registry if the package appears) and enter: com.baltin.fbt
- Navigate to your Unity project folder in a terminal.
- Run
git submodule add https://github.com/dmitrybaltin/FunctionalBT.git Packages/FunctionalBT
git submodule update --init --recursive
C# 9 (Unity 2021.2 and later) is required because of using static anonymous delegates.
This section explains the design philosophy, implementation details, and optimizations behind the Functional Behavior Tree (BT) library. It also highlights key constraints, the reasoning behind chosen approaches, and solutions to specific challenges.
There are many different implementations of Behavior Trees in Unity. Typically, they include a node editor, a large set of nodes, some debugging tools, and a lot of internal service code whose efficiency is hard to assess. Debugging is often a major challenge.
There are also some implementations in C#, such as Fluid Behavior Tree, but they have some drawbacks. In particular, debugging can be difficult (requiring a special debugger), the codebase is quite heavy, unnecessary memory allocations occur, and the code is not very compact.
Functional Behavior Tree is a simple software design pattern that offers the following approach.
- Instead of a node editor, you define the behavior tree inside C# using simplest and clear syntax.
- Instead of a using heavy libraries with ton of internal code, you use a thin simple pattern that is absolutely transparent for you.
- Instead of a specialized debugger, you use C# debugger inside you favorite IDE.
The library was designed with the following limitations in mind:
-
Classic Behavior Tree Execution:
- Implements a traditional behavior tree that executes completely during each game loop cycle, rather than adopting an event-driven approach.
-
Code-Only Implementation:
- Focuses solely on C# code, avoiding the need for visual editors (e.g., Behavior Designer) or custom languages (e.g., PandaBT).
-
Separation of Concerns:
- The behavior tree and the managed object (commonly referred to as the Blackboard) are treated as separate entities. Nodes operate on the Blackboard shared across all nodes within the same tree instance.
Many different implementations of Behaviour Trees in Unity involve a node editor, a large set of nodes, a runtime system, and debugging tools, along with a lot of internal service code whose efficiency is often uncertain. However, most of these implementations treat the tree as a graph, which leads to unnecessarily complex designs, where nodes are represented as objects requiring individual classes and intricate visual editors. As a result, debugging becomes challenging due to the separation of node creation and execution across various parts of the codebase, making specialized tools necessary.
To truly understand the Behaviour Tree, we should think of it as a function, not a graph.
Indeed, each node in the BT is a function (not an object!), with multiple inputs and a single output, and it doesn’t require memory (there is no need to store any state between function calls). Therefore, the tree as a whole, or any of its subtrees, is also a function that calls the functions of its child nodes.
In this library:
-
Nodes are functions, not objects:
- Each node is a function with multiple inputs and one output, avoiding the need for memory allocation.
-
Tree as a recursive function:
- The behavior tree is a recursive function that calls nested node functions.
-
Simplified debugging:
- Functional programming principles ensure clarity, making code easy to debug using standard IDE tools.
This approach results in clean, readable, and efficient code with minimal boilerplate, all while being fast and memory-efficient.
Below is a full implementation of the Functional Behavior Tree pattern, including all the classic nodes (Selector, Sequencer, Conditional, and Inverter) and the required supporting code: the Status enum and a couple of extension methods for it.
In total, the code is just over ~100 lines, including comments.
You can also find the same code in the file MainNodes.cs.
The main point here is an each node is a static function rather than an object. That's why the code is minimal and contains the core logic only:
- Every node - is the only static function, containing the required logic.
- Different boilerplate services code (for example class constructior) is not required.
- No code for Action node is required because an every static delegates Func<T, Status> can be used as an Action node.
#if !NET9_0_OR_GREATER
public enum Status
{
Success = 0,
Failure = 1,
Running = 2,
}
public static class MainNodes
{
/// <summary>
/// Classic inverter node
/// </summary>
/// <param name="board">Blackboard object</param>
/// <param name="func">Delegate receiving T and returning Status</param>
/// <returns></returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static Status Inverter<T>(this T board, Func<T, Status> func)
=> func.Invoke(board).Invert();
/// <summary>
/// Execute the given func delegate if the given condition is true
/// </summary>
/// <param name="board">Blackboard object</param>
/// <param name="condition">Condition given as a delegate returning true</param>
/// <param name="func">Action to execute if condition is true. Delegates receiving T and returning Status</param>
/// <returns></returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static Status If<T>(this T board, Func<T, bool> condition, Func<T, Status> func)
=> condition.Invoke(board) ? func.Invoke(board): Status.Failure;
/// <summary>
/// Execute the given 'func' delegate if the given condition is true
/// Else execute 'elseFunc' delegate
/// </summary>
/// <param name="board">Blackboard object</param>
/// <param name="condition">Condition given as a delegate returning true</param>
/// <param name="func">Action to execute if condition is true. Delegate receiving T and returning Status</param>
/// <param name="elseFunc">Action to execute if condition is false. Delegate receiving T and returning Status</param>
/// <returns></returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static Status If<T>(this T board, Func<T, bool> condition, Func<T, Status> func, Func<T, Status> elseFunc)
=> condition.Invoke(board) ? func.Invoke(board) : elseFunc.Invoke(board);
#if !NET9_0_OR_GREATER
/// <summary>
/// Classic selector node
/// </summary>
/// <param name="board">Blackboard object</param>
/// <param name="f1">Delegate receiving T and returning Status</param>
/// <param name="f2">Delegate receiving T and returning Status</param>
/// <param name="f3">Optional delegate receiving T and returning Status</param>
/// <param name="f4">Optional delegate receiving T and returning Status</param>
/// <param name="f5">Optional delegate receiving T and returning Status</param>
/// <param name="f6">Optional delegate receiving T and returning Status</param>
/// <param name="f7">Optional delegate receiving T and returning Status</param>
/// <param name="f8">Optional delegate receiving T and returning Status</param>
/// <returns></returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static Status Selector<T>(this T board,
Func<T, Status> f1,
Func<T, Status> f2,
Func<T, Status> f3 = null,
Func<T, Status> f4 = null,
Func<T, Status> f5 = null,
Func<T, Status> f6 = null,
Func<T, Status> f7 = null,
Func<T, Status> f8 = null)
{
var s = f1?.Invoke(board) ?? Status.Failure; if (s is Status.Running or Status.Success) return s;
s = f2?.Invoke(board) ?? Status.Failure; if (s is Status.Running or Status.Success) return s;
s = f3?.Invoke(board) ?? Status.Failure; if (s is Status.Running or Status.Success) return s;
s = f4?.Invoke(board) ?? Status.Failure; if (s is Status.Running or Status.Success) return s;
s = f5?.Invoke(board) ?? Status.Failure; if (s is Status.Running or Status.Success) return s;
s = f6?.Invoke(board) ?? Status.Failure; if (s is Status.Running or Status.Success) return s;
s = f7?.Invoke(board) ?? Status.Failure; if (s is Status.Running or Status.Success) return s;
s = f8?.Invoke(board) ?? Status.Failure; if (s is Status.Running or Status.Success) return s;
return s;
}
/// <summary>
/// Classic sequencer node
/// </summary>
/// <param name="board">Blackboard object</param>
/// <param name="f1">Delegate receiving T and returning Status</param>
/// <param name="f2">Delegate receiving T and returning Status</param>
/// <param name="f3">Optional delegate receiving T and returning Status</param>
/// <param name="f4">Optional delegate receiving T and returning Status</param>
/// <param name="f5">Optional delegate receiving T and returning Status</param>
/// <param name="f6">Optional delegate receiving T and returning Status</param>
/// <param name="f7">Optional delegate receiving T and returning Status</param>
/// <param name="f8">Optional delegate receiving T and returning Status</param>
/// <returns></returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static Status Sequencer<T>(this T board,
Func<T, Status> f1,
Func<T, Status> f2,
Func<T, Status> f3 = null,
Func<T, Status> f4 = null,
Func<T, Status> f5 = null,
Func<T, Status> f6 = null,
Func<T, Status> f7 = null,
Func<T, Status> f8 = null)
{
var s = f1?.Invoke(board) ?? Status.Success; if (s is Status.Running or Status.Failure) return s;
s = f2?.Invoke(board) ?? Status.Success; if (s is Status.Running or Status.Failure) return s;
s = f3?.Invoke(board) ?? Status.Success; if (s is Status.Running or Status.Failure) return s;
s = f4?.Invoke(board) ?? Status.Success; if (s is Status.Running or Status.Failure) return s;
s = f5?.Invoke(board) ?? Status.Success; if (s is Status.Running or Status.Failure) return s;
s = f6?.Invoke(board) ?? Status.Success; if (s is Status.Running or Status.Failure) return s;
s = f7?.Invoke(board) ?? Status.Success; if (s is Status.Running or Status.Failure) return s;
s = f8?.Invoke(board) ?? Status.Success; if (s is Status.Running or Status.Failure) return s;
return s;
}
#endifThere is one notable detail: the Selector and Sequencer functions use multiple arguments with default values, effectively acting as variadic functions, instead of relying on the classic params arrays.
This design is intentional. params arrays are inefficient because they create a new array on the heap every time the function is called.
The drawback of this approach is a limit on the maximum number of child nodes (8). In practice, however, this is rarely an issue. The number 8 was chosen based on experience and is usually sufficient for behavior tree logic. If more nodes are required, there are two simple solutions:
- Organize nodes hierarchically, placing them one inside another. Each extra "level" in the hierarchy exponentially increases the possible number of children.
- Extend the Sequencer and Selector functions by adding more input arguments (this takes about 10 minutes of coding).
C# 13 introduces a new efficient way to avoid memory allocation in variadic functions, called params Collections.
Below is an implementation of Selector() and Sequencer() using this new feature. As you can see, I used ReadOnlySpan instead of a traditional params array.
This approach completely avoids dynamic memory allocation when calling these functions.
For more details, see the official documentation on params collections.
#if NET9_0_OR_GREATER
/// <summary>
/// Classic selector node
/// </summary>
/// <param name="board">Blackboard object</param>
/// <param name="funcs">Actions returning Status</param>
/// <returns></returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static Status Selector(T board,
params ReadOnlySpan<Func<T, Status>> funcs
)
{
foreach (var f in funcs)
{
var childStatus = f?.Invoke(board) ?? Status.Failure;
if(childStatus is Status.Running or Status.Success)
return childStatus;
}
return Status.Failure;
}
/// <summary>
/// Classic sequencer node
/// </summary>
/// <param name="board">Blackboard object</param>
/// <param name="funcs">Delegates receiving T and returning Status</param>
/// <returns></returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static Status Sequencer(T board,
params ReadOnlySpan<Func<T, Status>> funcs
)
{
foreach (var f in funcs)
{
var childStatus = f?.Invoke(board) ?? Status.Success;
if (childStatus is Status.Running or Status.Failure)
return childStatus;
}
return Status.Success;
}
#endif
}However, since Unity likely won't support C# 13 in the near future, you'll need to use a different approach, as shown earlier. While this alternative is not as syntactically elegant, it is still an efficient solution for handling multiple arguments.
Below is the code from an earlier version of the pattern.
public class MyFunctionalBt : FunctionalBt <ActorBoard>
{
public MyFunctionalBt(ActorBoard board) : base(board) { }
public Status Execute()
{
return
Selector(
_ => Action(
_ => SetColor(Color.grey)),
_ => Action(
_ => SetColor(Color.red)));
}
Status SetColor(Color color)
{
Board.View.SetColor(color);
return Status.Failure;
}
}It works not so bad but unfortunatelly allocated significant memory due to:
-
Anonymous Delegates:
- Each delegate implicitly captured the
thispointer, creating newSystem.Delegateobjects on every execution.
- Each delegate implicitly captured the
-
Dynamic Arrays:
- Functions like
SequencerandSelectorusedparams, leading to heap-allocated arrays for each call.
- Functions like
Memory allocation during each game loop cycle, especially for large scenes with many NPCs, caused performance bottlenecks. By the way, Fluid Behavior Tree library have the same memory allocation problems.
I eventually switched to the following implementation:
public static class MyFunctionalBt
{
public static Status Execute(ActorBoard b) =>
b.Selector(
b => b.SetColor(Color.grey),
b => b.SetColor(Color.red));
}
public class ActorBoard
{
//.....
Status SetColor(Color color)
{
View.SetColor(color);
return Status.Failure;
}
}Using static anonymous functions (introduced in C# 9) resolves delegate memory allocation issues. Key adjustments include:
-
Static Delegates:
- Node functions (e.g.,
Sequencer,Selector) are declared as static.
- Node functions (e.g.,
-
Shared Blackboard Variable:
- The blackboard is passed explicitly as an argument, allowing consistent naming (e.g.,
b) without capturing external variables.
- The blackboard is passed explicitly as an argument, allowing consistent naming (e.g.,
Here is a perfect article about static anonimous delegates and their efficiency Understanding the cost of C# delegates
Dynamic memory allocation for params was addressed by:
-
ReadOnlySpan (C# 13):
- Uses stack-allocated collections instead of heap-allocated arrays. Example:
public Status Sequencer(ReadOnlySpan<Func<T, Status>> funcs)
Unfortunately, C# 13 features are not yet supported in Unity.
- Uses stack-allocated collections instead of heap-allocated arrays. Example:
-
Fixed Parameter Overloads:
-
Introduced functions with fixed parameter counts:
public Status Sequencer(Func<T, Status> func1, Func<T, Status>? func2 = null, ...)
-
Practical limit of 5 parameters to cover typical scenarios.
-
-
Auto-Generated Functions:
- For advanced use cases, additional overloads (e.g., for up to 20 parameters) can be generated to optimize performance further.
No actions are required to switch between "Unity mode" and "C#13 mode" because this is handled automatically using preprocessor directives (#if !NET9_0_OR_GREATER) in the code.
If C#13 is supported, it will be used automatically.
The library was designed to provide an easy-to-debug, textual (C#) solution, and this goal has been fully achieved.
The behavior tree implementation is compact, and debugging is intuitive, leveraging your IDE’s capabilities.
You can set breakpoints anywhere in the code, and they will trigger correctly when that part of the tree is executed, making it easy to trace and understand the tree’s behavior.
The BT code is extremely lightweight.
- The tree structure is not stored in memory; it is defined directly in the code as nested functions.
- No memory is allocated for delegates, as only static delegates and static functions are used.
- Passing parameters explicitly to functions, without relying on params arrays, thus avoiding heap allocations.
The code is designed for high performance as it avoids complex operations such as memory allocation suach as GS, hashtables etc. Instead, it relies on simple operations: conditional operators, basic loops, direct method calls.
These minimal operations ensure that the code runs efficiently with little overhead, and further optimization is likely unnecessary. Any performance bottlenecks are more likely to arise from other parts of your code rather than from this implementation.
If your project is fully built on DOTS and you are actively using Jobs and the Burst compiler for speed optimization, FBT could potentially become a bottleneck. The reason is that the library is built around delegates, which are reference types and not compatible with Jobs and Burst optimizations.
So far I haven’t needed to optimize this solution in practice, but there is definitely room for optimization.
Since FBT and UniTaskFBT rely on reference types (delegates), they cannot be placed entirely into Unity Jobs. However, there are several other approaches worth exploring:
-
Parallel execution of the tree.
A synchronous tree can be parallelized withParallel.For. In experiments with many NPCs this gave a 2–5x speedup (depending on device and build type, mono/IL2CPP) compared to a regularforloop. The caveat is that the code must be thread-safe, and engine functions cannot be called from insideParallel.For. -
Batching engine calls.
The tree nodes themselves are lightweight; the heavy part is engine calls triggered from the tree. One optimization is batching, e.g. usingRaycastCommand. Instead of callingPhysics.Raycastdirectly from the tree, commands can be queued and executed in a batch before the next tick, with the tree resuming on results. This also works well in combination withParallel.For. -
Running the tree on a separate thread.
In theory the tree could be executed in its own thread. In practice this likely brings all the downsides of multithreading without much benefit.
A limitation of the current solution is its handling of asynchronous operations.
For example, when performing raycasts, especially with a large number of NPCs, it is often more efficient to batch the raycasts operations. In this case, it would be ideal to pause the tree, wait for the raycast results, and then continue execution on the next frame.
To solve this problem I started a new project Unitask Functional Behavior Tree based on async functions.
The core idea has been successfully implemented, and the result is close to 100% of the expected outcome. The planned development will be evolutionary, including:
- Enhancing the library based on user feedback.
- Optimization, if good ideas arise.
- Adding more nodes.
- Improving the handling of variable argument lists.
- Attempting to reduce boilerplate code in behavior trees (although this is unlikely, it’s not ruled out).
Additionally, I’m considering the creation of a more advanced behavior tree based on the same functional principles, such as an asynchronous tree (using async functions) or an event-driven tree. However, it’s still unclear whether these more complex BT models would justify the time and effort required to implement them.
