Links

Simulation Lifecycle

Simulate engine simulation logic is implemented through a series of lifecycle callbacks that are registered with Simulate on starting the simulation. The callbacks are where the logic for your simulation lives defining what happens on simulation startup and on each incremental tick.
The logic that you implement through the callbacks is defined in a class that inherits from entity_based_user_state and is passed to the Manager on initialisation. See Initialising a Simulation for more details on how to pass arguments to the Manager.

Implementing the Simulation Lifecycle

A summary of each of the lifecycle functions is shown below. Details of the signature for each function can be found further down the page.
Lifecycle Callback
Description
Initialise World
Called once for the whole simulation in the first cell that is created and is responsible for initialising the simulation world. This will be called after the initialise_cell callback
Initialise Cell
Called when a new Simulate cell is created and can be used to initialise the state of the cell. This may be due to a cell merging with other neighbouring cells, or dividing from a larger cell, or being created in a new region of previously unmanaged space Iniitialise Cell is not tick synchronised when a cell is splitting or merging, this means that if initialisation will take a long time, it can occur across the course of multiple ticks. Once the new cells are ready handover will occur and the new cells will take ownership of any entities, with the old worker being deallocated. See Cell Initialisation for more detailed information.
Cell Tick
Called once per tick to advance the state of the simulation. The manager will pass a parameter indicating the amount of time to progress the simulation
Receive Messages / Send Messages
Each function is called once per tick to process events sent to the current cell or to send messages. This will include all message types that would have a destination including the current worker. See Messaging for more details. These functions will be handled automatically if using an ECS and so will not need to be implemented
Agent Axis Aligned Bounding Box
Called once per tick per agent (entity) to define the area over which an entity is of interest. This will be used to determine if Ghost entity information should be sent to neighbouring cells if the AABB overlaps with the neighbours managed area. It is specified by two Cartesian coordinates
Agent Center
Called once per tick per agent to set the effective location for an entity. This will be used by Aether to decide if ownership of an entity should be moved to another cell. The value is returned using integer Cartesian coordinates. See Converting between floating points and integers for more details
Get Topics
Called once per tick to tell Simulate which optional messages topics the worker would like to receive messages for on the next tick
Serialise to Client
Called once per tick. If your simulation is publishing its state to a connected client via Connect then this function will be called to allow the simulation to serialise the data it would like to send out to the client
Estimate Load
Called once per tick to return a value from 0 to 1 indicating the load on the cell. 1 being maximum load. This function is used to help Simulate determine if a Cell should be split to reduce load, or merged with neighbouring cells to not waste resources if the functionality is enabled
De-Initialise Cell
Called once when a Cell is Destroyed. This should be used for any clean-up that is required.
In addition to the lifecycle callback above, Simulate will need to have the following method implemented in order to gain access to the ECS
Get Store
Used by Simulate to access the entity storage mechanism. Simulate needs to do this so that it can transfer entities to other workers and make them aware of changes to entities in the current cell.
In order to implement these callbacks you will need to create a class that inherits from aether::entity_based_user_state<entity_store_traits<octree_traits>>,
however Simulate provides a base class aether::entity_based_user_state_base<entity_store_traits<octree_traits>>
that provides default implementation for many of the functions which will help in getting up and running quickly. An instance of this class will be passed to the Manager when you initialise your simulation.
The following shows an example header file showing the required function signatures.
simulate.hh
class user_cell_state_impl : public aether::entity_based_user_state_base<entity_store_traits<octree_traits>> {
public:
using traits_type = entity_store_traits<octree_traits>;
using store_type = traits_type::store_type;
store_type store;
public:
user_cell_state_impl(const aether_state_type &aether_state) : store {}
// DATA ACCESS
//------------------
store_type &get_store() override {
return store;
}
// LIFECYCLE FUNCTIONS
//--------------------
void initialise_cell(
const aether_state_type &aether_state
) override;
void initialise_world(
const aether_state_type &aether_state
) override;
// delta_time will be the amount of time to advance
// the simulation given in seconds
void cell_tick(
const aether_state_type &aether_state,
float delta_time
) override;
void serialize_to_client(
const aether_state_type &aether_state,
client_writer_type &writer
) override;
float estimate_load(
const aether_state_type &aether_state
) override;
void receive_messages(
const aether_state_type& aether_state,
message_reader_type& reader
) override;
std::vector<subscriber_topic_type> get_topics(
const aether_state_type& aether_state
) override;
// agent_reference is a type specific to the entity store in use
// and indicated which entity is being queried
aabb_type agent_aabb(
const aether_state_type &aether_state,
agent_reference agent
) override;
// agent_reference is a type specific to the entity store in use
// and indicated which entity is being queried
vector_type agent_center(
const aether_state_type &aether_state,
agent_reference agent
) override;
};
The common parameters passed to these lifecycle functions are as follows
  • aether_state_type &aether_state
    • an object providing the ability to inspect information about the current cell e.g. the current cell position and size.
  • client_writer_type &writer
    • an object used to send cell and agent data from a worker to the client.
  • message_reader_type &reader
    • an object providing access to messages and client-generated events received by the current cell.
Managing Entities
Simulate simulations must always have at least a single entity at all times. This helps Simulate manage the allocation of workers to cells, based on where entities are. If you do not have a natural entity that should exist for the lifetime of the simulation, you can create a dummy entity with no components during initialise_world. If you do this, ensure your agent_aabb and agent_centermethods correctly handle entities with no components on them.
Managing Estimate Load
As with having no entities in a simulation, if you set your estimate_load function so that it can return 0 for all cells simultaneously, then this can cause Simulate to try to deallocate all workers, leading to a crash

Tips

  • Get Topics is called by Simulate to determine which events the current worker is interested in, this can be used to get updates about player controlled entities located in the cell by setting the list of topics so that this cell receives all events sent by those players. See Messaging for more information on setting destination and defining topics

Converting between floating-points and integers

The engine is designed to work with integer-based position types in order to avoid loss of precision. Fortunately, users can still use floating-point types in their simulation logic as long as integer values are fed into the engine. Care must be taken when converting from floating-point to integer values. We must use the floor() operation on the floating-point value before casting it. The key thing to keep in mind is that the integer value is used by the engine to determine the cell to which some point belongs.:
// Use conversion functions provided by the engine to cast vectors:
static_cast<vec2i64>(vec2f{ 2.5, 2.5 });
static_cast<vec3i64>(vec3f{ 2.5, 2.5, 2.5 });
// You must use `floor` when converting individual floating-point values:
static_cast<int64_t>(floor(2.5));
Consider a one-dimensional AABB (axis-aligned bounding box). It is defined by two coordinates: (-0.1) and (0.1). Assuming there is a cell boundary along the origin, we can see that this particular AABB occupies two cells. Converting using floor() gives us (floor(-0.1), floor(0.1)) i.e. (-1, 0). This lets the engine know that this AABB is in cell -1 and cell 0 (let’s assume that each cell has unit length). Without floor(), the engine would incorrectly assume that the bounding box only belongs to cell 0.
Using this casting method should not introduce any inconsistency as long as we make sure that conversions are always done properly.