Worked Example

Introduction

Spatial messaging is a fundamental feature of Simulate, in which messages are sent from workers to workers based on the location of those workers in space. However, this is an over-approximation: the workers that receive a message will more than likely cover an area greater than just the area to which the message is relevant. Consequently, your user code must perform further filtering on the worker as part of its work to make sure any messages are matched up with the entities that are interested in them. This is a tutorial-style introduction to writing that code.

For the purposes of this tutorial, we will assume your simulation is based on EnTT. However, similar functionality can be implemented for any cell state with a notion of entity, including the Simulate Native ECS.

Overview

Simulate provides a message inbox and a message outbox for the worker on the aether::entity_based_user_state_base class, from which your cell state should inherit. In this tutorial we will implement a simple per-entity message-queue component and a system that dispatches spatial messages to the entities that fall within the destination area of the message by pushing them into the entity’s message queue. In a real application, such a system might read the message and immediately update the entity, which would be less wasteful of memory, but here we can imagine that a later system reads the messages off the queue and performs any associated updates.

Implementation

Setup

First, let’s define our message queue component. We can implement this as a simple struct that contains a std::deque of messages. We use the Simulate agent_ prefix convention to indicate a component. Let’s fix the payload of our messages as a simple uint8_t indicating how many entities are in the area.

struct volume {
    aether::vec3f center;
    float radius;
};

struct volume_data {
    std::uint8_t number_of_entities;
};

struct agent_message_queue {
    std::deque<std::pair<volume, volume_data>> messages{};
};

We'll also need an agent_position component to keep track of where the entities are.

struct agent_position {
    aether::vec3f position;
};

Let's assume we have a cell_state type for our cell state, which extends aether::entity_based_user_state_base with an Simulate-compatible EnTT store called store.

class cell_state : aether::entity_based_user_state_base {
    aether::entt::store store;
public:
    void initialise_world(const aether_state_type &) override;
    void cell_tick(const aether_state_type &, float delta_time) override;
    // ...
};

The initialise_world function is part of the Simulate contract for a cell state type, and we will use it to create ENTITY_COUNT entities, to each of which we will attach a message queue to function as a target for our message-delivery system. We’ll also attach a standard agent_position component so we know where they all are, though we’ll just put them at the origin for now — you might want to write another system that updates their locations later.

void cell_state::initialise_world(const aether_state_type & aether_state) {
    for (std::size_t i = 0; i < ENTITY_COUNT; ++i) {
        entt::entity entity = store.registry.create();
        store.registry.emplace<agent_message_queue>(entity, {});
        store.registry.emplace<agent_position>(entity, {0, 0, 0});
    }
}

Receiving messages

Simulate provides a message inbox to the user, which is attached to aether::entity_based_user_state_base under the name incoming_message_buffer. As part of our cell_tick, we need to read from the buffer. This is done by calling the get_message_reader member function to get a reader; said reader can then be asked to get_next (message), which returns a std::optional<aether::message::message>, and the resulting message, if any, can be decoded to a POD type using aether::message::message::payload_as_pod. Get_area_around returns an instance of aether::message::area_around. If we find an area_around on the message, we iterate over all our entities with a position and a message queue, and if the position falls within the sphere we push the message onto the back of the entity’s message queue.

void cell_state::cell_tick(const aether_state_type &, float delta_time) {
    // create a reader
    auto reader = incoming_message_buffer.get_reader();

    // iterate over all messages in the incoming message buffer
    while (auto message = reader.get_next()) {
        aether::message::message message = std::move(message.value());

        // check whether the message has an associated area_around
        if (auto area_around = message.get_area_around<vec3f>()) {
            aether::message::area_around area_around
                = std::move(area_around.value());
            auto center = area_around.getCenter();
            auto radius = area_around.getRadius();

            // iterate over all entities with a position and message queue
            store.registry.view<agent_position, agent_message_queue>().each(
            [&] (agent_position & position, agent_message_queue & message_queue) {
                // check whether the entity is contained within our sphere
                if ((position.position - center).norm() <= radius) {
                    volume_data data;
                    // try to decode the message, and on success put it on the back
                    // of the entity's message queue, tagged with its associated
                    // volume
                    if (message.payload_as_pod(data))
                        message_queue.emplace_back(volume{center, radius}, data);
                }
            });
        } else {
            // process other message types
        }
    }

    // ... perform simulation work ...
}

This algorithm is of course suboptimal, being in O(n²), and in cases of high traffic it would be better to use a data structure more optimized for spatial queries, such as an R-tree.

Sending messages

Now we can receive spatial messages and process them, we can send them as part of our cell_tick. Let’s say that every tick we want to send a message informing every entity within a sphere of radius 5.0f about the origin that there are twelve entities present. All we have to do is set the writer's destination to an instance of aether::message::area_around with set_destination, we can now send spatial messages the same way we would send any other message. Our data is POD, so we can just push it with push_bytes. We call writer.send() when we’re done to indicate that we have written a whole message.

void cell_state::cell_tick(const aether_state_type &, float delta_time) {
    // ...
    auto writer = outgoing_message_buffer.get_writer();
    volume_data data;
    data.number_of_entities = 12;
    writer.set_destination(aether::message::area_around{{0, 0, 0}, 5.0f});
    writer.push_bytes(&data);
    writer.send();
    // ...
}

Best practices

Always use this approach when dealing with sending messages between entities that are within the same cell, since it doesn't involve a network request.

Last updated