Game Logic and the ECS

In order to implement the various features required for Aether Hunt, we will leverage the Aether Engine’s built in ECS. Looking at the game, we will need to:

  1. Initialise the world, filling it with prey
  2. Add a system for making the prey respond to other prey and the players
  3. Add a system to enable the players to bite prey
  4. Add a system to remove killed prey from the simulation.
  5. Add a system to respond to player input
  6. Update the simulation on each tick

Initialising the world

When we were setting up the Aether Engine simulation in our main.cc, we added a callback:

// initialise_world handles any global setup, e.g. creating our prey
params.initialise_world = &initialise;

intialise_world will be called once at the beginning of the simulation, and provides the appropriate hook for any setup that we want to do once to create the simulation. In the case of Aether Hunt, that means we want to intialise our world full of prey. In Aether Hunt, the initialise function hands off to initialise_world inside world.hh.

template<typename AetherState, typename ECS>
static void initialise_world(const AetherState &aether_state, ECS &state) {
initialise_preys(aether_state, state);
state.create_update_set()
.new_entity_local()
.template create_component<c_player_spawner>();
}
template<typename AetherState, typename ECS>
static void initialise_preys(const AetherState &aether_state, ECS &state) {
for (size_t i = 0; i < PREY_NUMBER; i++) {
float n = PREY_NUMBER;
float r = SPAWN_RADIUS;
auto s = (size_t)sqrt(n);
auto x = ((float(i % s) / float(s)) - 0.5f) * 2.f * r;
auto y = ((float(i / s) / float(s)) - 0.5f) * 2.f * r;
spawn_prey(aether_state, state, vec2f{ x, y });
}
}
template<typename AetherState, typename ECS>
static void spawn_prey(const AetherState &aether_state, ECS &state, vec2f position) {
auto update = state.create_update_set();
auto new_prey_entity = update.new_entity_local();
auto base_prey_component = new_prey_entity.template create_component<c_base>();
base_prey_component->entity_id = generate_uuid();
base_prey_component->position = position;
auto velocity_component = new_prey_entity.template create_component<c_velocity>();
float rand_x = PREY_MAX_SPEED * sinf(float(rand())/float(RAND_MAX));
float rand_y = PREY_MAX_SPEED * sinf(float(rand())/float(RAND_MAX));
velocity_component->velocity = {rand_x,rand_y};
new_prey_entity.template create_component<c_prey>();
}
static size_t generate_uuid() {
return rand();
}

Our initalise_world starts off by creating all the prey for our simulation. We position all the prey inside intialise_preys whilst initialise_prey handles actualy creating the components needed for our prey to behave like prey. To interact with the ECS, we create an update set with state.create_update_set(). Within this update set, creating a new entity is done with update.new_entity_local(). This will create a blank entity - one that does not even have a position. In Aether Hunt, our entities use c_base as the component for holding the ID and position of the entity. We want our prey to move, so we add a c_velocity component and initialise the starting velocity. Finally, we add a marker component c_prey to indicate that this entity is actually prey. Notice how at this point we have not initialised any predators - in Aether Hunt they are all controlled by players, and so we will wait until we have some players connect to the simulation before intialising them.

Now that we have some prey in the simulation, we want some way to update them.


Prey behaviour system

As is standard with any ECS, we will have a separate system that operates across all entities. In our case we are going to implement the prey behaviour by using a variation on the classic boids setup. If we consider prey_behaviour.hh we can see the interface required for our ECS systems.

struct prey_behaviour {
typedef std::tuple<c_prey, c_predator, c_velocity, c_base, c_death> accessed_components;
using ecs_type = aether::constrained_ecs<user_cell_state, accessed_components>;
void operator()(const aether_cell_state<octree_traits> &aether_state, ecs_type &state, float delta_time);
};

We have a struct that represents the system itself that exposes an accessed_components type - this is a tuple that is generic over the components we are interested in for this system. Notice how we have both c_prey and c_predator as components in this tuple. We will want to have our prey respond to the presence of other prey and predators, so we will need both. This does not mean that each entity we update on will need both components - accessed_components should declare every possible component that we may need regardless of which combination we need them in. We also make the struct callable, and this is the entry point for the logic to update our prey.

void prey_behaviour::operator()(const aether_cell_state<octree_traits> &aether_state, ecs_type &state, float delta_time){
using aether::mpl::maybe;
for( auto prey : state.local_entities<c_velocity, c_prey, c_base, maybe<c_death>>() ){
if(prey.has<c_death>()){
continue;
}
auto& position = prey.get<c_base>()->position;
auto& velocity = prey.get<c_velocity>()->velocity;
const auto got_bitten = got_eaten_by_predator(state, position);
if(got_bitten) {
prey.create_component<c_death>();
continue;
}
flock_behaviour(state, position, velocity, prey.get<c_base>()->entity_id);
predator_avoidance(state, position, velocity);
return_to_playable_area(position, velocity);
clamp_velocity(velocity);
position += velocity * delta_time;
}
}

This is the core of our prey logic. When we are going to update entities, we use state.local_entities to query for them. local_entities will give us all of the entities that are owned by the current worker. This is important - we distribute the workload for updating entities across workers, so we should make sure that we do not try and update entities that do not belong to the current worker. Notice that we pass in the list of components that we expect the entities to have - here we are expecting that entities have all of c_velocity, c_prey and c_base, and optionally have c_death as well. c_death is a marker to indicate that the prey has been killed. We can then use prey.has<c_component>() and prey.get<c_component>() to query for the presence of optional components or retrieve the data stored on a component. In this case if the prey has been killed we ignore it for the rest of the simulation (we don’t want killed prey to be able to run away - this is not a zombie game!).

Querying for entities outside the current cell

When checking if the prey has been eaten by a predator, we have an interesting edge case to consider. Our world is spatially divided into cells - what happens when our prey and predator are close to each other, but on opposite sides of the edge of a cell? The player will feel hard done by if we do not allow them to kill prey because our simulation has divided the world into parts and they are not in the correct part - especially if we do not provide any indication of that separation. So how do we find out if there is a biting predator nearby?

bool prey_behaviour::got_eaten_by_predator(ecs_type &state, vec2f& position){
using aether::mpl::maybe;
for(auto predator : state.visible_entities<c_predator, c_base>()) {
if(!predator.get<c_predator>()->is_biting) {
continue;
}
if(distance(position, predator.get<c_base>()->position) < BITE_RADIUS){
return true;
}
}
return false;
}

Here we use state.visible_entities to query for any entities that we are aware of. Remember how we defined a handover radius in our world setup? visible_entities will also return entities from neighbouring cells, where the handover radius for that entity indicates that we should know about them. This way we can let the player attempt to bite at the edge of one cell, and have it kill a prey inside a different cell close by. We should treat this collection as read-only however - entities that we have been made aware of because of the handover radius are cloned from the owning cell, and updates by a neighbouring worker do not represent updates to the source of the truth.

Returning to our prey_behaviour system implemenation, we will want to add the c_death component if the prey was being eaten by a predator. We can dynamically add components to an entity using prey.create_component<c_component>(). In this case, if the prey has been killed we do not want to update it’s velocity.

The rest of this system is concerned with implementing boids like behaviour, including predator avoidance and remaining somewhat close to the origin.

At this point, the Aether Engine is not aware of our system - we need to register it. In the Aether Engine ECS systems are initialised for each cell. In our main.cc we added a callback to intialise each cell:

// initialise_cell handles per-cell setup, including ECS configuration
params.initialise_cell = &initialise_cell;

Inside this callback we can add our prey_behaviour system to the ECS:

void initialise_cell(const aether_cell_state<octree_traits> &aether_state, user_cell_state &state) {
state.add_system<prey_behaviour>();
}

Now whenever a new worker is created to manage a cell, it will have our prey behaviour system.

The predator behaviour system handles the predator bite cooldown, but as it only reuses Aether Engine features we have already seen we will skip past it to the next interesting system, the corpse_decay_system.


Corpse decay system

The corpse decay system is the system responsible for removing dead entities from the simulation.

void corpse_decay_system::operator()(const aether_cell_state<octree_traits> &aether_state, ecs_type &state, float delta_time) {
auto entities = state.local_entities<c_death>();
auto update = state.create_update_set();
for (auto entity : entities) {
update.drop_entity(entity);
}
}

Here we query for all local entities that have the c_death component, and we use update.drop_entity(entity)to remove the entity from the simulation entirely. Notice that this means that the moment an entity has the c_death attribute added it will be eligible to be removed from the simulation. As a result, the ordering of systems is important - in order to play the correct animations on the client we will want to send an update to the client where the prey has been marked as dead, before it is removed from the simulation. This is handled by adding the corpse_decay_system to the ECS before the prey_behaviour system.

void initialise_cell(const aether_cell_state<octree_traits> &aether_state, user_cell_state &state) {
state.add_system<corpse_decay_system>();
state.add_system<prey_behaviour>();
}

This way, each entity will remain in the simulation for a single tick after they have been killed, allowing us to send an update to each client indicating that the prey has been killed.


Responding to user input

User input it received by the simulation through the receive_messages callback that we configured in main.cc.

// receive_messages allows the simulation to respond to player input
params.receive_messages = &receive_messages;

Although we have the aether_cell_state<octree_traits> state passed into this callback, and so could try and update entities inline to respond to user input, Aether Engine provides a helpful method for moving the inputs into a separate system. We can read the messages into the cell state and then retrieve them from within a system.

void receive_messages(const aether_cell_state<octree_traits> &aether_state, user_cell_state &state, message_reader_type &reader) {
state.receive_messages(reader);
}

Now in our incoming_messages_system we can read these events.

void incoming_messages_system::operator()(const aether_cell_state<octree_traits> &aether_state, ecs_type &state, float delta_time) {
auto &reader = state.get_message_reader();
while (auto maybe_message = reader.get_next()) {
const auto &message = maybe_message.value();
aether_event event;
if (!message.payload_as_pod(event)) {
continue;
}
if (event.type == EVENT_MOVE) {
// Move the player
}
else if (event.type == EVENT_CONNECT) {
// Create a new player
}
else if (event.type == EVENT_BITE) {
// Try and bite prey
}
}

Here we get the message reader from the cell state, and loop over all available messages. Messages from the message reader are opaque and need to be translated to a useable format with payload_as_pod. Any type can be used as an event, aether_event in this case is one of our own design. We will look at this more in Serialization and the Muxer , but for now the important restriction is that std::is_pod<T>::value must return true to for T to be considered a valid target type.

Now we have our event we can inspect it and respond appropriately. For Aether Hunt, EVENT_MOVE represents the player moving, and so we update the position of the player in the simulation to reflect the position on the client. EVENT_BITE represents the player triggering a bite, in which case we start the bite logic on the relevant predator. Notice how both of these query for local_entities and then check that the entity_id on the component matches the entity_id in the passed message.

The last event, EVENT_CONNECT, represents a new player joining the game. We keep a list of players on a dummy entity, and check that the entity ID of the player does not already exist in the simuation. If all is well, then we spawn a new predator near the origin. In world.hh we have the logic for creating new predators.

template<typename AetherState, typename ECS>
static void spawn_predator(const AetherState &aether_state, ECS &state, vec2f position, uint64_t player_id, uint8_t additional_flags = 0b00000000) {
auto update = state.create_update_set();
auto new_predator_entity = update.new_entity_local();
auto base_predator_component = new_predator_entity.template create_component<c_base>();
base_predator_component->entity_id = generate_uuid();
base_predator_component->position = position;
auto player_component = new_predator_entity.template create_component<c_player>();
player_component->player_id = player_id;
if(additional_flags & AetherHuntProtocol::aether_connect_flags::EVENT_CONNECT_ALWAYS_RELEVANT) {
player_component->always_relevant = true;
}
if(additional_flags & AetherHuntProtocol::aether_connect_flags::EVENT_CONNECT_SHOW_CELLS) {
player_component->show_cells = true;
}
new_predator_entity.template create_component<c_predator>();
}

Notice that we start off in much the same way as creating prey - we create an update set, then add a new local entity, before adding components to handle position, and the player ID. Here we have added some additional flags that allow the client to control what kind of information they receive - this will tie in closely with the information we will want to send the client. For the Unreal Engine 4 client, the player should only receive local updates about the world, and not be aware of the cell boundaries. However for the OpenGL client we want to show every entity and the cell boundaries to act as a debug view. To enable this we pass flags with the connection, and record their values on the player_component.

As before we will need to register our incoming_messages_system, so that with all systems our initialise_cell method in main.cc now has four systems registered.

void initialise_cell(const aether_cell_state<octree_traits> &aether_state, user_cell_state &state) {
state.add_system<corpse_decay_system>();
state.add_system<incoming_messages_system>();
state.add_system<prey_behaviour>();
state.add_system<predator_behaviour>();
}

Cell tick

There is one very important piece missing in our current setup. We have our systems containing our logic, and we’re generating prey and connecting players. But nothing actually advances the simulation - for this we use one of our other callbacks, cell_tick. Recall in our main.cc we added this as a callback to the params.

// cell_tick is the called per cell, per tick and advances the simulation
params.cell_tick = &cell_tick;

As we are using the Aether Engine ECS, our cell tick is very straightforward - we just ask the cell state to tick.

void cell_tick(const aether_cell_state<octree_traits> &aether_state, user_cell_state &state, float delta_time) {
state.tick(aether_state, delta_time);
}

Of course, if you were to prefer to not use the ECS, this would still be the main entry point for the cell tick. delta_time here is in seconds.

Now that we have a simulation set up with all our logic and a way to connect players, we need to cover how we transfer data between the simulation and the clients. For that we will need to look at Serialization and the Muxer.