Titouan Deslandes

Gameplay programmer

Prerequisite system

This article is part of my internship report < click.

Introduction

One of the first things we prototyped in the game was selecting units and giving them orders.

We've decided that every entity in the game would be a Node, and we're giving them orders using NodeCommand, following the command design pattern.

Every Node has a list of all the NodeCommand it has to execute, chaining them one after the other.

For instance if we select a Node and then right click on the ground, we'll create a NodeCommandWalk, add it to the Node's command list and when the Node executes this command, it will move to the destination we clicked.

We also have NodeBehaviour which can be added to a Node to give it the ability to perform actions, for example a Node will be required to own a NodeBehaviourWalk to be able to walk.

With this system in place, we started to expend it by writing more NodeCommand, such as NodeCommandHarvest that tells a Node to harvest another one, or NodeCommandAttack to fight foes.

Pretty quickly, the first few lines of every NodeCommand started to look the same: a bunch of conditions to verify we're actually "authorized" to execute the command.

if (Node.HasComponent<NodeBehaviourHarvester> && Target.HasComponent<NodeBehaviourHarvestable>) 
{
    if (Utils.Distance(Node, Target) <= threshold)) 
    {
        // ...
    }
}

Besides looking ugly, it felt like a design flaw to mix the command's logic with the prerequisites of the command. When the command is executed it should do it's job regardless of the context, but it shouldn't be executed in the first place if the context prevents it.

Writing the prerequisite system

With this flaw in mind, let's sum up what we really want to achieve with these commands.

  • Ideally we want to create a command and give it some prerequisites.
  • We want those prerequisites to be met before executing the command, else there's no point in executing it because we're not supposed to.
  • Furthermore we want each prerequisite to be able to solve itself if it's not met. For example, if I'm too far from a Node I want to harvest, I don't want to cancel my harvest command, I want to move to my target first and THEN start my harvest command.

Now that we know what we want, let's formalize it into something that fits our needs.

enum NODE_CONDITION_STATUS
{
    VALID,
    INVALID,
    FIXABLE
}

interface INodeCommandCondition
{
    NODE_CONDITION_STATUS Evaluate(Node node);
    NodeCommand TrySolve(Node node);
}

This block declares:

  • A NODE_CONDITION_STATUS. It can be VALID if the conditions are met. INVALID if they're not. Or FIXABLE if the conditions are not met but we know we can find a way to fulfill them.
  • An Evaluate method that returns a NODE_CONDITION_STATUS. This method will hold all the logic to tell if the conditions are met or not.
  • A TrySolve method that will return the command needed to validate the Evaluate method

We now want to adapt our NodeCommand system to take into account those conditions so that when we try to execute a command, we actually verify the prerequisite first, fix them, and THEN execute the command.

When the Node executes it's NodeCommand we'll just fire the prerequisite check & solve method. This method simply calls Evaluate() on every NodeCommand and stores the result. Then we run through the results and decide what to do.

// In the Node
public void TryExecute()
{
    if(CurrentCommand.CheckAndSolvePrerequisites()) 
    {
        CurrentCommand.Execute();
    }                
}

// In the Command
protected bool CheckAndSolvePrerequisites() 
{
    // Create a dictionary that stores the status of the prerequisite for each prerequisite and populate it
    Dictionary<INodeCommandCondition, NODE_CONDITION_STATUS> prerequisitesStatus = new Dictionary<INodeCommandCondition, NODE_CONDITION_STATUS>();
    foreach (INodeCommandCondition prerequisite in prerequisites)
        prerequisitesStatus.Add(prerequisite, prerequisite.Evaluate(node));

    // If any of the prerequisite was INVALID
    if (prerequisitesStatus.Any(status => status.Value == NODE_CONDITION_STATUS.INVALID)) 
    {
        // Complete the current command because we're not supposed to execute it
        node.CompleteCommand();
        return false;
    }

    // If all the prerequisites are VALID
    if (prerequisitesStatus.All(status => status.Value == NODE_CONDITION_STATUS.VALID)) 
    {
        // We're legal, do the job
        return true;
    }

    // Else if all the prerequisites are either VALID or FIXABLE
    else if (prerequisitesStatus.All(status => status.Value == NODE_CONDITION_STATUS.FIXABLE || status.Value == NODE_CONDITION_STATUS.VALID)) 
    {
        // For each FIXABLE prerequisite try to solve it and add it to the node command queue
        foreach (var fix in prerequisitesStatus) 
        {
            if (fix.Value == NODE_CONDITION_STATUS.FIXABLE) 
            {
                // When we solve a prerequisite it returns a Command
                node.AddCommand(fix.Key.TrySolve(node));
            }
        }

        // Add the current command to the end so it'll be executed after we've done all the prerequisites
        node.AddCommand(this);

        // Then skip the current command because it's not valid anymore since we needed fixes
        node.CompleteCommand();
        return false;
    }

    return false;
}

