
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
UtilityAggregrateandUtilityConsiderationclasses
- 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
UtilityDecisionnode 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
UtilityConsiderationwhich also acts as a base class - Our consideration adds 3 things
- A
contextIDthat we can use to grab information we need from theUtilityDecisionclass - A
curvevariable 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.gdscript - This has a few variables:
- The
actionIDis what we use to determine what action to take- ! Each action should have it’s own unique ID
- The
calculationMethodwill be discussed more later but basically it’s how we want to calculate all our scores together (add, subtract, multiply etc.) - The
factorsarray stores all our different factors (like theUtilityConsiderationclass) scoresis used to track all the different scores from ourfactors
- The
- In our
calculate_utility_score() -> floatfunction we go through each of our factors, calculating their score and then sending the list of values to ourutilityDecisionthat 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.gdscript - 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
CalculationMethodenum. 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
actionsarray stores all the different actions our AI can do - The
contextdictionary stores data needed by ourUtilityConsiderationsto 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
contextas 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@exportif you don’t need it
- The
- Next lets go over our functions:
- Our
_ready()function simply stores a reference of ourUtilityDecisionnode to all ourUtilityActionsandUtilityFactors - The
get_best_action() -> UtilityActionis what is actually called in our code to determine the best action for us to take. It has two variables,bestActionto store a reference to the actual action andhighestScoreto 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_scorefrom 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
UtilityFactorbase class? Well that’s for ourUtilityAggregrateclass! - Our
UtilityAggregrateexists 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 theUtilityAggregratecomes in! It let’s us say, hey for these considerations we want to add them first and then let ourUtilityActionadd that value to it’s calculations and do whatever with it - Let’s go over it’s variables:
calculationMethodis just like in theUtilityActionscriptfactorsis also just like theUtilityAction, an array to store all the considerations and even otherUtilityAggregratenodes
- Just like the
UtilityConsiderationscript it also has thecalculation_factor_score()function (since it inherits fromUtilityFactor) which calls thecalculate_aggregration_scores()function which uses a for loop just like theUtilityActionscript 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 theUtilityConsiderationandUtilityAggregratescripts?- 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
UtilityConsiderationscripts you have will vary based on your game, but I’ll go over the three types I have hereFirst, is the
UtilityConsiderationStatscript. This takes in a specific stat value and compares it on the curve to give it a score. Which also brings back up thecurvevariable we have, the curvesxis set between 0 and 1 while we can set ouryto 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) normalizedStatnormalizes the value whileutilityplots 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
UtilityConsiderationBooleanscript - It’s pretty simple, since it simply takes in a
boolvalue, 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
UtilityConsiderationActionIsZeroscript - 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
UtilityActionhas 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
TextEditnode and make it a variable to ourUtilityDecisionscript. Then whenever a newUtilityActionis calculated simply add the action score to theTextEditand 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
UtilityBucketcan 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
