THUNGSTEN THE GAMEDEV BLOG

You probably want an Agent Task System

Organizing a variety of tasks or orders in game development

No matter what kind of game you're creating, if you have units, characters or agents performing different kinds of actions you probably want an agent task system. In this article, we'll use the separation of concerns principle to create an adaptable and modular agent task system for games. It's aimed at novice developers.

As I mentioned in my introductory article about real-time strategy games, most games with characters or agents benefit from a modular task system. This is due to the variety of what the agents should be capable of doing. For example, the same agent is usually capable of moving, attacking, gathering resources, returning resources, entering transports and constructing buildings.

A user on Reddit, commenting on the previous article, asked me to elaborate on this point, so here goes.

Disclaimer

What we'll cover is just one example of a barebones implementation, your mileage may vary. It's first and foremost intended to help you get started. This article is heavier on code, but it is by no means a full implementation.

Henceforth, when talking of agents or characters, I'll use the word Unit. Likewise, I'll use the word Order for tasks. This is purely a semantic choice to fit the context of a real-time strategy game — though, as I emphasize, the system itself is also well-suited to other genres.

As always, the code here targets Unity and C# in a game development context.

If you were to implement all this functionality in one and the same class, you'd quickly end up with a mess.

We want to create an organized system. You could call this system a "Command System", "Task System", "Unit Command System" or something else. There does not seem to be an agreed-upon standard. I've chosen to call it "agent task system" for the purpose of this article, on the basis that we'll focus on making a system for agents performing tasks.

The Problem

We end up in a mess trying to tangle the functionality of cutting trees, patrolling, moving and attacking in the same place. The bigger the mess, the harder for one to understand, debug and adjust.

With the problem identified and decision taken to implement a system to handle it, we define our goal, what do we want the system to achieve?

What requirements do we have? In other words, what would we, the players, expect from a task system. Something like:

What other facts do we know?

