Rule-based AI + Unity

Hello! In the last article we already talked about artificial intelligence and the difficulties of choosing it. In this one we'll talk about how to start writing it so as not to shoot yourself in the ass leg.

Returning to the notorious scale of “artificial intelligence complexity”, according to the classics, everything that is to the left of the State Machine is called some kind of Scripting AI/Non-scripted AI/No-framework AI and so on. Like the names, they themselves are a chaotic set of conditions and behaviors, implemented without any centralized approach. There are no dedicated states, transitions, or any of the other reusable building blocks that make up all other approaches.

An example is a bird in an open-world game. It flies across the entire map along a given route, when it hits it, it falls, and 5 seconds after the fall it disappears with some effect. For such behavior, it will be more expensive to fence in frameworks.

But there is one approach that falls between the above “approach” and the rest of the classic approaches for creating game AI. In the Game AI Pro book it is called Rule-Based AI (not to be confused Rule Based AIapplied at a completely different level of AI).

Its advantages:

  • Much simpler than more “serious” approaches such as FMS, BT, Utility AI and GOAP

  • It can easily be transformed into any of these approaches if necessary (although I don’t understand who in their right mind would think “oh, now we’ve thought about everything and decided that we will write AI on a state machine”)

  • Very flexible and can exist as an independent solution for quite a long time if the game does not require a huge number of behaviors or advanced algorithms for changing them

In this part of the article, we’ll figure out what it is and how to implement it in Unity, and in the following parts, how to move from it to more serious approaches, if necessary. Along with the reasons why it is or is not worth doing.

For more content on game development and game AI in particular, welcome to my channel:

Definition

Rule-based AI consists of a collection of predicate-action pairs. We check all predicates and for the first one, for which this predicate is true, we perform an action.

Go!

1. Create a “framework”

As an example, we will have players find the nearest enemy, approach it, and deal damage while the enemy is still there. And then again.

First, let's define our main classes for AI:

public interface IRule
{
    bool CanExecute { get; }
    void Execute();
}

And the actor himself with whom the game will interact:

public class Actor
{
    private readonly List<IRule> _rules;

    public Actor(params IRule[] rules) => 
        _rules = rules.ToList();

    public void Act() =>
        _rules
            .Find(x => x.CanExecute)
            ?.Execute();
}

It couldn't be simpler, but behind that simplicity lies great potential.

2. Create an entry point and game services

Let's create a sketch for the entry point for now. In it we will define how our characters will be created and how their behavior will be processed.

public class EntryPoint : MonoBehaviour
{
    private void Update()
    {
        if (Input.GetKeyDown(KeyCode.Alpha0))
            CreateActor(teamId:0);
        
        if (Input.GetKeyDown(KeyCode.Alpha1))
            CreateActor(teamId:1);

        UpdateActors();
    }
}

Now let's see what we need to implement this logic. The “CreateActor” method tells us to create a factory for characters, and the “UpdateActors” method tells us to create a repository with them.

Let's start the other way around with the repository. Inside, we only need methods for adding and removing entities, executing some method for each of them, and the collection itself for sampling. Let's also make the class generic to store Actors and characters separately:

public class Repository<T>
{
    public IEnumerable<T> Items => _items.Values;

    private readonly Dictionary<Guid, T> _items = new();

    public void Register(Guid id, T item) => 
        _items.Add(id, item);

    public void Unregister(Guid id) => 
        _items.Remove(id);

    public void ForEach(Action<T> action)
    {
        foreach (T item in _items.Values.ToArray())
            action(item);
    }
}

Please note that when iterating over objects in ForEach, we create an additional array using the ToArray() method. Otherwise, with a large number of characters, we may catch the error that when the array is iterated from outside, elements will be added or removed from it.

