Jump to content

Using Behavior Trees in your scripts (Kotlin)


Nullable

Recommended Posts

Overview

Behavior Trees are a way of expressing AI logic in a modular and expressive way. They are often used in video game engines for game AI development. While they are often used alongside a visual scripting tool, such as Unreal's Blueprint system, they adapt well to Tribot scripting as a means to organize your code and keep things decoupled yet cohesive.

This tutorial assumes that you already know:

  • How to set up your development environment
  • Intermediate Kotlin (lambda functions will be used a lot)
  • Basic scripting knowledge with Tribot's SDK

For this tutorial, we won't be implementing the specific code needed to create a functional script. Rather, we skip the implementation and focus on the logic.

 

Behavior Trees Explained

Fully read this article!

That article explains the semantics and flow of behavior trees very well. Read it well so that you're familiar with the most used node types "sequence" and "selector".

You may be wondering, though, why can't I just use functions and if/else chains? After all, much of the behavior tree will look like re-implementations of boolean logic. The main benefit is that you can structure the tree such that your code is sequential, yet will handle failures appropriately. 

 

Tribot-Specific stuff

Our framework has some node types not found in typical tree implementations.

  • perform - Performs the void function and always returns success
  • condition - Performs the boolean function and returns success if the boolean is true, failure otherwise
  • conditional - This is a decorator node that runs its child if the provided boolean function returns true. If not, it returns failure immediately. This node is special in that it's how you use observer aborts (which I'll explain later).

We also have a node result type called KILL, which always bubbles up regardless of parent node. This is usually used to kill entire trees under exceptional circumstances.

 

Creating our own tree

To start our own behavior tree, we simply need to do:

@TribotScriptManifest(name = "LocalTest", author = "Nullable", category = "Test")
class MyScript : TribotScript {
    override fun execute(args: String) {
        val tree = behaviorTree {
			// Here's where our logic will go
        }
        
        tree.tick() // Executes the tree
    }
}

 

This will create a tree and then execute it. Of course, it's empty, so let's actually implement something. But before we do, let's talk about lambdas, receivers, and declarative programming.

 

How does this code produce a tree?

This framework makes heavy use of Kotlin's lambdas and extension functions to create a DSL-like syntax for tree building. This structure is declarative. Declarative programming is a way of programming such that your code doesn't actually execute the behavior you write. Instead, your behavior is put inside a function which then gets executed later. Meaning, you declare your tree first, then run it. 

Here is a very basic example of declaration:

println("Hello")
        
// Declare a function
val addFunc = { int1: Int, int2: Int -> 
    // Code inside here doesn't run right now.
    println("I'm adding things!")
    int1 + int2 
}
        
println("World")
        
println(addFunc(3, 4)) // prints 7
println(addFunc(5, 5)) // prints 10

This outputs:

Hello
World
I'm adding things!
7
I'm adding things!
10

 

Not too surprising if you're familiar with functions/lambdas, right? Well, this concept is applied to the extreme with behavior trees.

 

For example:

behaviorTree { 
    sequence { 
        perform { println("Hello") }
        println("World")
    }
}.tick()

This outputs:

World
Hello

 

But why? The key concept here is that the tree is comprised of lambdas where each lambda is mutating its receiver to add tree nodes. The methods we call like "sequence" and "selector" are extension functions of the receiver. Some functions, however, use lambdas to create a behavior which is then appended to the receiver. Those sentences are probably confusing, so maybe this will help:

behaviorTree {
    // The "receiver" is just an implicit parameter that you can reference with "this". You can call methods on "this" without using the keyword
    // Right now, our receiver is an "IParentNode"
    sequence { 
        // We called the "sequence" function, which adds a "SequenceNode" to the "IParentNode" receiver
        // Now we're in a lambda that the sequence node will use to create itself. This is where we append more nodes.
        // Our receiver now is an instance of "SequenceNode" (which implements IParentNode)
        perform { 
            // The "perform" function stores this lambda in the behavior node it creates and appends the node to the receiver (the sequence node)
            // This lambda has no receiver, because it's not executed as part of the tree creation
            println("Hello") // The "perform" function creates a behavior node that always returns Success, so we don't have to return anything
        }
        
        // Wait a minute, this code isn't appending anything to the receiver. It's just... doing something....
        // Since this lambda runs on creation of the sequence node, it just runs this immediately. It will NOT run as part of the tree execution
        println("World")
    }
}.tick()

 

If you're interested more in how this actually works under the hood, you can read about Kotlin's Type-Safe Builders here.

 

Making a simple chopper with banking

Let's first assume we have these methods:

Spoiler


private fun isAtTrees(): Boolean {
        return true
    }

    private fun walkToTrees(): Boolean {
        return true
    }

    private fun walkToBank(): Boolean {
        return true
    }

    private fun depositEverythingExceptAxe(): Boolean {
        // Does nothing if the only thing in the inventory is an axe
        return true
    }

    private fun equipBestAxe(): Boolean {
        // Does nothing if the best is already in the inventory or equipped
        return true
    }

    private fun hasAxe(): Boolean {
        return true
    }

    private fun isChoppingTree(): Boolean {
        return true
    }
    
    private fun clickTree(): Boolean {
        return true
    }

Now let's write some logic.