Given these goals and requirements, we should follow the separation of concerns design principle. It's a principle equally applicable to game development as to software- or webdevelopment. Like most principles, it's abstract (we'll soon return to that term) and can be applied in almost anything you develop. But — it's a good one! I recommend you try to keep it in mind no matter what you're programming. Doing so will make it easier to share systems between your games and projects.

The game Age of Empires IV
Age of Empires IV. In games of the classic real-time strategy genre villagers can attack, enter buildings, construct buildings and gather resources.

Bring it down a level of abstraction and we find the Command Pattern. No exact definition exists here either, but there is some common ground among most. For example, we'll not be implementing redo/undo functionality as is included in the classic definition, but it's still the nearest we'll get to any known 'pattern'. Here are some resources if you want to read up more on the pattern itself:

The core idea from the command pattern that we'll use is encapsulating an action as an object, decoupling execution — in order to achieve separation of concerns. So instead of the tangled mess that might or might not be our unit class right now, we'll tackle this with an object-oriented approach where each task is responsible for its own execution.

Creating an Order Manager

Again, as we don't want to clog our class Unit, we create an class OrderManager. Each unit will have one of its own public OrderManager Orders. The first thing we give the manager is a collection, because we know units can have multiple orders. For the sake of simplicity, we'll go with a list for now.

Hold on! A list of what? Orders of course. However, these orders vary in nature — that's the whole problem! We need to use abstraction here. Abstract classes and interfaces provide a way of defining behavior without implementation.

About abstraction

Abstraction hides complexity by exposing only essential behavior. In C#, you use abstract classes or interfaces to define this behavior, but they serve different purposes.

An abstract class is a partial implementation: it can contain fields, constructors, and shared method logic. It's ideal when related classes share common functionality but also need to implement specific methods.

An interface defines a contract without implementation*. It's used when unrelated types need to guarantee certain behaviors, like IExecutable or ICancelable.

*Since C# 8.0, interfaces can provide a default implementation of methods. The Unity Engine uses C# 9.0. But they're still only contracts, they cannot have fields or constructors.

In other words, abstract is a "template" which can contain base logic and variables, while interface is just an empty "template" stating what an inheriting class must implement.

Another important thing to note is that a class can implement multiple interfaces, but only ever inherit from one class. In general, use an interface if you can.

So we create an interface IOrder. The I is a common way of prepending interface names and lets us know at a glance what we're looking at. If you at a later point find that you're copying code between different order types (violating the DRY principle — Don't repeat yourself), you can make an abstract class BaseOrder: IOrder or class BaseOrder: IOrder (with virtual methods) which implements IOrder, keeping the same contract for modularity. For now, we don't do anything more in the interface, it's empty.

Going back to our requirements, we know that orders should start, finish, be queueable and interruptible and most importantly, do something. So we'll need at least these methods to call from our unit.

The class OrderManager could look something like this:


public List<IOrder> Queue = new List<IOrder>();
// A shorthand to get the first (current) order in the list
public IOrder Current => Queue.Count == 0 ? null : Queue[0];

// REQUIREMENT: "be queueable" and "start".
public void New(IOrder order, bool queue = false)
{
    // 1) Check if the order is valid,
    // this logic is ideally a part of the Order type itself,
    // implemented through a Validate() method
    if (!order.Validate()) return;
    // Think about what we want to do if a order is not valid.
    // Perhaps we should Clear all current orders?
    // In this case, we just return and ignore it.

    // 2) Add it our collection, front or end based on queue parameter
    // For example: the queue parameter will probably be true
    // if the player is holding down shift.
    if (!queue)
        Clear();
    Queue.Add(order);

    // 3) Run it if it is the first order in the list
    if (Current == order)
        order.Run();
} 

// REQUIREMENT: "be interruptible"
public void Clear()
{
    // When a unit dies or or just ordered to cancel all orders,
    // we call this method, clearing the list.
    if (Current != null)
    {
        Current.ClearState();
    }
    Queue.Clear();
}

// REQUIREMENT: "be interruptible" and "finish"
// Primarily called by an order when it finishes.
// Could also be called through player action.
public void RemoveOrder(IOrder order)
{
    if (order == null || !Queue.Contains(order)) return;
    order.ClearState();
    Queue.Remove(order);
    // You have to decide if you want to run the next order (if queued)
    // here immediately or just wait for the next Process()
}

// REQUIREMENT: "do something"
public void Process()
{
    // Process order if we have any.
    if (Current == null)
      return;

    Current.Process();
}

Now we can start a new order by calling unit.Orders.New(...), either by clearing all orders queue = false or queueing it queue = true.

But wait, say we have a class MoveOrder : IOrder with a bool IsAttackMove parameter. Our intent with that is that if the unit comes across an enemy, we want the MoveOrder to start an AttackOrder without removing itself from the OrderManager. When the AttackOrder is finished, the MoveOrder will continue. Currently, we could only either replace the whole list with the AttackOrder, or put it at the end of it.


public void NewPriority(IOrder order)
{
    // This should pause the current order and 
    // issue a new order in front of the list.
    // _Without_ clearing all orders.

    // If we have some logic, effects or other things that
    // should only be active while an order is running
    // we call a ClearState() method
    // _whenever_ we stop an order.
    if (Current != null)
    {
        Current.ClearState();
    }
    // ... move new order to front
}

Sweet, this is what we'll call when we want to issue a new order without forgetting about those in the queue. Usually it's not something the players could do themselves directly. We could use this same functionality in a GatherOrder to run StoreResources when the unit cannot carry any more resources, by which GatherOrder will continue when the resources have been stored!

Alternative solution to prioritizing new orders

Alternatively, a cleaner solution could be to implement an enum and use that as a parameter instead of a bool in the void New() method, outright skipping the void NewPriority() method.

public enum OrderIssueType
{
    FrontAndClear,  // Used instead of queue = false
    BackAndKeep,    // Used instead of queue = true
    FrontAndKeep    // Used instead of NewPriority()
}

Defining the IOrder Interface

With the base of public OrderManager in place, it's time to define the structure of an order. We're already referencing all these methods in our OrderManager. Every order must implement this contract — providing methods for starting, processing, and clearing the state.


public interface IOrder { 
  // FACT: Owner is always a unit.
  // The Unit whose order this is.
  // Further on, you might expand the OrderManager to also handle buildings,
  // in which case, you should target a common interface or parent class of 
  // the two.
  public Unit Owner { get; set; }

  // Returns whether the order has been instantiated with valid parameters.
  bool Validate(); 

  // REQUIREMENT: "do something"
  // Called once when the order begins execution. 
  // Should do the heavy lifting, set all references, perform pathfinding, etc.
  void Run(); 
  
  // REQUIREMENT: "do something"
  // Called every frame or tick while this is the current order
  // by the OrderManager.
  // Must check if the order is finished.
  void Process(); 
  
  // REQUIREMENT: "be interruptible" and "finish"
  // Called when the order is finished OR interrupted.
  void ClearState(); 
}

Each implementation of IOrder can be stateful and tailored to its purpose — but they all obey the same structure. Let's look at an example.

Example: MoveOrder

The MoveOrder instructs a unit to move to a specific world position. It needs a reference to the unit and a destination, and an optional value for whether to attack move (stopping to attack enemies along the way).

public class MoveOrder : IOrder
{
    public Unit Owner { get; set; }
    private Vector3 destination;
    private bool isAttackMove;
    private const float margin = 0.1f;
    private FlagMarker flagMarker;

    public MoveOrder(Unit unit, Vector3 destination, bool isAttackMove = false)
    {
        Owner = unit;
        this.destination = destination;
        this.isAttackMove = isAttackMove;
    }

    public bool Validate()
    {
        // In this example, the order is valid if:
        // the Unit has been set to an instance and is Alive,
        // destination has been set and is within World space
        return Owner != null && 
              Owner.IsAlive && 
              destination != null && 
              World.IsVectorWithinBounds(destination);
    }

    public void Run()
    {
        // The unit MoveTo() method performs the necessary 
        // pathfinding calculations and starts the movement.
        Owner.MoveTo(destination, OnArrived);

        // In this example, we want to display a flag marker at the destination
        // as long as the order is active.
        // This is purely for showcasing the need of a ClearState method.
        flagMarker = new FlagMarker(destination);
    }

    public void Process()
    {
        // Movement is likely handled by the Unit itself.
        // The only thing we do is in this case is
        // check whether the unit has arrived.
        if (Owner.DistanceTo(destination) <= margin)
        {
            Complete();
            return;
        }

        // Here is the logic for switching to an AttackOrder 
        // in case isAttackMove is true, mentioned earlier.
        if (!isAttackMove)
            return;

        Unit nearestEnemy = Owner.NearestEnemyWithinAcquisitionRange();
        if (nearestEnemy != null)
        {
            Owner.NewPriority(new AttackOrder(Owner, nearestEnemy));
            // We return in case we add more code below in the future.
            return;
        }
    }

    // Only called within this class, see comments below.
    private void Complete()
    {
        ClearState();
        Owner.Orders.RemoveOrder(this);
    }

    // This gets called by both Complete() and OrderManager.RemoveOrder().
    // If we called Complete() from the OrderManager we would
    // end up with circular logic (like an endless loop).

    // Also, we might run specific logic only when an order completes.
    // An example could be an AttackOrder which immediately searches
    // for new targets upon killing (completing) the current target. 
    public void ClearState()
    {
        flagMarker.Destroy();
    }
}

Putting It All Together

Each unit has its own OrderManager. When the player issues a command, the unit receives a new IOrder like MoveOrder, AttackOrder, or BuildOrder through OrderManager.New().

The system runs per-frame (or tick) by calling OrderManager.Process() on each unit. Orders run, check if they're complete, and gracefully transition to the next. If the queue becomes empty, the unit is idle.

When we use the system in our game, it could look like:

orderedUnit.Orders.New(new MoveOrder(orderedUnit, LocalPlayer.MouseWorldPosition));

You might notice something odd here

Do we really need to mention orderedUnit twice?

You could leave out the Unit parameter from the Order constructor and add it through the OrderManager.New() methods. Because realistically, you would probably never want to assign an order to a unit which belongs.. to another unit.

You might need it in the constructor though, for validation, so we've left it there to keep it simple. The alternative is to leave the constructor empty and create a new method called for example void Create() or void Instantiate() which runs after the OrderManager has set the Unit Owner property. Then, in order to call it, you need to define the new method in the interface IOrder(), because that's the only thing the OrderManager speaks with.

Further Improvements

Here are a collection of some things you might want to implement further on, which I've found useful myself but we have not brought up in this article:

The game Kingdom Come Deliverance 2
Kingdom Come: Deliverance 2. In role-playing games too, developers benefit from a structure for non-player characters (NPCs) to perform different actions.

Final Thoughts

We have achieved our goals which were:

Now, we have something that is:

This system is simple to start with, yet powerful enough to scale into more complex logic. I believe you will soon realize how you can use the same principles for other systems in your game development endeavours. And as was the point with the previous article, this system is not exclusive to real-time strategy games. The moment you have units, agents or characters which need to handle multiple kinds of tasks — don't write monolithic logic. Make a task system.

I hope this article was as enjoyable for you to read as it was for me to write. If you're interested in future posts, follow me on your preferred platform:

Ps. if you prefer less rambling, follow the THUNGSTEN page instead. Links in the footer below.