Now that we have somewhere to put the characters, let's make a factory for creating them:

  • we will load the character from resources

  • We will create it at a random point, we will set the maximum spread for this point as a constant immediately inside the factory (and we don’t care if it’s wrong)

  • then we’ll pass the teamId into our character, so that in the future we can choose an enemy depending on it

  • Let's create an Actor, which we will then fill with possible actions of the character

  • The Character class will contain the character data and execute methods for movement and attack (a bit overloaded, but in the context of the article I don't care)

  • Let's register the character in Repository, and the Actor in Repository

public class CharacterFactory
{
    private const float FIELD_SIZE = 15f;
    
    private readonly Repository<Character> _characters;
    private readonly Repository<Actor> _actors;

    public CharacterFactory(Repository<Character> characters, Repository<Actor> actors)
    {
        _characters = characters;
        _actors = actors;
    }

    public void Create(int team)
    {
        Guid id = Guid.NewGuid();
        Vector3 startPosition = new(Range(-FIELD_SIZE, FIELD_SIZE), 0f, Range(-FIELD_SIZE, FIELD_SIZE));
        Character instance = Instantiate(
		        Resources.Load<Character>("Character"), 
		        startPosition, 
		        Quaternion.identity);

        instance.Setup(team);
        
        Actor actor = new();
        
        _characters.Register(id, instance);
        _actors.Register(id, actor);
    }
}

3. Let's write a character

Now let's move on to the character class. I will immediately write in it everything that our future rules will need, and then I will move on to writing AI. The following code example can be redone 10 more times and divided into smaller ones according to their responsibilities. For example, a separate component for movement, another one holds a link to the enemy, a third one for attacking, a fourth one for taking damage. This way each rule could work with less character context and be more reusable. But this again is not the topic of the article. In it, everything that a character can do will be contained in one huge (not so huge, only 60 lines) class:

public class Character : MonoBehaviour
{
    public event Action OnDamage;

    public bool HasEnemy => _enemy is { IsAlive : true };
    public bool CloseToEnemy => HasEnemy && Distance(Position, _enemy.Position) < 1f;
    public bool InCooldown => time - _attackTime < _attackCooldown;
    public float Health { get; private set; }
    public Vector3 Position => transform.position;
    public int Team { get; private set; }

    [field: SerializeField] public float MaxHealth { get; private set; } = 5f;

    private bool IsAlive => Health > 0f;

    [SerializeField] private Rigidbody _rigidbody;
    [SerializeField] private float _speed = 2.5f;
    [SerializeField] private float _attackCooldown = 1f;
    [SerializeField] private float _damage = 1f;
    
    private Character _enemy;
    private float _attackTime;

    public void Setup(int team)
    {
        Team = team;
        Health = MaxHealth;
    }

    public void MoveToEnemyPosition()
    {
        Vector3 target = MoveTowards(Position, _enemy.Position, _speed * deltaTime);
        Vector3 direction = (_enemy.Position - Position).normalized;
        _rigidbody.MovePosition(target);
        transform.forward = direction;
    }

    public void SetEnemy(Character enemy) => 
        _enemy = enemy;

    public void AttackEnemy()
    {
        if (!_enemy.IsAlive)
        {
            _enemy = null;
            return;
        }
        
        _enemy.TakeDamage(_damage);
        _attackTime = time;

        if (!_enemy.IsAlive)
            _enemy = null;
    }

    private void TakeDamage(float damage)
    {
        Health = Max(Health - damage, 0f);
        OnDamage?.Invoke();
    }
}

4. Rules

Now that our character is ready, let's start writing the first rule – it will select a suitable opponent. That is, the rule will simply go to the repository with characters and find the closest one there:

public class FindEnemy : IRule
{
    public bool CanExecute => !_context.HasEnemy;
    
    private readonly Character _context;
    private readonly Repository<Character> _characters;

    public FindEnemy(Character context, Repository<Character> characters)
    {
        _context = context;
        _characters = characters;
    }

    public void Execute() => 
        _context.SetEnemy(_characters
            .Items
            .Where(x => x != _context && x.Team != _context.Team)
            .OrderBy(x => Distance(x.Position, _context.Position))
            .FirstOrDefault());
}

The following rule will move the character towards the selected enemy, if there is one:

public class FollowEnemy : IRule
{
    public bool CanExecute => _context.HasEnemy && !_context.CloseToEnemy;
    
    private readonly Character _context;
    
    public FollowEnemy(Character context) => 
        _context = context;

    public void Execute() => 
        _context.MoveToEnemyPosition();
}

If the character is close enough to the enemy and is not on attack cooldown, then the following rule applies:

public class AttackEnemy : IRule
{
    public bool CanExecute => _context.CloseToEnemy && !_context.InCooldown;

    private readonly Character _context;

    public AttackEnemy(Character context) => 
        _context = context;

    public void Execute() => 
        _context.AttackEnemy();
}

And if the player’s health is zero, then the following rule will destroy him and erase him from all repositories:

public class Die : IRule
{
    public bool CanExecute => _context.Health.ApproximatelyEqual(0f);

    private readonly Guid _id;
    private readonly Character _context;
    private readonly Repository<Character> _characters;
    private readonly Repository<Actor> _actors;

    public Die(Guid id, Character context, Repository<Character> characters, Repository<Actor> actors)
    {
        _id = id;
        _context = context;
        _characters = characters;
        _actors = actors;
    }

    public void Execute()
    {
        _characters.Unregister(_id);
        _actors.Unregister(_id);
        Object.Destroy(_context.gameObject);
    }
}

All we have to do is attach the Character script to the prefab with the character, put an empty object with the EntryPoint script on the stage and we’re done.

5. Beautiful

Also, for the sake of beauty, we’ll write a small script that will display the character’s team, his health points and the very fact of causing damage (don’t forget to import the package DoTween for animation of taking damage in the HandleDamage() method:

public class CharacterView : MonoBehaviour
{
    [SerializeField] private Color[] _colors;
    [SerializeField] private Renderer _renderer;
    [SerializeField] private Slider _slider;
    
    private Character _character;

    public void Setup(Character character)
    {
        _character = character;
        _renderer.material.color = _colors[character.Team];
        character.OnDamage += HandleDamage;

        SetupHealthValue();
    }

    private void OnDestroy() => 
        _character.OnDamage -= HandleDamage;

    private void HandleDamage()
    {
        SetupHealthValue();
        _renderer 
            .material
            .DOColor(red, .15f)
            .SetLoops(2, Yoyo);
    }

    private void SetupHealthValue() => 
        _slider.value = _character.Health / _character.MaxHealth;
}

After starting by pressing keys 0 and 1, characters will be created. If you press hard enough, the picture will look like this:

Continuing the conversation about expanding this framework, we have quite a lot of good options that would speed up the development of new rules for characters and make them more readable:

  • make a generic abstract class Rule, where T would be the context with which the rule would operate. Then you wouldn’t have to write the same constructor in each rule with initialization of the Character field

  • make the rule not stateless as it is now, but storing state. That is, within each rule we would determine whether it is called for the first time, subsequent ones, or whether this is the last time it is called. Then it will be possible to add certain actions at the start or end of the rule action

  • on the contrary, as part of optimization or creation of a more ECS-like architecture, it would be possible to make the rules “pure”. That is, we would pass the context in them not through the constructor, but directly into the methods. This would mean that it wouldn't make sense for us to create rules objects for each character. In our example, this is not scary, but imagine if there are 15-20 such rules, and there are 500-1000 players on the stage at the same time. That is, 20*1000 = 20,000 extra objects for the poor garbage collector.

  • add decorators for rules: a wrapper that runs multiple rules in turn or runs them in parallel

But with each such improvement, the first question you will need to ask yourself is: “Am I actually trying to implement some existing framework?” After all, decorators are taken directly from the Behavior Tree, and states in the rules are taken from the State Machine. And do you really need to write a bicycle or would it be better to immediately switch to a different approach to writing AI?

We'll talk more about this in the next article! If anyone wants to know even more about development, welcome to my channel at telegram And youtubewhere this article will soon be released in video format.

And if someone wants to see all my works before others or just support, then welcome to Boosty!

Thank you!

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *