Aether Hunt demo

Overview

Aether Hunt places multiple players in the position of a pack of wolves hunting their prey. And being lucky wolves, there are thousands of deer available to hunt!
Presented as a sandbox the players are free to decide how they would like to approach the challenge of hunting as much prey as possible. Shall they team up to leave stray deer no chance, or live up to being a lone wolf and hunt without help?
Aether Hunt demonstrates building a simple game on the Aether Engine, including integrating game logic within the Simulate ECS, attaching multiple players with different clients to the simulation, and scaling the number of agents in the game beyond what can be handled on a single machine by deploying in the cloud, without having to write code specifically to handle the complexity this brings.

Supported features

  • The world is infinite, and the simulation will respond to the need to keep simulating new parts of the world as the player moves.
  • A simple muxer implements a basic net relevancy function, helping optimise the network traffic between the simulation and the clients.
  • Prey has simple behaviour implemented, based on the boids simulation.
  • An Unreal Engine client that allows users to play the game
  • An OpenGL based client for inspecting the current state of the simulation, without having to play the game.

Prerequisites

  • The latest Aether SDK should be installed, with the BuildTools and CLI
  • WSL must be installed and configured properly. You can refer to Installing the Windows Subsystem for Linux (WSL) to do this.
  • Visual Studio 2019 and Unreal Engine 4.23+ has to be installed
  • The SDK VM should be running

Setting up the Simulation

  • Within the AetherHunt\Simulation directory call cmake -B build -G Ninja and ninja -C ./build to build the simulation then use aether run to run it within the local WSL.

Setting up the Unreal Engine Client

  • Generate Aether Hunt project file, by right clicking on Aether_Hunt.uproject and picking Generate Visual Studio projects
  • Open this generated project in Visual Studio, and build it.
  • Run the client project, and play in the Unreal Engine.
  • In menu, choose the default properties to connect to the simulation
  • Player inputs:
    • W, A, S, D - Run
    • E - Bite the prey (this attack has a cooldown).

Setting up the OpenGL Client

  • Open AetherHunt\AetherSimulation\OpenGLClient\AetherHuntClient.sln in Visual Studio
  • Run OpenGLClient. If the simulation is running on your local WSL, the OpenGL Client will automatically connect to it. If the client cannot connect you will see a blank screen.
  • Inputs:
    • W, A, S, D, Left Ctrl and Space move the camera
    • Mouse movement rotates the camera
  • When using the OpenGL client, red dots represent connected clients (typically this will be players, but there will also be a dot at the origin representing the OpenGL client itself), whilst green dots are prey. The green squares represent the region each worker is processing.

OpenGL features

  • Use mouse and W, A, S, D key to navigate camera
  • For OpenGL view Interest Management feature is ignored to show scale
  • For OpenGL, packet has additional array of cells to show server boundaries
  • Red colour represents predators & green colour represents preys
  • For now OpenGL clients logs in as player with ID 1

Simulation configuration

There are multiple settings that can be configured to change the behaviour within Aether Hunt. To update these settings, edit the AetherHunt\AetherSimulation\globals.hh file, and then run cmake -B build -G Ninja; ninja -C build, then aether run to see your changes.
Some of the settings affect the running of the simulation, whilst others affect the gameplay. Those that configure Simulate are:
  • TICK_RATE This sets the target tick rate for the server side simulation, in ticks per second.
  • WORKERS This sets the starting number of workers for the simulation.
  • NET_RELEVANCY_RADIUS This is used by Connect to define how close entities should be to the player for that player to receive updates about their position and state.
  • PREY_HANDOVER_RADIUS & PREDATOR_HANDOVER_RADIUS These define the how close a predator or prey should be to the edge of a cell before neighbouring cells are informed about updates to the prey or predator.
  • CELL_LEVEL This is the initial cell level, which controls the starting size of cells in the simulation. A smaller CELL_LEVEL gives a smaller cell and allows a better spread of CPU load across workers, at the expense of network traffic to hand over information about agents within the simulation between workers. This should only be relevant for the start of the simulation, Aethers load balancing should take over and rebalance the cell sizes afterwards.
Settings that impact gameplay are:
  • MAX_PLAYERS This sets the maximum number of simultaneous connected players
  • PREY_NUMBER This sets the number of prey in the simulation
  • PREDATOR_BITE_COOLDOWN This defines how long a player has to wait after having finished biting a prey before being able to bit again, in seconds.
  • PREDATOR_LENGTH_OF_BITE_IN_TICKS This is the number of ticks the predator biting animation takes to complete.
  • BITE_RADIUS This defines how close the player has to be to a prey for a bite attack to succeed.
  • PREY_MAX_SPEED This defines the maximum speed the prey can move.
  • WORLD_RADIUS This defines the radius of the world, beyond which prey will be encouraged to turn around and head towards the center of the game.
  • SPAWN_RADIUSThis defines how far apart prey should initially spawn, and influences how far from the origin players are able to spawn when they connect.
  • SIGHT This defines how close prey have to be to each other before responding to each others presence.
  • DANGER_SIGHT This defines how close the player can get to prey before the prey will respond to the presence of the player.
  • SEPERATION, FLOCKING_POWER, FLOCKING_REPEL These define the boids-like behaviour of the prey when not in the presence of a predator.
  • DANGER_REPEL This defines how strongly the prey will react to the player getting too close.

Adding more entities to the simulation:

Aether Hunt running on Simulate can scale the size of the simulation to match the available hardware. Running on two eight core servers, it can smoothly run with 8000 entities at 15Hz. Try playing with the settings above to change the number of entities, or use these settings to jump to 8000 entities:
  • PREY_NUMBER = 8000
  • WORKERS = 100
  • SPAWN_RADIUS = 62.2f
Remember to run aether run to see the effect of changing the settings.

Simulation Initialisation

The initial entrypoint for the simulation is the main method in main.cc. This method follows a typical Simulate setup, where we initiailise the Simulate and logger, create the octree and enter the main game loop. Code samples below are slightly abridged for clarity.
int main(int argc, char *argv[]) {
// Initialise the aether engine
hadean::init();
// Initialise the logger
aether::log::init("AE_Manager", hadean::pid::get());
// Configure the simulation
arguments arguments;
rguments.workers = WORKERS;
// ticks = 0 means run forever
arguments.ticks = 0;
arguments.tickrate = TICK_RATE;
// If this is false, Aether will run as fast as
// possible rather than at a steady tick rate
arguments.realtime = true;
// This controls the starting size of cells in the simulation
arguments.cell_level = CELL_LEVEL;
...
We then add the callbacks to handle the various events that happen during the lifecycle of an Simulate simulation. This is done by setting up the build_user_state callback on our octree params.
...
auto static_args = arguments.to_octree_params<octree_traits>();
static_args.build_user_state = [](
const aether_cell_state<octree_traits> &aether_state
) -> std::unique_ptr<user_state<octree_traits>> {
using user_state_type = entity_store_wrapper<entity_store_traits<octree_traits>>;
user_state_type::params_type params{};
// initialise_world handles any global setup, e.g. creating our prey
params.initialise_world = &initialise;
// initialise_cell handles per-cell setup, including ECS configuration
params.initialise_cell = &initialise_cell;
// handle_events allows the simulation to respond to player input
params.handle_events = &handle_events;
// cell_tick is the called per cell, per tick and advances the simulation
params.cell_tick = &cell_tick;
// serialize_to_client handles serializing the cell state to be sent to
// clients. The packets generated by this function will be sent to the
// muxer, which can choose what needs to be forwarded to which client
params.serialize_to_client = &cell_state_serialize;
// estimate_load is a hook to allow workers to indicate if they are being
// over or under utilised.
params.estimate_load = &estimate_load;
// deinitialise_cell allows for any per-cell cleanup
params.deinitialise_cell = &deinitialise_cell;
// One of the Aether Engine core concepts is that any agents within the
// simulation have a handover radius that defines how close to the edge
// of a neighbouring cell that agent would need to be before the neighbouring
// cell is made aware of the agents presence.
// Here we query the components the agent has from the ECS to decide what
// the handover radius should be.
params.agent_aabb = [](const auto &aether_state, const user_cell_state&, user_cell_state::agent_reference agent) -> auto {
vec2f pos{0, 0};
if( agent.has<c_base>() )
pos = agent.get_dynamic<c_base>()->position;
const float reach = agent.has<c_prey>() ? PREY_HANDOVER_RADIUS : PREDATOR_HANDOVER_RADIUS;
return encoder<2>::encode_aabb(pos, reach);
};
// In order to judge how close the entity is to neighbouring cells we need a
// single point, and this function is the one that defines that point.
params.agent_center = [](const auto &aether_state, const user_cell_state&, user_cell_state::agent_reference agent) -> auto {
vec2f pos{0, 0};
if( agent.has<c_base>() )
pos = agent.get_dynamic<c_base>()->position;
return encoder<2>::encode_position(pos);
};
return std::unique_ptr<user_state<octree_traits>>(
new user_state_type(params, aether_state)
);
};
...
Other than adding the various initialise, tick, serialize and deinitialise callbacks, the other main task we have just completed is hooking into the Simulate's handover system, by setting the params.agent_aabb and params.agent_center callbacks. These functions define eactly which cell a given agent belongs two, and which neighbouring cells we believe they may influence. This is then used by the Simulate to let the workers know which agents they are responsible for updating (particularly, those belonging to a cell that worker controls), and which workers need to receive updates about that agent (again, those workers controlling a cell that intersects the agent’s handover AABB).
Now that we have our hooks set up and our handover radius arranged, we can configure the octree and start our main loop.
...
// The octree handles the distribution of work across multiple machines
// As a result we want to make it aware of all the workers and muxers we
// need
aether::octree<octree_params_default<octree_traits>> octree(arguments.workers, static_args);
for (const auto &muxer : arguments.muxers) {
octree.add_muxer(muxer);
}
// Main loop. Most of the work here is handled by octree.master_tick()
for (uint64_t tick = 0; arguments.ticks == 0 || tick < arguments.ticks; tick++) {
auto loop_time = timer::get();
octree.master_tick();
AETHER_LOG(INFO)(fmt::format("Hello from tick {}", tick));
if (arguments.realtime) {
loop_time = timer::add(loop_time, static_cast<std::chrono::nanoseconds>(1s) / static_args.ticks_per_second);
timer::sleep_until(loop_time);
}
}
return 0;
}
This completes the required setup for starting a Simulate based game. Now that we are set up, we can investigate other parts of Aether Hunt.