AI-controlled battlers

The next lessons are dedicated to Artificial Intelligence (AI). We finally have everything in place to design it.

The AI is going to work in the following way:

  1. At the start of each turn, the AI gathers information about the battlefield.
  2. It then chooses an action and a set of targets based on that information.

We are going to use another programming pattern here called subclass sandbox. In short, it consists of using inheritance to have a base class with all the methods we need to design different kinds of AIs. Then, we inherit from that class to define specific behaviors, like an AI that favors healing or that is aggressive.

There’s once again a great guide dedicated to it in the game programming patterns ebook: Subclass Sandbox

In this lesson, we will write the code that gathers information about the state of the battlefield.

Probing the battlefield

The state of combat changes after every battler’s turn. The information that’s relevant to an A also depends on its own stats and actions.

That is why we will have functions that get called at the start of every turn and calculate the battlefield’s state.

Gathering information means making whatever calculations you want to use for your AI. We will see a few here, but the ones you will need in your project entirely depend on how you want your battlers to behave.

Let’s start by defining some properties and values the AI needs to get from the outside.

class_name BattlerAI
extends Node

# The actor that has this AI brain.
var _actor: Battler
# An array of battlers in the AI's party, including the `_actor`.
var _party := []
# An array of battlers that are in the opposing party.
var _opponents := []


# Filters and saves the list of party members and opponents.
func setup(actor: Battler, battlers: Array) -> void:
    _actor = actor
    for battler in battlers:
        var is_opponent: bool = battler.is_party_member != actor.is_party_member
        if is_opponent:
            _opponents.append(battler)
        else:
            _party.append(battler)

Here is the _gather_information() function. It calls many other functions and stores their return values in a dictionary.

Pay attention to the info dictionary’s keys below, which represent the information we calculate and store. We will then look at individual functions.

# Finds key information about the state of the battle so the agent can take a decision.
func _gather_information() -> Dictionary:
    # We first filter available actions, that is, actions the agent can use this turn.
    var actions := _get_available_actions()
    var attack_actions := _get_attack_actions_from(actions)
    var defensive_actions := _get_defensive_actions_from(actions)

    # We then populate a dictionary with the information we want the agent to have.
    var info := {
        weakest_target = _get_battler_with_lowest_health(_opponents),
        weakest_ally = _get_battler_with_lowest_health(_party),
        health_status = _get_health_status(_actor),
        fallen_party_count = _count_fallen_party(),
        fallen_opponents_count = _count_fallen_opponents(),
        available_actions = actions,
        attack_actions = attack_actions,
        defensive_actions = defensive_actions,
        strongest_action = _find_most_damaging_action_from(attack_actions),
    }
    return info

Most of these functions involve comparison, counting, or arithmetics. Let’s go through them in order, starting with filtering actions. All three methods below work in a similar way: for each action in an array, we append it to a new array if it meets a condition.

# Returns an array of actions the agent can use this turn.
func _get_available_actions() -> Array:
    var actions := []
    # Note that `Battler.actions` is an array of `ActionData`.
    for action in _actor.actions:
        # We call `ActionData.can_be_used_by()` to ensure the agent can use the action this turn.
        if action.can_be_used_by(_actor):
            actions.append(action)
    return actions


# Returns actions of type `AttackActionData` in `available_actions`.
func _get_attack_actions_from(available_actions: Array) -> Array:
    var attack_actions := []
    for action in available_actions:
        if action is AttackActionData:
            attack_actions.append(action)
    return attack_actions


# Returns actions that are *not* of type `AttackActionData` in `available_actions`.
func _get_defensive_actions_from(available_actions: Array) -> Array:
    var defensive_actions := []
    for action in available_actions:
        if not action is AttackActionData:
            defensive_actions.append(action)
    return defensive_actions

That’s how we get actions, attack_actions, and defensive_actions in _gather_information().

Next is finding the ally and opponent with the lowest health, called respectively weakest_ally and weakest_target above.

# Returns the battler with the lowest health in the `battlers` array.
func _get_battler_with_lowest_health(battlers: Array) -> Battler:
    var weakest: Battler = battlers[0]
    # We loop over battlers and compare their health with the current lowest.
    for battler in battlers:
        # If the current battler has a lower health than `weakest`, we assign it to `weakest`.
        if battler.stats.health < weakest.stats.health:
            weakest = battler
    return weakest

