Generic Netcode Protocol

Documentation Objective

Overview

The generic netcode is designed to function as a net relevancy implementation for different simulations. As a consequence, the netcode must speak the correct protocols to receive data from the simulation and send updates to clients.

The generic netcode itself primarily manipulates C++ data structures that correspond to simulation entities, and also metadata received from simulation workers (typically used for debug clients to visualize which workers own areas of the simulation space). To bridge the gap between the on-the-wire protocol and these C++ data structures, the generic netcode is parameterized by a marshalling factory type, which enables it to create objects that it can use to encode and decode lists of entities from binary messages.


The marshalling factory

Aether Engine provides a virtual base class for marshalling factories: aether::netcode::marshalling_factory. A marshalling factory provides the functions create_marshaller and create_demarshaller which can be used to create objects that can decode and encode messages sent from the simulation to the muxer and sent from the muxer to clients.

template<typename Marshaller, typename Demarshaller>
class marshalling_factory {
public:
// Some details omitted for clarity
virtual marshaller_type create_marshaller() const = 0;
virtual demarshaller_type create_demarshaller() const = 0;
};

The marshalling factory is a useful place to encapsulate certain protocol parameters (e.g. the accuracy to which positions and velocities are sent) and makes it simple to ensure that a marshaller and corresponding demarshaller share the same parameters since these can be controlled by a single function that creates the factory object.

In a typical usage of the generic netcode, it is expected that

  • simulation workers will use marshallers to send messages to the muxer;
  • the muxer will use demarshallers to decode these messages;
  • the muxer will apply per-client interest management to the data sent from the simulation;
  • the muxer will use marshallers to sent data to connected clients;
  • clients will use demarshallers to decode the data sent from the muxer.

The marshaller interface

template<typename Traits>
class marshaller {
public:
using entity_type = typename Traits::entity_type;
using static_data_type = typename Traits::static_data_type;
using per_worker_data_type = typename Traits::per_worker_data_type;
virtual void set_static_data(const static_data_type &data) = 0;
virtual void reserve(const size_t count) = 0;
virtual void add_entity(const entity_type &entity) = 0;
virtual void add_worker_data(uint64_t worker_id, const per_worker_data_type &data) = 0;
virtual std::vector<char> encode() const = 0;
};

The marshaller interface defines a few member types that represent information that can be encoded into a message:

  • entity_type - This defines the type of entities that may be encoded via the interface. The general netcode will perform position-based interest management on these objects.
  • per_worker_data_type - This defines a value that may be associated with each simulation worker. Only one value may be associated with each worker. This value might contain information such as the simulation tick number (which is useful for interpolation) and/or the area of space the worker manages and/or debug information such as the number of entities managed by the worker.
  • static_data_type - This type represents data of which there should only be a single instance. For example, this might be used for a globally visible leaderboard. Forwarding this value to clients is not yet implemented in the current generic netcode.

Arbitrary numbers of entities may be added to a message using the add_entity function. The reserve function can be used to specify the number of entities to be added to the message in advance to improve allocation efficiency. add_worker_data can be used to associate an instance of a per_worker_data_type with a particular worker ID. For messages sent from workers to the muxer, the generic netcode will ignore the associated worker ID and choose an unspecified value if multiple values are sent.

Once all values have been input into the marshaller, the encode() function is used to produce a binary blob to be sent.


The demarshaller interface

template<typename Traits>
class demarshaller {
public:
using entity_type = typename Traits::entity_type;
using static_data_type = typename Traits::static_data_type;
using per_worker_data_type = typename Traits::per_worker_data_type;
virtual bool decode(const void *data, size_t count) = 0;
virtual std::vector<entity_type> get_entities() const = 0;
virtual std::optional<static_data_type> get_static_data() const = 0;
virtual std::unordered_map<uint64_t, per_worker_data_type> get_worker_data() const = 0;
};

The member types available in the demarshalling interface mirror those in the marshalling interface. The decode function is used to populate the demarshaller by decoding the supplied binary blob.get_entities can be used to retrieve a list of entities, and get_worker_data will return a map of worker id to data associated with that worker. It is a decision made by the netcode as to what entities and what worker data is sent to each client.


The trivial marshaller

In order to avoid having to write a marshaller from scratch, Aether Engine provides the trivial marshaller. The trivial marshalling factory is defined as aether::netcode::trivial_marshalling. The trivial marshaller is parameterised by a traits type which contains member types which define the entity, per-worker data and static data types. In the template project, the declaration of the marshalling factory used by both the demo and the muxer is found in protocol.hh

struct per_worker {
// Number of entities in the worker
int num_entities;
};
struct entity {
// Position of the entity
vec3f position;
};
// Defines how to parameterize the trivial marshaller
struct trivial_marshalling_traits {
using per_worker_data_type = per_worker;
using entity_type = entity;
using static_data_type = std::monostate; // Unused
};
int main() {
using marshalling_factory =
aether::trivial_marshalling<trivial_marshalling_traits>;
}

The trivial marshaller is so-named because it assumes that per_worker_data_type, entity_type and static_data_type are trivial types (e.g. it is valid to copy them using memcpy). This makes it fast to prototype by not having to define a protocol beyond declaring some simple struct types. In the longer term, a custom marshaller will be required since the trivial marshaller is incapable of handling issues such as data-layout, endian differences between platforms or performing any sort of protocol-specific optimizations.