Using the C++ worker SDK
Overview
The most important units of functionality in the C++ worker API are:
- The
worker::Locator
, which is responsible for querying for and connecting to remote cloud deployments. - The
worker::Connection
, which represents a worker’s connection to a SpatialOS simulated world. - The
worker::Dispatcher
, which is responsible for invoking user-provided callbacks based on data received from SpatialOS via theworker::Connection
. - The
worker::Entity
, which is a container for the component data associated with a single entity. - The
worker::View
, which is an optional drop-in replacement for theworker::Dispatcher
that additionally maintains a map from entity ID toworker::Entity
.
For the full API reference, have a look at <improbable/worker.h>
.
Providing your components
Since C++ has no reflection capability, the SDK makes no attempt to discover your components automatically. Instead, the list of components you want to work with must be explicitly provided to SDK methods that need to know about them (including standard library components).
This is done by passing an instance of the worker::Components
variadic template class:
// MyComponents can be passed to SDK functions.
const worker::Components<
my_schema::FooComponent,
my_schema::BarComponent,
// Must include standard library components you care about, too.
improbable::EntityAcl,
improbable::Position,
...> MyComponents;
Since worker::Components
is a template, you need to include the generated code for every component
you want to work with in order to define MyComponents
. However, since this class implements the
worker::ComponentRegistry
interface, this only needs to be done in one source file:
// This function can be called from other translation units without including generated code.
const worker::ComponentRegistry& MyComponents() {
static const worker::Components<my_schema::FooComponent, my_schema::BarComponent> components;
return components;
}
The rest of this page assumes you have defined a MyComponents
function like this.
Connecting to SpatialOS
Before it can interact with the simulated world, the worker must connect to SpatialOS.
There are two ways of doing this:
- Instantiate a
worker::Connection
object directly. This allows to connect a managed worker (i.e running in the cloud and started by the SpatialOS runtime) or a remote worker used for debugging via (spatial cloud connect external
). The connection is made through the receptionist service of the targeted deployment for which the IP and port should be passed as arguments to the worker. These values can be filled-in automatically at runtime by using theIMPROBABLE_RECEPTIONIST_HOST
andIMPROBABLE_RECEPTIONIST_PORT
placeholders in your worker’s launch configuration. - Use the
worker::Locator
object to enumerate cloud deployments and connect to a chosen deployment with authentication. This is typically used to connect an external client to a cloud deployment.
The value given for the UseExternalIp
field in the NetworkParameters
structure is also relevant.
The table below summarizes the connection possibilities:
Using worker::Connection directly |
Using worker::Locator |
|
---|---|---|
UseExternalIp == true |
Local client connecting via spatial cloud connect external proxy |
External client connecting to cloud deployment |
UseExternalIp == false |
Managed cloud worker; local client connecting to local deployment | N/A |
#include <improbable/standard_library.h>
#include <improbable/worker.h>
#include <example.h>
using namespace improbable;
The example below illustrates a very basic connection setup, where the function takes one argument specifying the worker’s own ID, and an optional second one representing the receptionist IP address. If the latter is not provided, the worker connects to localhost. In either case, it will use TCP and connect using internal IP address.
worker::Connection GetConnection(const std::string& worker_id, const std::string& hostname) {
worker::ConnectionParameters parameters;
parameters.WorkerType = "CppDocsWorker";
parameters.Network.ConnectionType = worker::NetworkConnectionType::kTcp;
parameters.Network.UseExternalIp = false;
return worker::Connection::ConnectAsync(MyComponents(), hostname, 7777, worker_id, parameters)
.Get();
}
Note that worker::Connection
objects are not thread-safe.
Handling data received from SpatialOS
It is up to each worker to decide how the basic event loop should be structured, depending on its
requirements. The worker should call worker::Connection::GetOpList
to get a list of “operations”
(for example a new entity, or a component update) from SpatialOS that have been sent since the last time
the function was called. This worker::OpList
then needs to be passed to a dispatcher in order to
invoke user-provided callbacks.
The following snippet shows a (very) simple example implementation of an event loop that processes operations from SpatialOS 60 times per second:
void RunEventLoop(worker::Connection& connection, const worker::Dispatcher& dispatcher) {
constexpr unsigned kFramesPerSecond = 60;
constexpr std::chrono::duration<double> kFramePeriodSeconds(
1. / static_cast<double>(kFramesPerSecond));
while (true) {
auto start_time = std::chrono::steady_clock::now();
auto op_list = connection.GetOpList(0 /* non-blocking */);
// Invoke user-provided callbacks.
dispatcher.Process(op_list);
// Do other work here...
auto stop_time = std::chrono::steady_clock::now();
auto wait_for = kFramePeriodSeconds - (stop_time - start_time);
std::this_thread::sleep_for(wait_for);
}
}
Note that all callbacks provided to the
worker::Dispatcher
will be invoked only when worker::Dispatcher::Process
is called, and only
on the thread that is currently calling worker::Dispatcher::Process
: the user has complete
control over callback timing and threading decisions.
If the connection fails (when Connection.IsConnected() == false
), any error messages explaining the cause
will be returned by GetOpList(). Make sure to process this list in order to receive all
available diagnostic information.
Dispatcher callbacks
Several kinds of callbacks can be registered on the worker::Dispatcher
. Each method takes an
arbitrary callable std::function<void(const Param&)>
, where the parameter type Param
depends on
the particular kind of callback being registered. The following table has the details:
Method | Parameter type (and fields) | Invoked when… |
---|---|---|
OnDisconnect |
worker::DisconnectOp (std::string Reason ) |
the Connection is no longer connected and can no longer be used. Check the log for errors relating to the disconnection. |
OnFlagUpdate |
worker::FlagUpdateOp (std::string Name , Option<std::string> Value ) |
a worker flag has been created, deleted or when its value has changed. |
OnLogMessage |
worker::LogMessageOp (worker::LogLevel Level , std::string Message ) |
the SDK issues a log message for the worker to print. This does not include messages sent using Connection.SendLogMessage . |
OnMetrics |
worker::MetricsOp (worker::Metrics Metrics ) |
the SDK reports built-in internal metrics. |
OnCriticalSection |
worker::CriticalSectionOp (bool InCriticalSection ) |
a critical section is about to be entered or has just been left. |
OnAddEntity |
worker::AddEntityOp (worker::EntityId EntityId ) |
an entity is added to the worker’s view of the simulation. |
OnRemoveEntity |
worker::RemoveEntityOp (worker::EntityId EntityId ) |
an entity is removed from the worker’s view of the simulation. |
OnReserveEntityIdResponse |
worker::ReserveEntityIdResponseOp (worker::RequestId<worker::ReserveEntityIdRequest> RequestId , worker::StatusCode StatusCode , std::string Message , Option<worker::EntityId> EntityId ) |
the worker has received a response for an entity ID reservation it had requested previously. |
OnReserveEntityIdsResponse |
worker::ReserveEntityIdsResponseOp (worker::RequestId<worker::ReserveEntityIdsRequest> RequestId , worker::StatusCode StatusCode , std::string Message , Option<worker::EntityId> EntityId , std::size_t NumberOfEntityIds ) |
the worker has received a response for a reservation of an entity ID range it had requested previously. |
OnCreateEntityResponse |
worker::CreateEntityResponseOp (worker::RequestId<worker::CreateEntityRequest> RequestId , worker::StatusCode StatusCode , std::string Message , Option<worker::EntityId> EntityId ) |
the worker has received a response for an entity creation it had requested previously. |
OnDeleteEntityResponse |
worker::DeleteEntityResponseOp (worker::RequestId<DeleteEntityRequest> RequestId , worker::EntityId EntityId , worker::StatusCode StatusCode , std::string Message ) |
the worker has received a response for an entity deletion it had requested previously. |
OnEntityQueryResponse |
worker::EntityQueryResponseOp (worker::RequestId<EntityQueryRequest> RequestId , worker::StatusCode StatusCode , std::string Message , std::size_t ResultCount , worker::Map<worker::EntityId, worker::Entity> Result >) |
the worker has received a response for an entity query it had requested previously. |
OnAddComponent<T> |
worker::AddComponentOp<T> (worker::EntityId EntityId , T::Data Data ) |
a component is added to an existing entity in the worker’s view of the simulation. |
OnRemoveComponent<T> |
worker::RemoveComponentOp (worker::EntityId EntityId ) |
a component is removed from an existing entity in the worker’s view of the simulation. |
OnAuthorityChange<T> |
worker::AuthorityChangeOp (worker::EntityId EntityId , worker::Authority Authority ) |
the worker’s authority state over a component is changed to Authoritative, Not Authoritative or Authority Loss Imminent. |
OnComponentUpdate<T> |
worker::ComponentUpdateOp<T> (worker::EntityId EntityId , T::Update Update ) |
a component for an entity in the worker’s view of the simulation has been updated. |
OnCommandRequest<T> |
worker::CommandRequestOp<T> (worker::RequestId<IncomingCommandRequest<T>> RequestId , worker::EntityId EntityId , std::uint32_t TimeoutMillis , std::string CallerWorkerId , List<std::string> CallerAttributeSet , T::Request Request ) |
the worker has received a command request for a component on an entity over which it has authority. |
OnCommandResponse<T> |
worker::CommandResponseOp<T> (worker::RequestId<OutgoingCommandRequest<T>> RequestId , worker::EntityId EntityId , worker::StatusCode StatusCode , Option<T::Response> Response ) |
the worker has received a command response for a request it issued previously. |
(Note that some callbacks use a template parameter T
which relates to schema-generated code. This is
discussed in more detail below.)
Here’s an example of registering callbacks:
void RegisterLambdaCallbacks(worker::Dispatcher& dispatcher) {
dispatcher.OnAddEntity([&](const worker::AddEntityOp& op) {
std::cout << "Entity " << op.EntityId << " added." << std::endl;
});
dispatcher.OnComponentUpdate<example::Switch>(
[&](const worker::ComponentUpdateOp<example::Switch>& op) {
// `op.Update.toggled()` contains a list of SwitchToggled events.
for (auto it : op.Update.toggled()) {
std::cout << "Switch toggled at " << it.time() << std::endl;
}
});
}
Of course, you don’t have to use lambdas:
void RegisterCallbacks(worker::Dispatcher& dispatcher) {
dispatcher.OnComponentUpdate<improbable::Position>(&PositionUpdated);
}
void PositionUpdated(const worker::ComponentUpdateOp<improbable::Position>& op) {
const auto coords = op.Update.coords().data();
std::cout << "Updated position of entity " << op.EntityId << " to "
<< " x: " << coords->x() << " y: " << coords->y() << " z: " << coords->z() << std::endl;
}
If you want to unregister a callback, call Remove()
with the Dispatcher::CallbackKey
returned from the registration method:
void UnregisterCallbacks(worker::Dispatcher& dispatcher) {
auto key = dispatcher.OnComponentUpdate<improbable::Position>(
[&](const worker::ComponentUpdateOp<improbable::Position>& op) {
std::cout << "Updated position of entity " << op.EntityId << std::endl;
});
// ...
dispatcher.Remove(key);
}
Using dynamic component dispatch
The SDK provides two functions for type-safe manipulation of components when the component
type is not known statically (however, the complete set of possible components must be statically
provided using the worker::Components
template class.
These functions are defined as follows:
/**
* Invokes Accept<T>() on the provided handler for the component in the given
* list of components whose ID matches the given component ID.
*/
template <typename... T, typename Handler>
void ForComponent(const Components<T...>& components, ComponentId component_id, Handler&& handler);
/**
* Invokes Accept<T>() on the provided handler for each component in the given list of
* components.
*/
template <typename... T, typename Handler>
void ForEachComponent(const Components<T...>& components, Handler&& handler);
For example, you could use ForComponent
to copy the components of one entity to another without
knowledge of the types of the components. To do this, you would first define a class which
implements a handler to provide the desired copying functionality:
struct CopyFunc
{
const Entity& from;
Entity& to;
template <typename T>
void Accept<T>() {
to.Add<T>(*from.Get<T>());
}
}
You would then an instance of the CopyFunc
class and call the static
methods worker::ForComponent
or worker::ForEachComponent
to apply the functionality defined in
Accept
to the entity’s components:
// Probably defined elsewhere.
using MyComponents = worker::Components<my_schema::Foo, my_schema::Bar>;
// Copy all components from source_entity to target_entity.
CopyFunc copy_func{source_entity, target_entity};
for (auto component_id : source_entity.GetComponentIds()) {
worker::ForComponent(MyComponents{}, component_id, copy_func);
}
This functionality can also be used to implement a View
with custom semantics, if desired.
Using the View
worker::View
is subclass of worker::Dispatcher
defined in a separate header file,
<improbable/view.h>
, which adds the functionality of automatically
maintaining the current state of the worker’s view of the simulated world. It has a field called
Entities
, which is a map from entity ID to worker::Entity
objects. When a View
processes an OpList
, it automatically updates the state of this map as appropriate. Any
user-defined callbacks are also invoked, as per the Dispatcher
contract.
Note that the View
can be implemented from scratch using the worker::ForEachComponent
functionality, and is intended mostly as an example.
Dispatcher invocation order
When processing a worker::OpList
, callbacks for most operations are invoked in the order they
were registered with the worker::Dispatcher.
However, some operations naturally come in pairs of
“start” and “end” operations, and the callbacks for the “end” operation are invoked in the reverse
of the order they were registered. This means that pairs of callbacks registered for the “start” and
“end” operations can correctly depend on resources managed by pairs of callbacks registered
previously, similar to the usual C++ construction and destruction order.
For example, AddEntityOp
and AddComponentOp
callbacks are invoked in the order they were
registered with the worker::Dispatcher
, while RemoveEntityOp
and RemoveComponentOp
callbacks
are invoked in reverse order. Similarly, AuthorityChangeOp
callbacks are invoked in the order
they were registered when authority is granted, but in reverse order when authority is revoked (or
authority loss is imminent). CriticalSectionOp
s are invoked in usual order when a critical
section is entered but reverse order when a critical section is left. The FlagUpdateOp
callbacks
are invoked in the order they were registered when a worker flag is created or its value is changed,
but in reverse order when a worker flag is deleted.
In particular, this invocation order has the side-effect that the final state of a component removed
in a RemoveComponentOp
callback can still be inspected in the worker::View
.
Critical sections
The protocol supports the concept of critical sections. A critical section is a block of operations that should be processed atomically by the worker. For example, a set of entities representing a stack of cubes should be added to the scene in its entirety before any physics simulation is done.
The CriticalSectionOp
callback is invoked with InCriticalSection == true
just before
the first operation of the critical section is delivered, and is invoked again with
InCriticalSection == false
just after the last operation of the critical section is delivered.
Worker configuration
A worker is assigned some static configuration settings when it connects to the SpatialOS runtime:
- a worker ID which is a unique identifier string for this particular worker.
- a list of worker attributes that are used when to determine to which worker the authority over a component can be delegated, either automatically or via by the developer through ACLs.
The values of these configuration settings can be retrieved via the GetWorkerId
and GetWorkerAttributes
methods of a
Connection
:
std::string GetWorkerId() const;
worker::List<std::string> GetWorkerAttributes() const;
Worker flags
To access the value of a worker flag, use the GetWorkerFlag
method
of a Connection
:
Option<std::string> GetWorkerFlag(const std::string& flag_name) const;
For example:
void SetWorkSpeedFlag(worker::Connection& connection) {
auto work_speed_flag = connection.GetWorkerFlag("mycompany_theproject_work_speed");
if (work_speed_flag) {
SetWorkSpeed(*work_speed_flag);
} else {
SetWorkSpeed("default_work_speed");
}
}
Worker flags are updated before the registered callbacks are invoked, so it is recommended to store the worker flag values in user code when using callbacks and only use GetWorkerFlag for checking the initial flag values.
Sending data to SpatialOS
A worker can send data such as logging, metrics, and component updates to SpatialOS using the
worker::Connection
.
Sending logs
You can send log messages by calling Connection::SendLogMessage
. Log messages can be viewed on deployment dashboards.
Sending and receiving metrics
You can optionally send metrics by calling Connection::SendMetrics
. Metrics can be viewed on deployment dashboards and in the Inspector.
There are two typical use cases for sending metrics:
- Reporting your own custom metrics. Both time series and histogram metrics are supported.
- Updating a worker’s load when the worker is using dynamic load balancing. The load of a worker is a floating-point value, with 0 indicating an unloaded worker and values above 1 corresponding to an overloaded worker. The reported values direct SpatialOS’s load balancing strategy.
The following example demonstrates both use cases:
Workers automatically send several built-in, internal metrics at a period
defined by the BuiltInMetricsReportPeriodMillis
field of worker::ConnectionParameters
.
You can register a callback to receive these metrics inside a worker::MetricsOp
using
Dispatcher::OnMetrics
.
Sending and receiving component updates
When the worker has authority over a particular component for some entity, it can send component
updates to SpatialOS. This is done using the worker::Connection
method
SendComponentUpdate<T>
which takes a reference to a T::Update
object. The template parameter T
should be component metaclass in the schema-generated code. These are discussed further below.
A component update can modify the value of a property or trigger an event. You can modify multiple properties and trigger multiple events in the same component update.
Component updates sent by the worker will appear in the operation list returned by a subsequent
call to worker::Connection::GetOpList
. This means that ComponentUpdateOp
callbacks will also be
invoked for component updates triggered by the worker itself, but not immediately.
There are several (related) reasons that callbacks for sent component updates should not be invoked
immediately: this can lead to extremely unintuitive control flow when components are recursively
updated inside a callback; and it violates the guarantee that callbacks are only invoked as a result
of a call to Process
method on the worker::Dispatcher
.
To be notified when a worker receives a component update on an entity in the worker’s local
view of the simulation, use the worker::Dispatcher
method OnComponentUpdate
with the same
template paramater T
as for sending updates. Note that the component updates can be partial, i.e.
only update some properties of the component, do not necessarily have to contain data that is different
from the workers current view of the component, and could have been sent by SpatialOS rather than a worker
for synchronization purposes.
Sending and receiving component events
Sending and receiving events works much in the same way as component updates. For a schema like the following,
package example;
type SwitchToggled {
int64 time = 1;
}
component Switch {
id = 1234;
bool is_enabled = 1;
event SwitchToggled toggled;
}
triggering an event can be done as follows:
void TriggerEvent(worker::Connection& connection, worker::EntityId entity_id) {
example::Switch::Update update;
example::SwitchToggled event{1};
update.add_toggled(event);
connection.SendComponentUpdate<example::Switch>(entity_id, update);
}
If you are not authoritative on the component, your event will be silently ignored.
Receiving an event works just like receiving a component update, by registering a callback on the dispatcher:
dispatcher.OnComponentUpdate<example::Switch>(
[&](const worker::ComponentUpdateOp<example::Switch>& op) {
// `op.Update.toggled()` contains a list of SwitchToggled events.
for (auto it : op.Update.toggled()) {
std::cout << "Switch toggled at " << it.time() << std::endl;
}
});
Sending component interests
For each entity in its view, a worker receives updates for a set of the entity’s components. This set is the union of the set of components the worker has authority over, and a set of components it is explicitly interested in.
The initial set of explicitly interested components can be configured in the
bridge settings.
This is the default set of explicit interest for every entity. At runtime, the worker can override this
set on a per-entity, per-component basis, by using the Connection::SendComponentInterest
method.
Whenever the set of interested components for an entity changes, the worker will receive the appropriate
OnAddComponent
and OnRemoveComponent
callbacks to reflect this change.
For example, you might be interested in one specific switch, but not every switch in the world:
void ComponentInterest(worker::Connection& connection, worker::EntityId entity_id) {
connection.SendComponentInterest(
entity_id,
{{example::Switch::ComponentId, worker::InterestOverride{/* IsInterested */ true}}});
}
Note that a worker is always considered to be interested in the components of the entities it is authoritative on, in addition to any others specificed in the bridge settings or using the method described above.
Sending and receiving component commands
To send a command to be executed by the worker currently authoritative over a particular component
for a particular entity, use the worker::Connection
method SendCommandRequest<T>
. In this case the
template parameter T
should be a command metaclass defined in the schema-generated code. This
function takes an entity ID, an optional timeout, a T::Request
object and an optional
CommandParameters
object. The CommandParameters
object contains a field called
AllowShortCircuiting
, which, if set to true, will try to “short-circuit” the command and avoid a
round trip to SpatialOS in some cases. See
documentation on commands for more information.
Before sending the command, a callback to handle the response should be registered with the
worker::Dispatcher
with OnCommandResponse<T>
. The worker::RequestId<worker::OutgoingCommandRequest<T>>
returned by SendCommandRequest
can be matched up with the one in the worker::CommandResponseOp
to identify the request that is being responded to.
Note that commands may fail, so the worker::StatusCode
field in the
worker::CommandResponseOp
should be checked, and the command can be retried as necessary.
The caller will always get a response callback, but it can be one of several failure cases, including:
ApplicationError
(rejected by the target worker or by SpatialOS)AuthorityLost
(target worker lost authority, or no worker had authority)NotFound
(target entity, or target component on the entity, didn’t exist)PermissionDenied
(sending worker didn’t have permission to send request)Timeout
InternalError
(most likely indicates a bug in SpatialOS, should be reported)
To handle commands issued by another worker, the opposite flow is used. Register a callback with the
worker::Dispatcher
with OnCommandRequest<T>
; when the callback is executed, the worker should
make sure to call the worker::Connection
method SendCommandResponse<T>
, supplying the
worker::RequestId<worker::IncomingCommandRequest<T>>
provided by the worker::CommandRequestOp
and an appropriate T::Response
response object. Alternatively, the worker can call
SendCommandFailure<T>
to fail the command instead.
Note that a RequestId
only uniquely identifies a single command invocation from the perspective of a
single worker; that is, the RequestId
used by the calling worker and the RequestId
used by
the caller worker for handling that same command are unrelated.
Entity queries
A worker can run remote entity queries against the simulation by using the worker::Connection
method SendEntityQueryRequest
. This takes a worker::query::EntityQuery
object and an optional timeout.
The query object is made of an worker::query::Constraint
and a worker::query::ResultType
.
The constraint determines which entities are matched by the query, and the result type determines what data is
returned for matched entities. Available constraints and result types are described below.
Constraint | Description |
---|---|
EntityIdConstraint |
Matches a specific entity ID. |
ComponentConstraint |
Matches entities with a particular component. |
SphereConstraint |
Matches entities contained in the given sphere. |
AndConstraint |
Matches entities that match all of the given subconstraints. |
OrConstraint |
Matches entities that match any of the given subconstraints. |
NotConstraint |
Matches entities that do not match the given subconstraint. |
Result type | Description |
---|---|
CountResultType |
Returns the number of entities that matched the query. |
SnapshotResultType |
Returns a snapshot of component data for entities that matched the query. To select all components, use worker::query::SnapshotResultType snapshotResultType{{}}; . To select every component whose ID is contained in the given set, use worker::query::SnapshotResultType snapshotResultType{{componentIdSet}}; (thus, pass an empty set to get no components but entity IDs only). |
Like other request methods, this returns a worker::RequestId
, which can be used to match a request with its
response. The response is received via a callback registered with the worker::Dispatcher
using the
OnEntityQueryResponse
method.
The EntityQueryResponseOp
contains a std::size_t ResultCount
field (for CountResultType
requests)
and a Map<EntityId, Entity>
(for SnapshotResultType
) requests. Again, success or failure of the
request is indicated by the StatusCode
field of the response object, but in the failure case the
result may still contain some data: the count or snapshot map might still contain the data for
some entities that matched the query, but won’t necessarily contain all matching entities. This is
because the worker might still be able to do something useful with a partial result.
Creating and deleting entities
A worker can request SpatialOS to reserve one entity ID, reserve a range of entity IDs, create an entity,
or delete an entity, by using the worker::Connection
methods SendReserveEntityIdRequest
,
SendReserveEntityIdsRequest
, SendCreateEntityRequest
, and SendDeleteEntityRequest
, respectively.
These methods return a worker::RequestId
, which can be used to match a request with its response. The response is
received via a callback registered with the worker::Dispatcher
using OnReserveEntityIdResponse
,
OnReserveEntityIdsResponse
, OnCreateEntityResponse
, and OnDeleteEntityResponse
, respectively.
Note that these operations may fail, so the StatusCode
field in the ReserveEntityIdResponseOp
,
ReserveEntityIdsResponseOp
, CreateEntityResponseOp
, or DeleteEntityResponseOp
argument of the respective
callback should be checked, and the command can be retried as necessary. The caller will always get a response
callback, but it might be a timeout.
SendReserveEntityIdRequest
takes an optional timeout. If the operation succeeds, the response contains
an entity ID, which is guaranteed to be unused in the current deployment.
SendReserveEntityIdsRequest
takes an optional timeout. If the operation succeeds, the response contains
an entity ID, which is the first in a contiguous range guaranteed to be unused in the current deployment,
and the corresponding number of entity IDs in the reserved range. If the operation fails, the entity ID
is not set and the number of entity IDs in the reserved range is 0.
SendCreateEntityRequest
takes a worker::Entity
representing the initial state of the entity, an optional
entity ID (which, if provided, must have been obtained by a previous call to SendReserveEntityIdRequest
or
SendReserveEntityIdsRequest
), and an optional timeout. If the operation succeeds, the response contains
the ID of the newly created entity.
SendDeleteEntityRequest
takes an entity ID and an optional timeout. The response contains no
additional data.
Example
Here’s an example of reserving an entity ID, creating an entity with that ID and some initial state, and finally deleting it:
void CreateDeleteEntity(worker::Connection& connection, worker::Dispatcher& dispatcher) {
constexpr uint32_t kTimeoutMillis = 500;
// Reserve an entity ID.
worker::RequestId<worker::ReserveEntityIdRequest> entity_id_reservation_request_id =
connection.SendReserveEntityIdRequest(kTimeoutMillis);
// When the reservation succeeds, create an entity with the reserved ID.
worker::RequestId<worker::CreateEntityRequest> entity_creation_request_id;
dispatcher.OnReserveEntityIdResponse([&](const worker::ReserveEntityIdResponseOp& op) {
if (op.RequestId == entity_id_reservation_request_id &&
op.StatusCode == worker::StatusCode::kSuccess) {
// ID reservation was successful - create an entity with the reserved ID.
worker::Entity entity;
entity.Add<improbable::Position>({{1, 2, 3}});
entity_creation_request_id =
connection.SendCreateEntityRequest(entity, op.EntityId, kTimeoutMillis);
}
});
// When the creation succeeds, delete the entity.
worker::RequestId<worker::DeleteEntityRequest> entity_deletion_request_id;
dispatcher.OnCreateEntityResponse([&](const worker::CreateEntityResponseOp& op) {
if (op.RequestId == entity_creation_request_id &&
op.StatusCode == worker::StatusCode::kSuccess) {
entity_deletion_request_id = connection.SendDeleteEntityRequest(*op.EntityId, kTimeoutMillis);
}
});
// When the deletion succeeds, we're done.
dispatcher.OnDeleteEntityResponse([&](const worker::DeleteEntityResponseOp& op) {
if (op.RequestId == entity_deletion_request_id &&
op.StatusCode == worker::StatusCode::kSuccess) {
// Test successful!
}
});
}
Entity ACLs
Entity ACLs are exposed to the worker as a component, and
can be manipulated as any other component. ACLs can be set at entity creation time, or modified
dynamically at runtime by the worker that has authority over the EntityAcl
component.
This example adds an EntityAcl
to an Entity
given a CommandRequestOp
, which is currently the only
way to make a specific worker (as opposed to, potentially, a set of workers) to qualify for authority of
a component. Specifically:
- The entity will be visible to workers that have the “client” or “physics” worker attribute.
- Any “physics” worker (i.e. a worker with “physics” as one of its attributes) can be authoritative over the
entity’s
Position
andEntityAcl
components. - The worker, which issued the
CommandRequestOp
, is the only worker that can be authoritative over thePlayerControls
component.
This can be used as part of creating a new entity in response to a command request.
template <typename C>
void AddComponentDelegations(const worker::CommandRequestOp<C>& op, worker::Entity& entity) {
static const std::string physics = "physics";
static const std::string client = "client";
// This requirement set matches only the command caller, i.e. the worker that issued the command,
// since attribute set includes the caller's unique attribute.
auto callerWorkerAttributeSet = improbable::WorkerAttributeSet{op.CallerAttributeSet};
auto callerWorkerRequirementSet = improbable::WorkerRequirementSet{
worker::List<improbable::WorkerAttributeSet>{callerWorkerAttributeSet}};
auto physicsWorkerAttributeSet = worker::List<std::string>{physics};
// This requirement set matches any worker with the attribute "physics".
auto physicsWorkerRequirementSet = improbable::WorkerRequirementSet{
worker::List<improbable::WorkerAttributeSet>{{physicsWorkerAttributeSet}}};
// This requirement set matches any worker with the attribute "client" or "physics".
auto clientOrPhysicsRequirementSet =
improbable::WorkerRequirementSet{worker::List<improbable::WorkerAttributeSet>{
improbable::WorkerAttributeSet{worker::List<std::string>{client}},
physicsWorkerAttributeSet}};
// Give authority over Position and EntityAcl to any physics worker, and over PlayerControls to
// the caller worker.
worker::Map<worker::ComponentId, improbable::WorkerRequirementSet> componentAcl{
{improbable::Position::ComponentId, physicsWorkerRequirementSet},
{EntityAcl::ComponentId, physicsWorkerRequirementSet},
{example::PlayerControls::ComponentId, callerWorkerRequirementSet}};
entity.Add<EntityAcl>(
EntityAcl::Data{/* read */ clientOrPhysicsRequirementSet, /* write */ componentAcl});
}
The worker authoritative over the EntityAcl
component can later decide to give the authority over
position (or any other component) to a different worker, e.g. the client. In order to do this,
EntityAclData
needs to be modified in order to map Position::ComponentId
to callerWorkerRequirementSet
(created above). This change can be made using the method below:
// Take a copy of the EntityAclData argument, so that this method doesn't modify the original one.
improbable::EntityAclData
DelegateComponent(improbable::EntityAclData acl, worker::ComponentId componentId,
const improbable::WorkerRequirementSet& requirementSet) {
// Set the write ACL for the specified component to the specified attribute set,
// assuming the componentAcl option is not empty.
acl.component_write_acl().insert({{componentId, requirementSet}});
return acl;
}
For these changes to take effect, the worker authoritative over the EntityAcl
component needs send the changes
through a component update.
Please read Worker attributes and worker requirements for more information.
Generated code
The C++ schema-generated code consists of two main parts: data classes and component classes. Data
classes are used to represent data at rest and correspond to the schemalang type
definitions;
component classes correspond to schemalang component
definitions and contain metadata and classes
for sending and receiving component data (updates, command requests and command responses).
A data class will be generated for each type defined in the schema, with fields corresponding to each field in the schema definition.
For each component, we generate:
- a metaclass. These metaclasses are used when referring to specific components when using the API -
every template parameter
T
expects a metaclass argument. - an
Update
class nested inside the metaclass. This has an optional field for each field in the component (since it represents a diff). - command metaclasses nested inside the metaclass, as
SomeComponent::Commands::SomeCommand
. These work in a similar way to component metaclasses, but for command-related functions.
Data representation
- Strings are represented as UTF-8 encoded
std::string
members. Make sure to use this same encoding when updating a component. list<T>
fields are represented as aworker::List<T>
of the repeated type. This type is very similar tostd::vector<T>
and has most of the methods you’d expect.map<Key, Value>
fields are represented asworker::Map<Key, Value>
. This type is very similar tostd::unordered_map<Key, Value>
and has most of the methods you’d expect. The biggest difference is it has a well-defined iteration order, which is insertion order.option<T>
fields are represented asworker::Option<T>
. First, check if the option contains a value with!option.empty()
orif (option) { ... }
, then dereference the option to access the contained value. This type is very similar to thestd::optional<T>
type introduced in C++17.
Example
Consider the following simple schema (in package example
):
type StatusEffect {
string name = 1;
int32 multiplier = 2;
}
component Creature {
id = 12345;
int32 health = 1;
list<StatusEffect> effects = 2;
}
The generated code will contain a data class StatusEffect
corresponding to type defined in the
schema. For the creature
component, a Creature
metaclass containing a Creature::Update
class
will be generated, alongside a CreatureData
data class.
The Creature
metaclass can be passed as a template parameter to any templated API function, for
example to send an update for the creature
component. Note that the Creature
metaclass is also
used with the worker::Entity
class (which the View
uses to represent entities) to inspect
component data “at rest”.
Here are some ways that these classes can be used with the API:
void GeneratedCodeExamples(worker::Connection& connection) {
worker::Dispatcher dispatcher(MyComponents());
dispatcher.OnAuthorityChange<example::Creature>([&](const worker::AuthorityChangeOp& op) {
if (op.Authority == worker::Authority::kAuthoritative) {
// We were granted authority over the creature component. Send an update.
example::Creature::Update update;
update.set_health(10);
connection.SendComponentUpdate<example::Creature>(op.EntityId, update);
} else {
// Authority was revoked.
}
});
dispatcher.OnComponentUpdate<example::Creature>(
[&](const worker::ComponentUpdateOp<example::Creature>& op) {
const example::Creature::Update& update = op.Update;
if (update.effects()) {
auto effects = update.effects().data();
for (const auto& effect : *effects) {
std::cout << op.EntityId << " has effect " << effect.name() << " with multiplier "
<< effect.multiplier() << std::endl;
}
}
});
}
Snapshots
The SDK provides two classes to manipulate snapshots stored in files, worker::SnapshotInputStream
to read snapshot files from disk, one entity at a time, and worker::SnapshotOutputStream
to write
snapshot files to disk, one entity at a time. These stream classes are the recommended methods of
manipulating snapshots as they do not require the entire snapshot to be stored in memory when reading
or writing a snapshot.
worker::SnapshotInputStream
has a constructor and two public methods:
/** Creates a SnapshotInputStream to read the snapshot file from the given path. */
explicit SnapshotInputStream(const ComponentRegistry& registry, const std::string& path);
/** Returns true if SnapshotInputStream has not reached EOF. */
bool HasNext();
/**
* Loads the next EntityId and Entity pair from the Snapshot. Returns an
* error message if error occurred.
*/
Option<std::string> ReadEntity(EntityId& entity_id, Entity& entity);
worker::SnapshotOutputStream
has a constructor and one public method:
/** Creates a SnapshotOutputStream to read the snapshot file from the given path. */
explicit SnapshotOutputStream(const ComponentRegistry& registry, const std::string& path);
/**
* Writes the EntityId and Entity pair to the output stream. Returns an
* error message if error occurred.
*/
Option<std::string> WriteEntity(EntityId entity_id, const Entity& entity);
Note that when a SnapshotOutputStream
is destructed, the end of file is written and the stream’s resources are released.
Note that, unlike the rest of the API described in this document, snapshot manipulation does not require a Connection
,
making it possible to develop standalone, offline snapshot manipulation tools. However, we recommend using the build
infrastructure provided by SpatialOS for workers to build your standalone tools.
Here is an example of loading a snapshot, performing some manipulation on it, and saving it back. It uses the types and components defined in the example above.
void AddLowHealthEffectToEntities(const std::string& snapshot_filename) {
// Load a snapshot from a file.
std::unordered_map<worker::EntityId, worker::Entity> entities;
auto load_error_opt = worker::LoadSnapshot(MyComponents(), snapshot_filename, entities);
if (load_error_opt) {
std::cerr << "Error loading snapshot: " << *load_error_opt << std::endl;
return;
}
// Add the "LowHealth" effect to all entities that have a Status component and less than 10 health
// points.
for (const auto& pair : entities) {
auto entity = pair.second;
auto creature = entity.Get<example::Creature>();
if (creature && creature->health() < 10) {
creature->effects().emplace_back("LowHealth", 100);
}
}
// Save the snapshot back to the file.
auto save_error_opt = worker::SaveSnapshot(MyComponents(), snapshot_filename, entities);
if (save_error_opt) {
std::cerr << "Error saving snapshot: " << *save_error_opt << std::endl;
return;
}
}