We define arbitrary health percentages for the health status we can later use for the AI to make decisions. For instance, it could suicide when its health is critical. Here, you could instead store the agent’s remaining health ratio.

enum HealthStatus { CRITICAL, LOW, MEDIUM, HIGH, FULL }


# Returns `true` if the `battler`'s health is below a given ratio.
func _is_health_below(battler: Battler, ratio: float) -> bool:
    # We ensure the ratio is in the [0.0, 1.0] range.
    ratio = clamp(ratio, 0.0, 1.0)
    return battler.stats.health < battler.stats.max_health * ratio


# Returns a member of the `HealthStatus` enum depending of the agent's current health ratio.
func _get_health_status(battler: Battler) -> int:
    if _is_health_below(battler, 0.1):
        return HealthStatus.CRITICAL
    elif _is_health_below(battler, 0.3):
        return HealthStatus.LOW
    elif _is_health_below(battler, 0.6):
        return HealthStatus.MEDIUM
    elif _is_health_below(battler, 1.0):
        return HealthStatus.HIGH
    else:
        return HealthStatus.FULL

Next up is counting fallen allies and opponents. Both functions follow a similar logic. To implement it, we could check if the battler’s health is at 0, but let’s add a method to the Battler class.

Open Battler.gd and add the is_fallen() method.

func is_fallen() -> bool:
    return stats.health == 0

Back to BattlerAI.gd, add:

# Returns the count of fallen party members.
func _count_fallen_party() -> int:
    var count := 0
    for ally in _party:
        if ally.is_fallen():
            count += 1
    return count


# Returns the count of fallen opponents.
func _count_fallen_opponents() -> int:
    var count := 0
    for opponent in _opponents:
        if opponent.is_fallen():
            count += 1
    return count

Next is finding the most damaging attack action. Below, we only calculate theoretical maximum damage. We don’t account for a potential target’s defense or other stats like evasion.

func _find_most_damaging_action_from(attack_actions: Array):
    var strongest_action
    # The strongest action is the one that will inflict the most total damage, in theory.
    var highest_damage := 0
    for action in attack_actions:
        var total_damage = action.calculate_potential_damage_for(_actor)
        # We loop over all actions and always keep the one with the highest potential damage.
        if total_damage > highest_damage:
            strongest_action = action
            highest_damage = total_damage
    return strongest_action

You can see we need a calculate_potential_damage_for() method on our action data. Open AttackActionData.gd and add this method.

# Returns the total damage for the action, factoring in damage dealt by a status effect.
func calculate_potential_damage_for(battler) -> int:
    var total_damage: int = int(Formulas.calculate_potential_damage(self, battler))
    return total_damage

We will later extend it with potential damage dealt by a status effect.

You can calculate the potential of each action over each target in different ways. For instance, you could calculate a score for each of them based on the battlers’ stats and other values. Whatever best serves your game’s design.

Back to BattlerAI.gd, let’s wrap up with some more functions for now. Note that you can add more as you move forward with the project: whatever allows you to better define your AI’s behavior. Here are two more. One is the opposite of _is_health_below(), and the other tells you if a battler is weak to a given action.

# Returns true if the `battler`'s health ratio is above `ratio`.
func _is_health_above(battler: Battler, ratio: float) -> bool:
    ratio = clamp(ratio, 0.0, 1.0)
    return battler.stats.health > battler.stats.max_health * ratio


# Returns `true` if the `battler` is weak to the `action`'s element.
func _is_weak_to(battler: Battler, action: ActionData) -> bool:
    return action.element in battler.stats.weaknesses

These two can always come in handy. For example, if the AI’s health is above a given threshold, it can use one strategy, and another one when its health becomes low.

Here is one more useful information we can calculate using _is_weak_to(): storing actions that are effective against a given opponent.

# For each ActionData key, stores an array of opponents that are weak to this action.
var _weaknesses_dict := {}


# We extend the `setup()` function to initialize our `_weaknesses_dict`.
func setup(actor: Battler, battlers: Array) -> void:
    #...
    _calculate_weaknesses()