Let's make a banking module first. This is something we could re-use in multiple parts of the script. We'll write this part as its own function:

/**
* This function can be called on an [IParentNode], which means it can be called in lambdas for nodes
* such as "sequence" and "selector"
*/
private fun IParentNode.bankNode() = sequence {

    // Ensure the bank is open
    selector {
        condition { Bank.isOpen() }
        sequence {
            // If the bank isn't open, check if we are nearby one. If not, walk to it
            selector {
                condition { Bank.isNearby() }
                condition { walkToBank() }
            }
            condition { Bank.open() } // open bank
        }
    }

    condition { depositEverythingExceptAxe() }
    condition { equipBestAxe() }

    condition { Bank.close() }
}

This module will do everything needed to properly bank. We don't really care if we need to bank in the module. We assume that if the module is executed, we need to bank. This helps us decouple our logic. Our components are reusable because what if there are multiple conditions in multiple situations where we need to bank? This module lets you use it in both spots without code duplication.

Also, it's very important to remember why we write it like this. This code will run sequentially. The output of each method is actually used. If any of the conditions return false, the node reports failure and subsequently affects the parent's behavior. For example, if "depositEverythingExceptAxe()" fails, the entire module will fail. In most cases, you'll structure your tree so that it will naturally re-loop back to this module and it will try again.

But the point remains that we can code without the "one-action-per-loop" paradigm, while still ensuring that failures don't result in doing unnecessary actions. 

Now let's do the rest of the tree:

        val tree = behaviorTree {
            // repeats the sequence until the sequence eventually returns KILL
            repeatUntil(BehaviorTreeStatus.KILL) {
                sequence {
                    // Ensure we can chop
                    selector {
                        condition { !Inventory.isFull() && hasAxe() }
                        bankNode()
                    }

                    // Ensure we are at the trees
                    selector {
                        condition { isAtTrees() }
                        condition { walkToTrees() }
                    }

                    // ensure we're chopping a tree
                    selector {
                        condition { isChoppingTree() } // chopping
                        sequence {
                            condition { clickTree() }
                            condition { Waiting.waitUntil { isChoppingTree() } }
                        }
                    }

                    // Wait until we're done chopping
                    condition { Waiting.waitUntil { !isChoppingTree() } }
                }
            }
        }

As you can see, we heavily use this pattern:

selector {
    isConditionSatisfied
    doThingToSatisfyCondition
}

This pattern ensures that we can skip steps we don't need to do. So if the condition is satisfied, great! We move on. If not, we do something to satisfy it. The behavior we use to satisfy the condition must do so, though. This is important because you really need to consider what satisfies things. For example, confirming that you clicked a rock does not mean you're mining. It just means you clicked a rock. The behavior should also wait for the mining animation and that the rock is still alive before returning success.

 

Important note

Not every single bit of your script needs to be a behavior tree node. You can write larger functions that do things without using behavior tree syntax and then encapsulate them in a node. For example, we have a method for depositing in our example. It doesn't have to use trees to do the logic of determining if you need to use the deposit all button or click the inventory and whatnot. Or it can. It's completely up to you how granular you want your trees.

In cases where you want to bubble up failures, using trees works well. But sometimes using a tree adds more overhead that you just don't need.

 

Bonus: Reactivity in your trees

One of the primary pitfalls of this behavior tree framework is that the tree does not loop after every leaf execution, which means if you're in the middle of a sequence, you're stuck in there until the sequence returns. In some cases, you need to handle events that can occur at any time during a tree.

In these cases we can use observer aborts, which will listen for a condition to become true or false and stop trees accordingly. 

Observer aborts have 3 types:

  • LowerPriority - Causes lower priority trees to abort if the condition becomes true
  • Self - Causes the tree inside the conditional to abort if the condition becomes false
  • Both - Both of the above

For example, if you need to walk through the wildy but listen for PKers and hop if one is found:

selector {
    condition { isAtDestination() }
                
    repeatUntil({ isAtDestination() }) {
        sequence {
            // If a PKer is near, abort lower priority trees
            conditional(ObserverAbort(AbortType.LowerPriority), { isPkerNear() }) {
                condition { worldHop() }
            }
                        
            // This node will abort if a PKer is near, causing the repeatuntil to repeat,
            // causing the conditional to run again, which will be true and cause a worldhop before trying to
            // walk again
            condition { walkToDestination() }
        }
    }
}

This is extremely reactive because the "isPkerNear" will run on a separate thread than the "walkToDestination()" code. When a PKer is near, the walking thread will be interrupted, which should immediately cause it to stop and hop worlds.

Of course, always remember that using the "repeatUntil" node is like using a "while" loop. You need to make sure it can always exit. In this case, we could cause an infinite loop bug if we disabled the login handler because we have nothing to exit the loop if the player is just logged out. That needs to be handled properly.

Link to comment
Share on other sites

  • 1 year later...

Join the conversation

You can post now and register later. If you have an account, sign in now to post with your account.

Guest
Reply to this topic...

×   Pasted as rich text.   Paste as plain text instead

  Only 75 emoji are allowed.

×   Your link has been automatically embedded.   Display as a link instead

×   Your previous content has been restored.   Clear editor

×   You cannot paste images directly. Upload or insert images from URL.

×
×
  • Create New...