Forester. The orchestration with behavior trees. Part I. Simulation.

Boris Zhguchev
6 min readJul 17, 2023

--

Forester

The behavior trees became quite popular in the different areas of engineering in the last several decades, especially in robotics or game design for orchestration and composing the logic of executing some independent units (as if robots in robotics or NPC in games etc)

The reason is clear enough, namely, they have a strict and understandable model and work with and much easy to separate the business and flow logic. Besides, they have only a small set of logically conjucted components making the design easier which in its turn make them relatively easy to maintain and develop.

This article is supposed to give a brief intro into Forester and why the game is worth the candle.

Why Forester

First of all, Forester is an orchestration framework that operates above the behavior trees.

The main idea and the target of Forester is to make the process of chaining a complex logic of the different tasks together effectively and easily.

Therefore, the framework provides a specific dsl above the trees allowing writing the trees in a programmatic way pursuing to avoid duplicating the code.

The framework is written with Rust and is supposed to be used in a Rust environment. However, there are some ways to implement the trees and the behavior of actions without code directly, for example, using HTTP services for that. Another way is to simulate the actions of a given tree. This way will be described further in this article.

A detailed description of the framework is available on GitHub

F-tree

The DSL is quite straightforward and resembles the other scripting languages. It has tree definitions and can invoke the definitions from the other parts. It supports imports, parameters with arguments, higher-order tree definitions etc.

Below is an example of a simple tree:

root place_ball_to_target fallback {
place_to(
what = {"x":1 },
operation = place([10]),
)
retry(5) ask_for_help()
}

sequence place_to(what:object, operation:tree){
fallback {
is_approachable(what)
do_job(approach(what))
}
fallback {
is_graspable(what)
do_job(grasp_ball(what))
}
sequence {
savepoint()
operation(..)
}
}

sequence place(where:array){
is_valid_place(where)
do_job(slowly_drop({"cord":1}))
}

sequence do_job(action:tree){
savepoint()
action(..)
savepoint()
}

cond is_approachable(obj:object);
cond is_graspable(obj:object);
cond is_valid_place(obj:object);
impl approach(obj:object);
impl grasp(obj:object);
impl ask_for_help();
impl place_to(where:array);
impl savepoint();
impl slowly_drop(params:object);

The definitions without a body are the actions that need to be implemented.

The visual representation of the tree is the following:

As can be observed, the F-tree allows using simple conceptions of tree definitions, isolating code and reducing redundancy in creating trees.

The utter description of the features of the language is available on GitHub.

Simulation

The framework provides a simple way to test the tree in detachment to the implementations of the actions. It is important to ensure the logic of the tree is correct and predictable.

To start working with the simulation mode, we need to install the console utility. Cargo can help with it:

cargo install f-tree

The framework operates with the conception of the project, namely, it requires to have a root directory(the relative imports start from the root directory), and a main file that has the root tree definition.

By default, the main file has a name main.tree, and if the file has only one root definition that definition will be selected.

Let’s create a simple tree. The example is very synthetic but it should be enough to demonstrate the ability of the framework to provide some useful information.

// there is an std library that provides a small set of helpers
import "std::actions"

// root definition and the entry point of the tree
root main sequence {
// allows to store a string in Blackboard(internal memory layer)
store_str("info1", "initial")
// the higher-order tree that accepts other trees as arguments
retryer(task(config = obj), success())
store_str("info2","finish")
}

// simple retryier that tries to execute agiven tree 5 times
// and then proceed further if the given tree is failed.
fallback retryer(t:tree, default:tree){
// decorator that will repeat the invocation of the tree up to 5 times
retry(5) t(..)
// std tree definition that always fails
fail("just should fail")
default(..)
}

// The action that has some business logic.
impl task(config: object);

The visualization of the tree is the following:

And here we need to test the logic of the tree.

Let’s create a file main.tree and place the aforementioned code there.

Then let’s create a simulation profile in that folder, let’s name it fail_sim.yaml:

config:
trace: gen/main.log
graph: gen/main.svg
bb:
dump: gen/bb.json
max_ticks: 10

actions:
-
name: task
stub: failure
params:
delay: 100

Where config denotes some artefacts that will be produced during and after the execution of the tree:

  • trace: The trace file of every step that was performed.
  • graph: the visual representation of the tree
  • bb.dump: the snapshot of the Blackboard after the process is finished.
  • max_ticks: the limit of possible ticks. This can be important when we deal with endless loops or are restricted by the resources

On the other hand, the actions denote how we can stub the tasks that are supposed to be implemented. In the script, we have only one task to implement which is called the task. We assign the failure stub to the task. That means the stub will return failure after 100 milliseconds of delay.

After that, we can navigate to the folder from the console and run the simulation in the following way:

 f-tree sim -p sim.yaml

