Picking brains

by Tom on

Today I would like to share with you the way enemies work. Hopefully this prevents any dissections our players would make to find out how the enemies work from the inside to finally be able to beat them. In this dev log I will introduce the general workings of the behaviour systems in Roche Fusion. In my next dev log I will be taking a more detailed look at one of the recently use cases: a new boss.

Enemies and controllers


The first thing we will have to look at is the high level architecture of enemies. In Roche Fusion enemies are actually two entities that are strongly related to each other:

Image

The enemy is the actual in-game unit. It manages health, graphics, position, etc. The enemy itself however is completely passive. To actually move around in interesting ways, we can plug a controller into the enemy. The controller has very limited control over the enemy: it only determines the acceleration of the enemy. You can imagine that the enemy has a huge set of thrusters and the controller decides how much thrust each of them is giving at a certain point in time.

Image

There are a few exceptions. You could for example imagine that teleporting enemies around or keeping the claws of Captain Claw in the right place would be very difficult to do with just acceleration.

Still we prefer to use acceleration as much as possible. Apart from being somewhat intuitive, it also automatically causes all enemy behaviour to be completely smooth. It takes a bit of time to accelerate and decelerate, which gives the movement a very natural feel.

I will not go in detail on the relation between controllers and enemies, but feel free to let me know if you would like to know more about the more technical relationship and "physics" we use for our enemies.

Behaviour states


When we started working on Roche Fusion, we gave every enemy their own controller. Most controllers were implemented as a finite state machine (if you don't know what this means: more on that in a bit) using an enumerator that saved the current state. Every time we needed to decide on an acceleration we would consider the current state and perform the right code path, in addition to figuring out if we should change to a different state.

While this approach works just fine, there are a few major disadvantages. First of all the structure of the behaviour was often not entirely clear: the states were immediately obvious from the enumerator, but there was no clear description of state transitions, making it more difficult to find out the exact behaviour of enemies.

Secondly, a lot of code was shared between enemy controllers. Every new controller often started with copying and pasting a bunch of code from a previously written controller to get the state machine working. There were at some point even multiple variations of the implementation of states in the code. It was pretty clear from this that an abstraction was heavily needed.

Let's get back to that word I used before: finite state machine. A finite state machine (or FMS for short) is exactly what it says: some sort of machine or system that at every moment has one state from a finite list of states. One's life could be simplified in a finite state machine with for example states sleeping, eating, working, and playing Roche Fusion.

Enemy behaviour is a good example of a finite state machine. Let's for example consider a very simple enemy: the dark blue drone. These drones can only have two states: shooting and moving. In addition to states, we also need to add ways to switch between states: state transitions. In case of the blue drones, the state transitions are simply all possible transitions:

Image

Putting this in an image already shows us the underlying structure of this behaviour. It turns out that for almost every enemy you can make a graph like this. Let's take a look at another enemy: the squid. Despite the fact that this enemy seems quite dumb in the game, the states are actually a lot more complicated:

Image

Some behaviours are very simple. For example the red bugs do nothing but chase you. We can still make an overview of their states though:

Image

Graphs


If you are curious and checked the file names of the images above you will see I called them "graph". Indeed, if we look at the structure of the states and their transitions, we recognise a directed graph where the states are vertices or nodes, and the transitions are arcs. Don't worry if you don't know what I am talking about, but let's just settle that we can call the diagrams above state graphs or behaviour graphs.

The next step is to write a framework that has the structure as input and can then deal with all states and state transitions for us. If we have this black box, we can input the graph structure in there along with a manual about what to do for every state, and we don't have to deal with all that stuff any more.

Let's take this black box for granted and first take a look about what else we need. For every state the enemy still has to know what to do. Instead of testing every frame what state we are in we will use a different controller for every state. The black box will just plug in the right controller at the right time. This means that our controllers only have to deal with what to do in that state and nothing else.

Not convinced yet? Check out this small example class, which is the controller that is used by the dark blue drones to shoot at the player.

Code: Select all
private class ShootController : NodeController
{
    private Player target;

    public ShootController(GameEnvironment environment, Enemy enemy)
        : base(environment, enemy) { }

    protected override EnemyControlState control(GameUpdateEventArgs e)
    {
        // Target: closest Player
        this.target = this.target
            ?? this.environment.Aggro.ProbabilisticDangerousPlayer();

        foreach (var w in this.Enemy.Weapons)
        {
            w.SetTarget(this.target);
            w.TryShoot();
        }

        return EnemyControlState.Idle;
    }
}

Previously we also had to count the amount of shots fired and figure out when to stop shooting. There is no need for that any longer, making this class (and many others) more understandable and very easy to maintain.

State transitions


So where do we manage the state transitions? That would be in the arcs. If we look at the state graphs, we see that every state has several outgoing transitions. Coupled to each of these transitions is a certain condition. Maybe when the enemy reached a certain location, or a certain amount of shots is fired, or possibly the condition is purely time-based. Every time we enter a new state, we have to initialise these transitions. The black box will then check these conditions and if one of them is fulfilled, the state will automatically change along the corresponding arc.

Graph initialisation


The final thing I will discuss is how the actual graph is stored. We could build a datastructure that represents the entire set of states and transitions for every enemy, but this means that we have to store a large set of useless data for every enemy. The graph will look the same for every enemy that uses the same controller, so we can just store the structure of the state graph once. The main enemy controller will then store a reference to that structure, and also keeps track of where in the structure the controller currently sits.

As we want some of the controllers to have a state (e.g. the target in the example above), we can not have a single instance of each controller. That is why every node in the behaviour graph is not a controller, but only knows how to make the controller for that node, and also how to initialise a set of outgoing transitions.

To make it sound a bit less technical: the diagrams I showed earlier are stored somewhere in the code. The main controller just has an image of that diagram lying in front of it and continuously points its finger towards where it currently is, and follows the arrows when necessary.

Final words


Throughout the project the behaviour system has become quite a complex system. It is nearly impossible to go through all the technical details in a single dev log, but hopefully this post has already explained the intuition behind the chosen solution.

Let me return to the first image of this post. I showed how the enemy is actually separated in two entities in our game. I have now explained how the controller is basically only the interface to a more complex system: it decides at any given point in time which of a set of atomic behaviours should be used.

Image

As announced earlier, in my next dev log I will look at a more concrete example of enemy behaviour which benefits from this framework greatly. If you have any questions, whether it be about the structure or maybe about the more technical aspects, feel free to leave your question behind on our forums.