func _calculate_weaknesses() -> void:
    for action in _actor.actions:
        # This initializes the dictionary `_weaknesses_dict`, creating a key for each `action`.
        _weaknesses_dict[action] = []
        for opponent in _opponents:
            # We loop over each action and opponent. If the opponent is weak to the action,
            # we store it in our list of weak opponents.
            if _is_weak_to(opponent, action):
                _weaknesses_dict[action].append(opponent)

Notice how our AI is becoming omniscient. It knows which opponent is the weakest and which action is effective against whom.

The easiest way to code an artificial intelligence in game is to start with perfect information. You can then introduce a random bias or patterns in the agent’s decisions to make it interesting to battle against.

The AI’s code so far

Here is all the code we wrote in this lesson:

class_name BattlerAI
extends Node

enum HealthStatus { CRITICAL, LOW, MEDIUM, HIGH, FULL }

var _actor: Battler
var _party := []
var _opponents := []

var _weaknesses_dict := {}


func setup(actor: Battler, battlers: Array) -> void:
    _actor = actor
    for battler in battlers:
        var is_opponent: bool = battler.is_party_member != actor.is_party_member
        if is_opponent:
            _opponents.append(battler)
        else:
            _party.append(battler)
    _calculate_weaknesses()


func _gather_information() -> Dictionary:
    var actions := _get_available_actions()
    var attack_actions := _get_attack_actions_from(actions)
    var defensive_actions := _get_defensive_actions_from(actions)

    var info := {
        weakest_target = _get_battler_with_lowest_health(_opponents),
        weakest_ally = _get_battler_with_lowest_health(_party),
        health_status = _get_health_status(_actor),
        fallen_party_count = _count_fallen_party(),
        fallen_opponents_count = _count_fallen_opponents(),
        available_actions = actions,
        attack_actions = attack_actions,
        defensive_actions = defensive_actions,
        strongest_action = _find_most_damaging_action_from(attack_actions),
    }
    return info


func _get_available_actions() -> Array:
    var actions := []
    for action in _actor.actions:
        if action.can_be_used_by(_actor):
            actions.append(action)
    return actions


func _get_attack_actions_from(available_actions: Array) -> Array:
    var attack_actions := []
    for action in available_actions:
        if action is AttackActionData:
            attack_actions.append(action)
    return attack_actions


func _get_defensive_actions_from(available_actions: Array) -> Array:
    var defensive_actions := []
    for action in available_actions:
        if not action is AttackActionData:
            defensive_actions.append(action)
    return defensive_actions


func _get_battler_with_lowest_health(battlers: Array) -> Battler:
    var weakest: Battler = battlers[0]
    for battler in battlers:
        if battler.stats.health < weakest.stats.health:
            weakest = battler
    return weakest


func _is_health_below(battler: Battler, ratio: float) -> bool:
    ratio = clamp(ratio, 0.0, 1.0)
    return battler.stats.health < battler.stats.max_health * ratio


func _is_health_above(battler: Battler, ratio: float) -> bool:
    ratio = clamp(ratio, 0.0, 1.0)
    return battler.stats.health > battler.stats.max_health * ratio


func _get_health_status(battler: Battler) -> int:
    if _is_health_below(battler, 0.1):
        return HealthStatus.CRITICAL
    elif _is_health_below(battler, 0.3):
        return HealthStatus.LOW
    elif _is_health_below(battler, 0.6):
        return HealthStatus.MEDIUM
    elif _is_health_below(battler, 1.0):
        return HealthStatus.HIGH
    else:
        return HealthStatus.FULL


func _count_fallen_party() -> int:
    var count := 0
    for ally in _party:
        if ally.is_fallen():
            count += 1
    return count


func _count_fallen_opponents() -> int:
    var count := 0
    for opponent in _opponents:
        if opponent.is_fallen():
            count += 1
    return count


func _find_most_damaging_action_from(attack_actions: Array):
    var strongest_action
    var highest_damage := 0
    for action in attack_actions:
        var total_damage = action.calculate_potential_damage_for(_actor)
        if total_damage > highest_damage:
            strongest_action = action
            highest_damage = total_damage
    return strongest_action


func _is_weak_to(battler: Battler, action: ActionData) -> bool:
    return action.element in battler.stats.weaknesses


func _calculate_weaknesses() -> void:
    for action in _actor.actions:
        _weaknesses_dict[action] = []
        for opponent in _opponents:
            if _is_weak_to(opponent, action):
                _weaknesses_dict[action].append(opponent)