Not a Go veteran, but I implememted a behavior tree in a little simulation game prototype. Looked very similar structurally but your version is nicer.
As an aside:
I don’t particularly like behavior trees. Not sure why, but they feel brute-force-y to me, and I find them much harder to reason about than state machines. Once you express state machines as data, they can become just as powerful and feel less fiddly.
A different thought I have that I couldn’t get around exploring is to implement behavior trees with channels (no go routines). But that’s just a vague notion.
There was a article from Russ Cox „Storing Data in Control Flow“. Maybe there‘s something there?
sh0gg0the 10 minutes ago [-]
> A different thought I have that I couldn’t get around exploring is to implement behavior trees with channels (no go routines).
I don't think "channels without go routines" are possible. One thread can't send and simultaneously receive on a channel. I remember libraries that used go channels for control flow because the code looked better this way. They had to start a second thread to make it work which is very inefficient.
Better approaches are "functions" with internal state (generators) or the relatively new stdlib iter package.
Edit: the iter package internally uses compiler quirks to implement coroutines to be able to send and receive values on the same thread.
jenniferhooley 1 days ago [-]
Yeah, I agree with you. My experience has been very similar, when the actual game logic gets complex, BT's become a bit of a maintenance nightmare, making it super hard to reason about the system flow.
I ultimately landed on a flag-driven, hierarchical state machine. Instead of implementing a full tree traversal, which requires complex flow control nodes, I use bit flags to define my system's entire set of rulesets or invariants.
A state doesn't need to ask "what do I do next?" but rather "are my activation conditions met?" These conditions are defined as bitwise flags evaluated through standard bit-masking operations (AND, OR, XOR, etc.). For example, a state might only become active if flags IN_RANGE & GOAL_IS_NPC & PATH_BLOCKED. The flags allow me to mathematically encode complex prerequisite combinations as simple integer comparisons.
I found this approach makes the transition logic nice and clean. It shifts the burden from managing the flow (which is complex) to managing the data state (which is simple and deterministic). The system still feels like a full BT - it has hierarchy and sequential logic - but the decision process is purely data-driven, which makes it really easy to reason about even when there's a many of layers of complexity for each state/substate.
rvitorper 24 hours ago [-]
Hey, thanks a lot for commenting. That seems like a nice way to encode state. Did you ever have issues with multiple states being activated at the same time? I'll take some time to explore the hierarchical state machine, as I haven't seen an example of this short of HTN. Making it data-driven seems a nice and easy way to tackle the complexity. I like that
kasanari 24 hours ago [-]
Is this not approaching how production rules/forward chaining works, or fluents in action logic? I think it is a nice system, but I am not sure if state machines are the right theoretical framework for it.
rvitorper 24 hours ago [-]
Hey, thanks a lot for the comment. I'd love to see some code about the "express state machines as data". I always had a hard time with state machines, because every time I needed to add a new node, I had to reason about too many transitions, especially when the number of states was enormous. I like the BT structure because of the modularity, but it would be nice to see some other way to implement things - always a welcome addition. Regarding the BTs with channels, I'm very curious about it. Will check the article, for sure
dgb23 13 hours ago [-]
To note, the approach in the article is basically the polar opposite from writing a data driven state machine. I default to the latter, but I think it’s worth exloring and understanding the former.
rvitorper 6 hours ago [-]
Nice. Do you have any resources on the latter? I'd really like to see how much easier is a data driver state machine
reducirimagen 10 hours ago [-]
I really like the approach to time-travel testing here. Injecting the clock directly into the BTContext is a very clean way to avoid flaky CI tests without having to mock global time interfaces.
Quick technical question: When a node returns 0 (Running), does the Supervisor strictly adhere to the fixed tick interval (like the 100ms in the example), or is there any built-in exponential backoff? I'm wondering how it handles CPU load if you have hundreds of trees just waiting on I/O. Really clean architecture, great work!
rvitorper 6 hours ago [-]
Hey, thanks a lot for the comment! The supervisor I added - which is a super simple one - ticks using the time.Ticker, which I assume only works with regular intervals. I think adding the built-in exponential backoff would be a nice thing to add, maybe making it go back to the node in a subtick manner. I haven't tested a super large tree or a combination thereof, but I think if the nodes can behave properly with respect to waiting some IO, it can definitely handle quite a large tree. It would be nice to add the capability to handle several trees, though. What do you think?
emanuele-em 1 days ago [-]
The clock injection for testing temporal nodes is a really nice touch. Most BT libraries force you to actually wait or mock everything yourself. This would work well for orchestrating retries and fallback logic in microservices, not just game AI. Any plans for parallel composite nodes?
rvitorper 24 hours ago [-]
Hey, thanks a lot for the comment. I was thinking about the parallel composite nodes and I was wondering what would make good additions to the project. What do you think? I was going to start implementing something like RequireAny and RequireAll. I was also wondering about the "parallel" side of these nodes. Does it mean they tick all children nodes or does it mean that I spawn one goroutine for each child in a wait group and wait? Would like to hear your thoughts
redoh 1 days ago [-]
[dead]
Rendered at 20:26:34 GMT+0000 (Coordinated Universal Time) with Vercel.
As an aside:
I don’t particularly like behavior trees. Not sure why, but they feel brute-force-y to me, and I find them much harder to reason about than state machines. Once you express state machines as data, they can become just as powerful and feel less fiddly.
A different thought I have that I couldn’t get around exploring is to implement behavior trees with channels (no go routines). But that’s just a vague notion.
There was a article from Russ Cox „Storing Data in Control Flow“. Maybe there‘s something there?
I don't think "channels without go routines" are possible. One thread can't send and simultaneously receive on a channel. I remember libraries that used go channels for control flow because the code looked better this way. They had to start a second thread to make it work which is very inefficient.
Better approaches are "functions" with internal state (generators) or the relatively new stdlib iter package.
Edit: the iter package internally uses compiler quirks to implement coroutines to be able to send and receive values on the same thread.
I ultimately landed on a flag-driven, hierarchical state machine. Instead of implementing a full tree traversal, which requires complex flow control nodes, I use bit flags to define my system's entire set of rulesets or invariants.
A state doesn't need to ask "what do I do next?" but rather "are my activation conditions met?" These conditions are defined as bitwise flags evaluated through standard bit-masking operations (AND, OR, XOR, etc.). For example, a state might only become active if flags IN_RANGE & GOAL_IS_NPC & PATH_BLOCKED. The flags allow me to mathematically encode complex prerequisite combinations as simple integer comparisons.
I found this approach makes the transition logic nice and clean. It shifts the burden from managing the flow (which is complex) to managing the data state (which is simple and deterministic). The system still feels like a full BT - it has hierarchy and sequential logic - but the decision process is purely data-driven, which makes it really easy to reason about even when there's a many of layers of complexity for each state/substate.
Quick technical question: When a node returns 0 (Running), does the Supervisor strictly adhere to the fixed tick interval (like the 100ms in the example), or is there any built-in exponential backoff? I'm wondering how it handles CPU load if you have hundreds of trees just waiting on I/O. Really clean architecture, great work!