Which returns the following record:

[2023-07-17T19:48:16Z INFO  f_tree] the process is finished with the result: Success

At this point, we can head off to the folder gen and find the following artefacts:

Trace file main.log

The file shows how the tree is executed step by step with the format

[tick_number] [id] : [Status with the given arguments]
[1]  1 : Running(cursor=0,len=1)
[1] 2 : Running(cursor=0,len=3)
[1] 3 : Success(key=info1,value=initial)
[1] 2 : Running(cursor=1,len=3)
[1] 4 : Running(cursor=0,len=3)
[1] 6 : Running(len=1)
[1] 9 : Failure(config=obj,reason=)
[1] 6 : Running(arg=2,cursor=0,len=1)
[1] 4 : Running(cursor=0,len=3,prev_cursor=0)
[1] 2 : Running(cursor=1,len=3,prev_cursor=1)
[2] next tick
[2] 2 : Running(cursor=0,len=3,prev_cursor=1)
[2] 4 : Running(cursor=0,len=3,prev_cursor=0)
[2] 6 : Running(arg=2,cursor=0,len=1)
[2] 9 : Failure(config=obj,reason=)
[2] 6 : Running(arg=3,cursor=0,len=1)
[2] 4 : Running(cursor=0,len=3,prev_cursor=0)
[2] 2 : Running(cursor=0,len=3,prev_cursor=1)
[2] 1 : Running(cursor=0,len=1)
[3] next tick
[3] 2 : Running(cursor=0,len=3,prev_cursor=1)
[3] 4 : Running(cursor=0,len=3,prev_cursor=0)
[3] 6 : Running(arg=3,cursor=0,len=1)
[3] 9 : Failure(config=obj,reason=)
[3] 6 : Running(arg=4,cursor=0,len=1)
[3] 4 : Running(cursor=0,len=3,prev_cursor=0)
[3] 2 : Running(cursor=0,len=3,prev_cursor=1)
[3] 1 : Running(cursor=0,len=1)
[4] next tick
[4] 2 : Running(cursor=0,len=3,prev_cursor=1)
[4] 4 : Running(cursor=0,len=3,prev_cursor=0)
[4] 6 : Running(arg=4,cursor=0,len=1)
[4] 9 : Failure(config=obj,reason=)
[4] 6 : Running(arg=5,cursor=0,len=1)
[4] 4 : Running(cursor=0,len=3,prev_cursor=0)
[4] 2 : Running(cursor=0,len=3,prev_cursor=1)
[4] 1 : Running(cursor=0,len=1)
[5] next tick
[5] 2 : Running(cursor=0,len=3,prev_cursor=1)
[5] 4 : Running(cursor=0,len=3,prev_cursor=0)
[5] 6 : Running(arg=5,cursor=0,len=1)
[5] 9 : Failure(config=obj,reason=)
[5] 6 : Failure(arg=5,cursor=0,len=1,reason=)
[5] 4 : Running(cursor=1,len=3,prev_cursor=0)
[5] 7 : Failure(reason=just should fail)
[5] 4 : Running(cursor=2,len=3,prev_cursor=0)
[5] 8 : Success()
[5] 4 : Success(cursor=2,len=3,prev_cursor=0)
[5] 2 : Running(cursor=2,len=3,prev_cursor=1)
[5] 5 : Success(key=info2,value=finish)
[5] 2 : Success(cursor=2,len=3,prev_cursor=1)
[5] 1 : Running(cursor=0,len=1)
[5] 1 : Success(cursor=0,len=1)

Visualization file main.svg

Blackboard snapshot bb.json

Since we place some information in the Blackboard, sometimes it is useful to see the final snapshot.

{
"storage": {
"info2": {
"Unlocked": {
"String": "finish"
}
},
"info1": {
"Unlocked": {
"String": "initial"
}
}
}
}

Thus, by having 2 simulator profiles for success and failure cases, we can cover the execution of the tree.

sim_fail.yaml

config:
trace: gen/main_fail.trace
graph: gen/main.svg
bb:
dump: gen/bb_fail.json
max_ticks: 10

actions:
-
name: task
stub: failure
params:
delay: 100

sim_success.yaml

config:
trace: gen/main_success.trace
graph: gen/main.svg
bb:
dump: gen/bb_success.json
max_ticks: 10

actions:
-
name: task
stub: success
params:
delay: 100

Of course, the exceptions are beyond the scope, but still, the logic can be easily tested without even having to write code.

Conclusion

The simulation process can be very useful in automation and manual scenarios, in testing and debugging or even designing the trees.

In the next chapter, we try to implement tries using synchronous and asynchronous actions.

--

--

Boris Zhguchev

Rust and Robotics Enthusiast. Especially fond with BT in AI and Robotics.