So at this point the Node does the following:

  • Receive NodeCommand and store them.
  • Go through each NodeCommand one by one and:
    • Verify it's prerequisites
    • Solve them if needed by adding their solution to the command queue
    • Re-queue the current command AFTER the solved prerequisites
    • Execute the NodeCommand

Using the prerequisite system

Let's implement the interface we created earlier with a simple condition: we want the Node to have a certain NodeBehaviour attached to it.

public class ConditionHasNodeBehaviour<T> : INodeCommandCondition where T : NodeBehaviour
{
    public NODE_CONDITION_STATUS Evaluate(Node node)
    {
        // If we own the required component the condition is VALID
        if (node.HasComponent<T>() != null)
        {
            return NODE_CONDITION_STATUS.VALID;
        }
        // If we don't, it's INVALID
        else
        {
            return NODE_CONDITION_STATUS.INVALID;
        }
    }

    public NodeCommand TrySolve(Node node)
    {
        // This condition cannot be solved
        return null;
    }
}

Now when we create a NodeCommandWalk we can simply pass the prerequisites in the constructor:

// Ctor signature: NodeCommandWalk(Node node, Transform destination, params INodeCommandCondition[] prerequisites) : base(node, prerequisites)
NodeCommandWalk command = new NodeCommandWalk(node, destination,
                        new ConditionHasNodeBehaviour<NodeBehaviourWalk>());

What about something we can fix? A distance check condition!

public class ConditionDistanceCheck : INodeCommandCondition
{
    private Node targetNode;
    private float minDistance;

    public ConditionDistanceCheck(Node target, float minDistance = 10f)
    {
        this.targetNode = target;
        this.minDistance = minDistance;
    }

    public NODE_CONDITION_STATUS Evaluate(Node node)
    {
        // If we're too far from the targetNode
        if (Utils.Distance(node, targetNode) > minDistance)
        {
            // If the node has a walk behaviour it means that this condition is FIXABLE because we can walk
            if (new ConditionHasNodeBehaviour<NodeBehaviourWalk>().Evaluate(node) == NODE_CONDITION_STATUS.VALID)
            {
                return NODE_CONDITION_STATUS.FIXABLE;
            }

            // We cannot move, the condition is INVALID
            else
            {
                return NODE_CONDITION_STATUS.INVALID;
            }
        }

        // We're close enough to the target, the condition is VALID
        else
        {
            return NODE_CONDITION_STATUS.VALID;
        }
    }

    public NodeCommand TrySolve(Node node)
    {
        // We should double check here that we have the right NodeBehaviour, distance, etc...
        // Return the CommandWalk to move to destination
        return new NodeCommandWalk(node, targetNode);
    }
}

Now when we want to harvest a Node we simply do:

NodeCommandHarvest command = new NodeCommandHarvest(node, targetNode,
                        new ConditionHasNodeBehaviour<NodeBehaviourHarvester>(),
                        new ConditionHasNodeBehaviour<NodeBehaviourHarvestable>(targetNode),
                        new ConditionDistanceCheck(targetNode, 5f));

What's great about this system is that the prerequisites are nestable.

A Node might want to go and mine something. To do so he needs a pickaxe, but he doesn't have one. So he solves this by searching for a pickaxe. But to search he needs to walk, creating a series of SearchCommand, WalkCommand, GrabCommand, etc... all from a single MineCommand.