A few months ago, in the early days of this blog, I noted the importance of understanding the different possible types of failures of actions, and all other tasks for that matter. This article looks into ways for your AI logic, in particular your scripts or behavior trees, to deal with these problems more elegantly.
Taking an example, the MoveTo action can fail in two ways:
Detecting that there’s no valid path to the target before executing, and bailing out cleanly without having made any changes to the world. Call these failures.
Starting movement under the belief that everything should be O.K., but then running into a variety of collision and locomotion problems due to the unpredictable nature of the world. Call these exceptions.
In the second case, the position of the actor when the MoveTo action started probably changed. You could consider it a dirty failure as the original assumptions have been broken by unwanted side-effects.
This is important to know because the parent tasks must take this into account to find an alternative course of action. In this case, the AI needs to replan what to do again based on the new position, as the nearby navigation geometry may have changed. So how do you do this in practice?
Options for Dealing with Problems
Ideally, you should try to implement your actions such that they have no side effects if they would fail shortly after without achieving their purpose, but that’s not always possible. With that in mind:
Clean failures are ideally dealt with using logic that makes minor changes to the course of action, a.k.a. local replanning. In a behavior tree for example, the nearest (ancestor) selector deals with the Fail return status and falls back to a different sub-tree.
Exceptions are not quite so straightforward. In certain cases, it’s O.K. to deal with them locally just like failures, if the exception is irrelevant to the current goal or if the fallback options are capable of dealing with different starting situations (e.g. goal-driven behaviors).
In other cases, the exceptions affect the current behavior irreparably and require whole subtrees to bail out, and finding alternatives on a completely different level.
Finding ways to deal with exceptions is tricky because so often they are special cases — by definition!
How the code should propagate these exceptions is another matter also. You have two choices:
Give your task API in the behavior tree only one return status for expressing problems, and assume this means a clean refusals to execute. Then, in the case of exceptions, provide a separate communication channel.
Model exceptions explicitly as an extra return status and require each composite task to deal with these different possible values.
In practice, the behavior tree looks similar however you implement it. But it affects the low-level BT code quite a lot… I’ve done both recently, and learnt a lot from the process!
The following notes should seem obvious in retrospect; just keep them in mind when designing your system.
Clean failures have a very specific meaning and require no extra information to communicate that nothing went wrong.
Exceptions come in many different forms, and often need additional data attached to them to explain what was the problem.
Conceptually, exceptions are dealt with at only few levels in the tree itself, regardless of how they are implemented.
Having an explicit return status for exceptions sets a good discipline and coding guideline for dealing with them consistently — which makes the code more robust.
Having an extra return type complicates the implementation of each of the composite tasks in the tree, especially when a different communication channel could be used.
With a separate API for exceptions, it’s much easier to raise them from various parts of the code rather than relying on the actions to do so.
Words of Advice
In mainstream programming, the use of exceptions as a software pattern is still debated. When it comes to behavioral logic, you have no choice but to deal with them in one form or another if you want a certain level of intelligence.
My first few implementations used no explicit exception status, which seems like the right approach in retrospect! However, the behaviors suffered because of a lack of awareness about the problem and its solution. Making the return status explicit forced me to model these special cases…
In a way, you have a choice to make about your default policy. Should all actions signal errors explicitly in the return status, and force the parent behaviors to deal with it or pass it on? Or should all actions silently fail, and communicate errors via a different channel to tasks who are interested (for example, handlers that check assumptions on a higher level)? The tree may end up looking similar, but you’ll find yourself thinking about the problem very differently.
Personally, I’d recommend establishing a standard API for them so they can be raised with additional information, but keeping this interface separate from the return status, and maintaining the discipline to raise and capture them everywhere. Either way, you must admit that dealing with exceptions is a top priority!