What is the Utility AI?
Utility AI is a type of AI that takes multiple factors into account when considering a list of actions an AI could do. It averages the value of all the factors to the get the utility score for that action and the action with the highest value is what the AI should do next.
First, let’s go over some terminology:
- Utility Decision: The final decision the AI makes
- Utility Action: A possible action the AI could take
- Ex. Walk, Idle, Eat, Sleep, Attack etc.
- Utility Consideration/Factor: Some sort of factor the action considers when deciding on it’s value
- Ex. An attack action may have a consideration for the players health, the enemies health, how much damage the enemy can do, how much damage the player can do etc.
- Ex. A walk action on a tile based game may have a consideration for how far the player is, the total cost of the movement compared to how much the AI can move etc.
- Utility Aggregate: This just refers to how the values are calculated. Most commonly it’s done with multiplication, both other methods like addition, subtraction, division etc. can also be used
- Curves: Curves are often used when calculating the actions value, with curves allowing for more then just a linear approach.
- Ex. When deciding if an AI should sleep, you may not want to consider the sleep action at all until they’re sleep score is below 20. In this case you can use a curve so the sleep action will be set to a score of 0 (and therefore ignored) until the sleep score goes below 20, which it will then return a value based on the sleep score and the curve
WARNING: Utility ScoresWhen creating your utility scores, you MUST make sure the scale is the same across the board. If the scale is different, then the algorithm will not work correctly. This is why normalizing (converting a value to be between 0-1) is quite common (but of course not mandatory).
Godot Tutorial
- First, let’s look at the list of scripts we’ll need
UtilityDecision.gd
- This will provide us with the final decision on what the best action is as well as our calculation methods
UtilityAction.gd
- This will represent an action our AI can take
UtilityConsideration.gd
- This will be the base class that’s used for all the sub classes
UtilityAggregrate.gd
- This will allow use to calculate one set of considerations differently from the rest
UtilityFactor.gd
- This will be the base class our
UtilityAggregrate
andUtilityConsideration
classes
- This will be the base class our
- First let’s start with the
UtilityFactor.gd
- This scripts simply acts as a base class for others to inherit from
- It stores a reference to the
UtilityDecision
node in our scene as well as a function calledcalculate_factor_score
extends Node
class_name UtilityFactor
var utilityDecision : UtilityDecision
func calculate_factor_score() -> float:
print("WARNING: Called factor calculation from base class which is meant to be overwritten, returns 0 (UtilityFactor -> calculate_factor_score)")
return 0
- As you can see, I’ve left in a print statement to let us know that whatever class that’s inheriting from this hasn’t had their own
calculate_factor_score()
function made - Next let’s move onto the
UtilityConsideration
which also acts as a base class - Our consideration adds 3 things
- A
contextID
that we can use to grab information we need from theUtilityDecision
class - A
curve
variable that allows us to make our factor get applied to a curve (we’ll see how this is useful in our examples later) - A
calculate_consideration_score()
function that is what stores our calculation code
- A
- Since this is a base class, there are no calculations being calculated in the function, simply a warning to let us know we haven’t set one up yet
extends UtilityFactor
class_name UtilityConsideration
#region Variables
@export var contextID : String
@export var curve : Curve
#endregion
func calculate_factor_score() -> float:
return calculate_consideration_score()
func calculate_consideration_score() -> float:
print("WARNING: Called consideration calculation from base class which is meant to be overwritten, returns 0 (UtilityConsideration -> calculate_consideration_score)")
return 0
- Next let’s look at our
UtilityAction.gd
script - This has a few variables:
- The
actionID
is what we use to determine what action to take- ! Each action should have it’s own unique ID
- The
calculationMethod
will be discussed more later but basically it’s how we want to calculate all our scores together (add, subtract, multiply etc.) - The
factors
array stores all our different factors (like theUtilityConsideration
class) scores
is used to track all the different scores from ourfactors
- The
- In our
calculate_utility_score() -> float
function we go through each of our factors, calculating their score and then sending the list of values to ourutilityDecision
that calculates them all together for us before finally returning the final score
extends Node
class_name UtilityAction
#region Variables
@export var actionID : String
@export var calculationMethod : UtilityDecision.CalculationMethod
@export var factors : Array[UtilityFactor]
var scores : Array[float] = []
var utilityDecision : UtilityDecision
#endregion
func calculate_utility_score() -> float:
scores.clear()
for factor in factors:
scores.append(factor.calculate_factor_score())
return utilityDecision.calculate_final_score(calculationMethod, scores)
- Finally let’s look at our
UtilityDecision.gd
script - It’s a bit of a long one, but in reality that’s all our math functions
- First off, here is where we set-up our
CalculationMethod
enum. My version supports:- Add
- Subtract
- Multiply
- Divide
- Average
- Feel free to adjust this as you need to
- Now let’s look at it’s variables:
- The
actions
array stores all the different actions our AI can do - The
context
dictionary stores data needed by ourUtilityConsiderations
to calculate their scores (we’ll go over this more in my examples)- For example, to determine if an AI should attack we probably need to store things like it’s health, the players health, the damage it does, the damage the player does etc.
- Here I have
context
as an export, how you decide to set-up your context is up to you. You always have to update the context keys with the updated values, but being able to easily see your list of context from the editor as such may be useful for you. Feel free to remove the@export
if you don’t need it
- The
- Next lets go over our functions:
- Our
_ready()
function simply stores a reference of ourUtilityDecision
node to all ourUtilityActions
andUtilityFactors
- The
get_best_action() -> UtilityAction
is what is actually called in our code to determine the best action for us to take. It has two variables,bestAction
to store a reference to the actual action andhighestScore
to track the scores. We go through all ouractions
, calculating it’s score and then checking if it’s higher then the previous one and updating our variables accordingly - Lastly, you’ll have noticed our call to the function
calculate_final_score
from our previous scripts. This takes in the type of calculation to be done as well as the list of scores to calculate, returning the final value. We use a match statement to call the appropriate function for that calculation method
- Our
extends Node
class_name UtilityDecision
#region Vairables
enum CalculationMethod{
ADD,
SUBTRACT,
MULTIPLY,
DIVIDE,
AVERAGE
}
@export var actions : Array[UtilityAction]
@export var context : Dictionary = {} ## This stores the references to any needed variables, with a STRING being used as the key and the value being whatever the context is
#endregion
func _ready() -> void:
for action in actions:
action.utilityDecision = self
for factor in action.factors:
factor.utilityDecision = self
func get_best_action() -> UtilityAction:
var bestAction : UtilityAction = actions[0]
var highestScore : float = 0
for action in actions:
var actionScore : float = action.calculate_utility_score()
context[action.actionID] = actionScore
if actionScore > highestScore:
highestScore = actionScore
bestAction = action
return bestAction
#region Formulas
func calculate_final_score(_calculationMethod : CalculationMethod, _scores : Array[float]) -> float:
match _calculationMethod:
CalculationMethod.ADD:
return calculate_via_add(_scores)
CalculationMethod.SUBTRACT:
return calculate_via_subtraction(_scores)
CalculationMethod.MULTIPLY:
return calculate_via_multiply(_scores)
CalculationMethod.DIVIDE:
return calculate_via_division(_scores)
CalculationMethod.AVERAGE:
return calculate_via_average(_scores)
_:
print("CALCULATION METHOD INVALID OR NOT SET UP (UtilityAICalculator -> calculate_final_score)")
return 0
func calculate_via_add(_scores : Array[float]) -> float:
var totalScore : float = _scores[0]
for i in range(1, _scores.size()):
totalScore += _scores[i]
return totalScore
func calculate_via_subtraction(_scores : Array[float]) -> float:
var totalScore : float = _scores[0]
for i in range(1, _scores.size()):
totalScore -= _scores[i]
return totalScore
func calculate_via_multiply(_scores : Array[float]) -> float:
var totalScore : float = _scores[0]
for i in range(1, _scores.size()):
totalScore *= _scores[i]
return totalScore
func calculate_via_division(_scores : Array[float]) -> float:
var totalScore : float = _scores[0]
for i in range(1, _scores.size()):
totalScore /= _scores[i]
return totalScore
func calculate_via_average(_scores : Array[float]) -> float:
var totalScore : float = _scores[0]
for i in range(1, _scores.size()):
totalScore += _scores[i]
totalScore /= (_scores.size() - 1)
return totalScore
#endregion
- Okay, you may be asking at this point, what’s the point of the
UtilityFactor
base class? Well that’s for ourUtilityAggregrate
class! - Our
UtilityAggregrate
exists to give us more freedom. Let’s say we have 3 considerations for our action, our action will multiply all the final values but there are two of our considerations that we want to add together first, this is where theUtilityAggregrate
comes in! It let’s us say, hey for these considerations we want to add them first and then let ourUtilityAction
add that value to it’s calculations and do whatever with it - Let’s go over it’s variables:
calculationMethod
is just like in theUtilityAction
scriptfactors
is also just like theUtilityAction
, an array to store all the considerations and even otherUtilityAggregrate
nodes
- Just like the
UtilityConsideration
script it also has thecalculation_factor_score()
function (since it inherits fromUtilityFactor
) which calls thecalculate_aggregration_scores()
function which uses a for loop just like theUtilityAction
script to calculate the score
extends UtilityFactor
class_name UtilityAggregrate
#region Variables
@export var calculationMethod : UtilityDecision.CalculationMethod
@export var factors : Array[UtilityFactor]
#endregion
func calculate_factor_score() -> float:
return calculate_aggregration_score()
func calculate_aggregration_score() -> float:
var factorScores : Array[float] = []
for factor in factors:
factorScores.append(factor.calculate_factor_score())
return utilityDecision.calculate_final_score(calculationMethod, factorScores)
Ok, but why do I have a
calculate_factor_score()
and then a separate function it calls for both theUtilityConsideration
andUtilityAggregrate
scripts?- Good question! I just like it that way as I think the naming is more clear. There’s no real reason for it, and the code could simply all be placed in the
calculate_factor_score()
if you prefer
- Good question! I just like it that way as I think the naming is more clear. There’s no real reason for it, and the code could simply all be placed in the
And that’s it!
Now, I’ll show you how I created some considerations for my demo gif (seen at the top of the post)
Let’s take a look at my AI node in the scene tree:
As you can see, we have a few actions:
- Eat: Our AI can choose to eat based on it’s hunger score and if there’s food available
- Sleep: Our AI can take a nap if it gets real tired
- Fishing: If we’re not tired or hungry, we can just fish to pass the time
As you can see, each action has one or two considerations. The
UtilityConsideration
scripts you have will vary based on your game, but I’ll go over the three types I have hereFirst, is the
UtilityConsiderationStat
script. This takes in a specific stat value and compares it on the curve to give it a score. Which also brings back up thecurve
variable we have, the curvesx
is set between 0 and 1 while we can set oury
to whatever, in my case I set it’s max value to 100. We can then use this values to determine what value to return back- Formula:
curve.sample(utilityDecision.contect[contextID] / curve.max_value)
normalizedStat
normalizes the value whileutility
plots it on our curve
- Formula:
extends UtilityConsideration
class_name UtilityConsiderationStat
func calculate_factor_score() -> float:
return calculate_consideration_score()
func calculate_consideration_score() -> float:
var normalizedStat : float = utilityDecision.context[contextID] / curve.max_value
var utility : float = curve.sample(normalizedStat)
return utility
- The curve for my fatigue state check is actually at 0 until we hit 50% tiredness, since I don’t want my goblin to sleep if it’s not really that tired. You’ll need to mess with your curves to see what works best
- Next let’s look at my
UtilityConsiderationBoolean
script - It’s pretty simple, since it simply takes in a
bool
value, converting it into an int value (0 is false and 1 is true). I used this to check if there was food, since if there wasn’t I want to set the food action to 0 even if we’re hungry, so I make sure to multiply the state value by this value
extends UtilityConsideration
class_name UtilityConsiderationBoolean
func calculate_factor_score() -> float:
return calculate_consideration_score()
func calculate_consideration_score() -> float:
return int(utilityDecision.context[contextID])
- Lastly let’s look at my
UtilityConsiderationActionIsZero
script - This is done last, since I need the other actions to be calculated first and is used to know when we should fish. If a
UtilityAction
has a score of 0 or less, I want to return one and zero otherwise. I add these values together, so if my fatigue and eat action come back as zero, then this will return at least a 1 thus making it the best action to take
extends UtilityConsideration
class_name UtilityConsiderationActionIsZero
func calculate_factor_score() -> float:
return calculate_consideration_score()
func calculate_consideration_score() -> float:
if utilityDecision.context[contextID] <= 0:
return 1
else:
return 0
- And now that’s really it! You can see it in action in the gif at the top of the blog post. The top bar is our hunger and the bottom is our fatigue
- “But wait!” (You might be saying) “What about that debug console thing??” Don’t worry I got you
- For easy debugging, you can create a
TextEdit
node and make it a variable to ourUtilityDecision
script. Then whenever a newUtilityAction
is calculated simply add the action score to theTextEdit
and print out the final score once the loop is over
And that’s it! (for real lol) Now you have the building blocks of Utility AI. Now that hard part is figuring out how you want your calculations for all your considerations to go. Of course that doesn’t mean it ends here, there are further adjustments you can do.
- For instance I only normalize the consideration scores and not the final action scores, this may be something you’ll want to do.
- You’re game may also include a lot of options, like the sims 4, in which case adding a
UtilityBucket
can be helpful. This is where you group a bunch of actions into a certain type, you first decide what exactly it is you need to do and then choose an action from there. So our goblin can deicde between being more hungry or tired, and then there can be a list of possible actions. Like maybe our goblin wants to sleep on a healing pad if he’s injured over a normal bed
Okay, that’s it for now, maybe I’ll make a future post that includes the UtilityBucket
as well but until then you can view my other Godot 4
tutorials by selecting it under the Categories
tab on the left! (or possibly on the bottom if you’re on mobile)
Resources