Get SpatialOS

Sites

Menu

Using the C# worker SDK

The C# worker API closely mirrors the C++ worker API. For the full API reference, check C# API reference documentation.

Overview

The most important units of functionality in the C# worker API are:

  • The Improbable.Worker.Locator, which is responsible for querying for and connecting to remote cloud deployments.
  • The Improbable.Worker.Connection, which represents a worker’s connection to a SpatialOS simulated world.
  • The Improbable.Worker.Dispatcher, which is responsible for invoking user-provided callbacks based on data received from SpatialOS via the Improbable.Worker.Connection.
  • The Improbable.Worker.Entity, which is a container for the component data associated with a single entity.

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 Improbable.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 the IMPROBABLE_RECEPTIONIST_HOST and IMPROBABLE_RECEPTIONIST_PORT placeholders in your worker’s launch configuration.
  • Use the Improbable.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 Improbable.Worker.Connection directly Using Improbable.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

The example below illustrates a very basic connection setup, where the function takes three arguments arguments specifying the worker’s own ID as well as the receptionist’s IP and port. It will use TCP and connect using the internal IP address.

All code examples on this page assume using Improbable.Worker; and using Example; (the generated code namespace).

private static Connection GetConnection(string workerId, string hostname, ushort port)
{
  var parameters = new ConnectionParameters
  {
    WorkerType = "MyCsharpWorker",
    Network =
    {
      ConnectionType = NetworkConnectionType.Tcp,
      UseExternalIp = false
    }
  };
  return Connection.ConnectAsync(hostname, port, workerId, parameters).Get();
}

Note that Improbable.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 Improbable.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 Improbable.Worker.OpList then needs to be passed to a dispatcher in order to invoke user-provided callbacks.

In order to detect that a worker is still responsive, SpatialOS sends periodic heartbeat requests. Responding to these happens automatically, but the buffer for incoming operations can fill up with other messages. If that happens, the heartbeats cannot be processed and your worker may be killed. Therefore, you should never wait multiple seconds between calls to GetOpList.

The following snippet shows a simple example implementation of an event loop that processes operations from SpatialOS 60 times per second:

const int FramesPerSecond = 60;

private static void RunEventLoop(Connection connection, Dispatcher dispatcher)
{
  var maxWait = System.TimeSpan.FromMilliseconds(1000f / FramesPerSecond);
  var stopwatch = new System.Diagnostics.Stopwatch();
  while (true)
  {
    stopwatch.Reset();
    stopwatch.Start();
    var opList = connection.GetOpList(0 /* non-blocking */);
    // Invoke callbacks.
    dispatcher.Process(opList);
    // Do other work here...
    stopwatch.Stop();
    var waitFor = maxWait.Subtract(stopwatch.Elapsed);
    System.Threading.Thread.Sleep(waitFor.Milliseconds > 0 ? waitFor : System.TimeSpan.Zero);
  }
}

If positive timeout (in milliseconds) is provided to GetOpList, the function will block until there is at least one operation to return, or until the timeout has been exceeded.

Note that all callbacks provided to the Improbable.Worker.Dispatcher will be invoked only when Improbable.Worker.Dispatcher.Process is called, and only on the thread that is currently calling Improbable.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 Improbable.Worker.Dispatcher. Each method takes an arbitrary callable System.Action<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 Improbable.Worker.DisconnectOp (string Reason) the Connection is no longer connected and can no longer be used. Check the log for errors relating to the disconnection.
OnFlagUpdate Improbable.Worker.FlagUpdateOp (string Name, Improbable.Option<string> Value) a worker flag has been created, deleted or when its value has changed.
OnLogMessage Improbable.Worker.LogMessageOp (Improbable.Worker.LogLevel Level, string Message) the SDK issues a log message for the worker to print. This does not include messages sent using Connection.SendLogMessage.
OnMetrics Improbable.Worker.MetricsOp (Improbable.Worker.Metrics Metrics) the SDK reports built-in internal metrics.
OnCriticalSection Improbable.Worker.CriticalSectionOp (bool InCriticalSection) a critical section is about to be entered or has just been left.
OnAddEntity Improbable.Worker.AddEntityOp (Improbable.EntityId EntityId) an entity is added to the worker’s view of the simulation.
OnRemoveEntity Improbable.Worker.RemoveEntityOp (Improbable.EntityId EntityId) an entity is removed from the worker’s view of the simulation.
OnReserveEntityIdResponse Improbable.Worker.ReserveEntityIdResponseOp (Improbable.Worker.RequestId<ReserveEntityIdRequest> RequestId, Improbable.Worker.StatusCode StatusCode, string Message, Improbable.Option<Improbable.EntityId> EntityId) the worker has received a response for an entity ID reservation it had requested previously.
OnReserveEntityIdsResponse Improbable.Worker.ReserveEntityIdsResponseOp (Improbable.Worker.RequestId<ReserveEntityIdsRequest> RequestId, Improbable.Worker.StatusCode StatusCode, string Message, Improbable.Option<Improbable.EntityId> FirstEntityId, int NumberOfEntityIds) the worker has received a response for a reservation of an entity ID range it had requested previously.
OnCreateEntityResponse Improbable.Worker.CreateEntityResponseOp (Improbable.Worker.RequestId<CreateEntityRequest> RequestId, Improbable.Worker.StatusCode StatusCode, string Message, Improbable.Option<Improbable.EntityId> EntityId) the worker has received a response for an entity creation it had requested previously.
OnDeleteEntityResponse Improbable.Worker.DeleteEntityResponseOp (Improbable.Worker.RequestId<DeleteEntityRequest> RequestId, Improbable.EntityId EntityId, Improbable.Worker.StatusCode StatusCode, string Message) the worker has received a response for an entity deletion it had requested previously.
OnEntityQueryResponse Improbable.Worker.EntityQueryResponseOp (Improbable.Worker.RequestId<EntityQueryRequest> RequestId, Improbable.Worker.StatusCode StatusCode, string Message, int ResultCount, Improbable.Map<Improbable.EntityId, Improbable.Worker.Entity> Result) the worker has received a response for an entity query it had requested previously.
OnAddComponent<C> Improbable.Worker.AddComponentOp<C> (Improbable.EntityId EntityId, Improbable.Worker.IComponentData<C> Data) a component is added to an existing entity in the worker’s view of the simulation.
OnRemoveComponent<C> Improbable.Worker.RemoveComponentOp (Improbable.EntityId EntityId) a component is removed from an existing entity in the worker’s view of the simulation.
OnAuthorityChange<C> Improbable.Worker.AuthorityChangeOp (Improbable.EntityId EntityId, Improbable.Worker.Authority Authority) the worker’s authority state over a component is changed to Authoritative, Not Authoritative or Authority Loss Imminent.
OnComponentUpdate<C> Improbable.Worker.ComponentUpdateOp<C> (Improbable.EntityId EntityId, Improbable.Worker.IComponentUpdate<C> Update) a component for an entity in the worker’s view of the simulation has been updated.
OnCommandRequest<C> Improbable.Worker.CommandRequestOp<C> (Improbable.Worker.RequestId<IncomingCommandRequest> RequestId, Improbable.EntityId EntityId, uint TimeoutMillis, string CallerWorkerId, Improbable.List<string> CallerAttributeSet, Improbable.Worker.ICommandRequest<C> Request) the worker has received a command request for a component on an entity over which it has authority.
OnCommandResponse<C> Improbable.Worker.CommandResponseOp<C> (Improbable.Worker.RequestId<OutgoingCommandRequest> RequestId, Improbable.Worker.EntityId EntityId, Improbable.Worker.StatusCode StatusCode, Improbable.Option<Improbable.Worker.ICommandResponse<C>> Response) the worker has received a command response for a request it issued previously.

Here’s an example of registering callbacks:

private static System.Collections.Generic.List<ulong> RegisterCallbacks(Connection connection, Dispatcher dispatcher)
{
  var callbackKeys = new System.Collections.Generic.List<ulong>
  {
    dispatcher.OnAddEntity(op =>
    {
      // Do something with op.EntityId.
    }),
    dispatcher.OnMetrics(op =>
    {
      // Collect user-defined metrics.
      var metrics = CollectMetrics();
      // Merge in the SDK built-in metrics.
      metrics.Merge(op.Metrics);
      // Send the metrics to SpatialOS.
      connection.SendMetrics(metrics);
    }),
    dispatcher.OnComponentUpdate<Switch>(op =>
    {
      var update = op.Update.Get();
      foreach (var toggleEvent in update.toggled)
      {
        System.Console.WriteLine("Switch has been toggled at {0}.", toggleEvent.time);
      }
    })
  };

  return callbackKeys;
}

If you want to unregister a callback, call Remove() with the ulong returned from the registration method:

private static void UnregisterCallbacks(Dispatcher dispatcher, System.Collections.Generic.List<ulong> callbackKeys)
{
  foreach (var key in callbackKeys)
  {
    dispatcher.Remove(key);
  }
}

Using the View

Improbable.Worker.View is subclass of Improbable.Worker.Dispatcher, 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 Improbable.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.

Dispatcher invocation order

AddEntityOp callbacks are invoked in the order they were registered with the Improbable.Worker.Dispatcher, while RemoveEntityOp callbacks are invoked in reverse order. This means that pairs of add/remove callbacks can correctly depend on resources managed by pairs of callbacks registered earlier. This is similar to the usual construction/destruction order found in C++, for example. Similarly, AuthorityChangeOp callbacks are invoked in the order they were registered when authority is granted, but in reverse order when authority is revoked. 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.

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. Note that this notion of a critical section does not provide any guarantees.

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:

string GetWorkerId();
Improbable.Collections.List<string> GetWorkerAttributes();

Worker flags

To access the value of a worker flag, use the GetWorkerFlag method of a Connection:

const double DefaultWorkSpeed = 10;

private static void GetSpeedWorkerFlag(Connection connection)
{
  var speedFlag = connection.GetWorkerFlag("mycompany_theproject_speed");
  if (speedFlag.HasValue)
  {
    SetWorkSpeed(System.Convert.ToDouble(speedFlag.Value));
  }
  else
  {
    SetWorkSpeed(DefaultWorkSpeed);
  }
}

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 Improbable.Worker.Connection.

Logging and metrics

The Improbable.Worker.Connection has two methods to send diagnostic information to SpatialOS: SendLogMessage, which is used for distributed logging, and SendMetrics, which updates timeseries and histogram data associated with this worker. Logs and metrics are available on deployment dashboards.

The following basic example illustrates how metrics can be used. In reality, rather than generating the metrics out of thin air, there will be data to observe.

private static Metrics CollectMetrics()
{
  var metrics = new Metrics { Load = AssessLoad() };
  metrics.GaugeMetrics.Add("MyCustomMetric", 1.0);
  var histogram = new HistogramMetric();
  histogram.RecordObservation(123);
  metrics.HistogramMetrics.Add("MyCustomHistogram", histogram);
  return metrics;
}

Note that metrics are also used to set the worker load when using dynamic load balancing for a worker. 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.

A conceptual example of how load reporting could work:

// A queue of tasks the worker has to complete.
private static readonly System.Collections.Queue TaskQueue = new System.Collections.Queue();

private const float MaximumQueueSize = 200f; // An arbitrary maximum value.

private static float AssessLoad()
{
  return System.Convert.ToSingle(TaskQueue.Count) / MaximumQueueSize;
}

The Connection periodically reports several built-in internal metrics with MetricsOp, which you would usually want to send to SpatialOS along with your custom metrics. It might look something like this, as seen above:

dispatcher.OnMetrics(op =>
{
  // Collect user-defined metrics.
  var metrics = CollectMetrics();
  // Merge in the SDK built-in metrics.
  metrics.Merge(op.Metrics);
  // Send the metrics to SpatialOS.
  connection.SendMetrics(metrics);
}),

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 Improbable.Worker.Connection method SendComponentUpdate<C>, which takes an IComponentUpdate<C> object. The generic type parameter should be a subclass of IComponentMetaclass defined in the schema-generated code.

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 Improbable.Worker.Connection.GetOpList. This means that OnComponentUpdate callbacks will also be invoked for component updates triggered by the worker itself, but not immediately.

There are a couple of (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; it violates the guarantee that callbacks are only invoked as a result of a call to the Process method on the Improbable.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 Improbable.Worker.Dispatcher method OnComponentUpdate with the same IComponentMetaclass generic parameter C 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:

private static void TriggerEvent(Connection connection, Improbable.EntityId entityId)
{
  var update = new Example.Switch.Update();
  update.AddToggled(new Example.SwitchToggled(1));
  connection.SendComponentUpdate(entityId, 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<Switch>(op =>
{
  var update = op.Update.Get();
  foreach (var toggleEvent in update.toggled)
  {
    System.Console.WriteLine("Switch has been toggled at {0}.", toggleEvent.time);
  }
})

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:

private static void SendComponentInterest(Connection connection, Improbable.EntityId entityId)
{
  var interestOverrides = new System.Collections.Generic.Dictionary<uint, InterestOverride>
  {
    { Switch.ComponentId, new InterestOverride { IsInterested = true } }
  };
  connection.SendComponentInterest(entityId, interestOverrides);
}

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 specified 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 Improbable.Worker.Connection method SendCommandRequest<C>. In this case the generic type parameter should be a subclass of ICommandMetaclass defined in the schema-generated code. This function takes an entity ID, an optional timeout, an ICommandRequest<C> 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 Improbable.Worker.Dispatcher with OnCommandResponse<C>. The request ID (of type Improbable.Worker.RequestId<OutgoingCommandRequest>) returned by SendCommandRequest can be matched up with the one in the Improbable.Worker.CommandResponseOp to identify the request that is being responded to.

Note that commands may fail, so the Improbable.Worker.StatusCode field in the Improbable.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)

For more detail on these status codes, see the documentation for the StatusCode enum.

To handle commands issued by another worker, the opposite flow is used. Register a callback with the Improbable.Worker.Dispatcher with OnCommandRequest<C>; when the callback is executed, the worker should make sure to call the Improbable.Worker.Connection method SendCommandResponse<C>, supplying the request ID (of type Improbable.Worker.RequestId<IncomingCommandRequest>) provided by the Improbable.Worker.CommandRequestOp and an appropriate Improbable.Worker.ICommandResponse<C> response object. Alternatively, the worker can call SendCommandFailure<C> to fail the command instead.

Entity queries

Note: In order to send an entity query, a worker must have permission to do so. For more information, see the Worker permissions page.

A worker can run remote entity queries against the simulation by using the Improbable.Worker.Connection method SendEntityQueryRequest. This takes an Improbable.Worker.Query.EntityQuery object and an optional timeout.

The query object is made of an Improbable.Worker.Query.Constraint and an Improbable.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 new SnapshotResultType(). To select every component whose ID is contained in the given set, use new SnapshotResultType(componentIdSet) (thus, pass an empty set to get no components but entity IDs only).

Important: You should keep entity queries as limited as possible. All queries hit the network and cause a runtime lookup, which is expensive even in the best cases. This means you should:

  • always limit queries to a specific sphere of the world
  • only return the information you need from queries (eg the specific components you care about)
  • if you’re looking for entities that are within your worker’s checkout radius, search internally on the worker instead of using a query

Like other request methods, this returns an Improbable.Worker.RequestId request ID, which can be used to match a request with its response. The response is received via a callback registered with the Improbable.Worker.Dispatcher using the OnEntityQueryResponse method.

The EntityQueryResponseOp contains an int 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

Note: In order to create or delete an entity, a worker must have permission to do so. For more information, see the Worker permissions page.

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 Improbable.Worker.Connection methods SendReserveEntityIdRequest, SendReserveEntityIdsRequest, SendCreateEntityRequest, and SendDeleteEntityRequest, respectively.

These methods return an Improbable.Worker.RequestId<C> request ID, which can be used to match a request with its response. The type parameter C depends on the type of request being sent. The response is received via a callback registered with the Improbable.Worker.Dispatcher using OnReserveEntityIdResponse, OnReserveEntityIdsResponse, OnCreateEntityResponse, and OnDeleteEntityResponse, respectively.

Note that these operations may fail, so the StatusCode field in the worker.ReserveEntityIdOp, worker.ReserveEntityIdsOp, worker.CreateEntityResponseOp, or worker.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 due to 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:

private static void CreateDeleteEntity(Dispatcher dispatcher, Connection connection)
{
  const uint timeoutMillis = 500u;
  const string entityType = "Player";

  // Reserve an entity ID.
  var entityIdReservationRequestId = connection.SendReserveEntityIdRequest(timeoutMillis);

  // When the reservation succeeds, create an entity with the reserved ID.
  var entityCreationRequestId = default(RequestId<CreateEntityRequest>);
  dispatcher.OnReserveEntityIdResponse(op =>
  {
    if (op.RequestId == entityIdReservationRequestId && op.StatusCode == StatusCode.Success)
    {
      var entity = new Entity();
      // Empty ACL - should be customised.
      entity.Add(new Improbable.EntityAcl.Data(
        new Improbable.WorkerRequirementSet(new Improbable.Collections.List<Improbable.WorkerAttributeSet>()),
        new Improbable.Collections.Map<uint, Improbable.WorkerRequirementSet>()));
      // Needed for the entity to be persisted in snapshots.
      entity.Add(new Improbable.Persistence.Data());
      entity.Add(new Improbable.Metadata.Data(entityType));
      entity.Add(new Improbable.Position.Data(new Improbable.Coordinates(1, 2, 3)));
      entityCreationRequestId = connection.SendCreateEntityRequest(entity, op.EntityId, timeoutMillis);
    }
  });

  // When the creation succeeds, delete the entity.
  var entityDeletionRequestId = default(RequestId<DeleteEntityRequest>);
  dispatcher.OnCreateEntityResponse(op =>
  {
    if (op.RequestId == entityCreationRequestId && op.StatusCode == StatusCode.Success)
    {
      entityDeletionRequestId = connection.SendDeleteEntityRequest(op.EntityId.Value, timeoutMillis);
    }
  });

  // When the deletion succeeds, we're done.
  dispatcher.OnDeleteEntityResponse(op =>
  {
    if (op.RequestId == entityDeletionRequestId && op.StatusCode == StatusCode.Success)
    {
      System.Console.WriteLine("Successfully created and deleted entity.");
    }
  });
}

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 and EntityAcl components.
  • The worker, which issued the CommandRequestOp, is the only worker that can be authoritative over the PlayerControls component.

This can be used as part of creating a new entity in response to a command request.

public static void AddComponentDelegations<C>(CommandRequestOp<C> op, Entity entity) where C : ICommandMetaclass
{
  // This requirement set matches only the command caller, i.e. the worker that issued the command,
  // since callerWorkerAttributes includes the caller's unique attribute.
  var callerWorkerAttributes = new Improbable.Collections.List<Improbable.WorkerAttributeSet>
  {
    new Improbable.WorkerAttributeSet(op.CallerAttributeSet)
  };
  var callerWorkerRequirementSet = new Improbable.WorkerRequirementSet(callerWorkerAttributes);

  // This requirement set matches any worker with the attribute "physics".
  var physicsWorkerRequirementSet = new Improbable.WorkerRequirementSet(
    new Improbable.Collections.List<Improbable.WorkerAttributeSet>
    {
      new Improbable.WorkerAttributeSet(new Improbable.Collections.List<string> {"physics"})
    });

  // This requirement set matches any worker with the attribute "client" or "physics".
  var clientOrPhysicsWorkerRequirementSet = new Improbable.WorkerRequirementSet(
    new Improbable.Collections.List<Improbable.WorkerAttributeSet>
    {
      new Improbable.WorkerAttributeSet(new Improbable.Collections.List<string> {"client"}),
      new Improbable.WorkerAttributeSet(new Improbable.Collections.List<string> {"physics"})
    });

  // Give authority over Position and EntityAcl to any physics worker, and over PlayerControls to the caller worker.
  var writeAcl = new Improbable.Collections.Map<uint, Improbable.WorkerRequirementSet>
  {
    {Improbable.Position.ComponentId, physicsWorkerRequirementSet},
    {Improbable.EntityAcl.ComponentId, physicsWorkerRequirementSet},
    {PlayerControls.ComponentId, callerWorkerRequirementSet}
  };

  entity.Add(new Improbable.EntityAcl.Data( /* read */ clientOrPhysicsWorkerRequirementSet, /* write */ writeAcl));
}

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, EntityAcl.Data needs to be modified in order to map Position.ComponentId to callerWorkerRequirementSet (created above). This change can be made using the method below:

public static Improbable.EntityAcl.Data DelegateComponent(
  Improbable.EntityAcl.Data currentAcl,
  uint componentId,
  Improbable.WorkerRequirementSet requirementSet)
{
  // Take a deep copy, so that this does not modify the current EntityAcl.
  var newAcl = currentAcl.DeepCopy();
  // Set the write ACL for the specified component to the specified attribute set,
  // assuming the componentAcl option is not empty.
  newAcl.Value.componentWriteAcl[componentId] = requirementSet;
  return newAcl;
}

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 implementing Improbable.Worker.IComponentMetaclass. These metaclasses are used when referring to specific components when using the API - every generic type parameter C 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 string members. Make sure to use this same encoding when updating a component.
  • list<T> fields are represented as a Improbable.Collections.List<T> of the repeated type. This type is very similar to System.Generic.List<T>.
  • map<Key, Value> fields are represented as Improbable.Collections.Map<Key, Value>. This type is very similar to System.Generic.Dictionary<Key, Value>.
  • option<T> fields are represented as Improbable.Collections.Option<T>.

All of the Improbable.Collections equivalents provide structural equality checks.

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 the Creature IComponentMetaclass with Data and and Update classes. Moreover, there will be a StatusEffect struct representing the StatusEffect type, and CreatureData type for the (auto-generated) underlying type of the component. The Create() method of the data types can be used to obtain a default instance whose fields are initialised with zeroes, empty strings, empty collections or other default instances as appropriate. Enum fields will be initialised with the first value listed in the schema. Note that we strongly recommend using either the static Create() method or the full constructor that takes an initial value for every field; the default constructor of the generated data types will leave some fields initialized with null, which is not valid.

Here are some ways that these classes can be used with the API:

public static void GeneratedCodeExamples(Connection connection)
{
  var dispatcher = new Dispatcher();
  dispatcher.OnAuthorityChange<Creature>(op => {
    switch (op.Authority)
    {
      case Authority.Authoritative:
        // We were granted authority over the status component. Send an update.
        var update = new Creature.Update();
        update.SetHealth(10);
        connection.SendComponentUpdate(op.EntityId, update);
        break;
      case Authority.AuthorityLossImminent:
        // About to lose authority.
        break;
      case Authority.NotAuthoritative:
        // Authority was revoked.
        break;
    }
  });
  dispatcher.OnComponentUpdate<Creature>(op =>
  {
    // Again, use the extension method Get() to get the concrete type of update.
    var update = op.Update.Get();
    if (update.effects.HasValue)
    {
      // The `effects` field was updated.
    }
  });
}

In order to compile this example, you would have to wrap the Examples method in a class.

Snapshots

The SDK provides two classes in to manipulate snapshot stored in files; Improbable.Worker.SnapshotInputStream to read snapshot files from disk, one entity at a time, and Improbable.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.

Improbable.Worker.SnapshotInputStream has a constructor and three public methods:

// Constructs a SnapshotInputStream to read the file at the given string path.
public SnapshotInputStream(string path)

// Releases the resources of the SnapshotInputStream.
public void Dispose()

// Reads the next EntityId entityId, Entity entity pair from the snapshot.
// Returns an optional string error message if an error occurs during reading.
public Option<string> ReadEntity(out EntityId entityId, out Entity entity)

// Returns true if the SnapshotInputStream has not reached the end of the snapshot.
public bool HasNext();

Improbable.Worker.SnapshotOutputStream has a constructor and two public methods:

// Construct a SnapshotOutputStream to write a snapshot to a file at the string path.
public SnapshotOutputStream(string path)

// Writes the end of snapshot header and releases the resources of the SnapshotOutputStream.
public void Dispose()

// Writes the (EntityId entityId, Entity entity) pair to the snapshot.
// Returns an Optional string error message if an error occurs during writing.
public Option<string> WriteEntity(EntityId entityId, Entity entity)

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.

public static void AddLowHealthEffectToEntities(string snapshotFilename, string newSnapshotFilename)
{
  // Open a SnapshotInputStream to read from the snapshot file saved at snapshotFilename.
  SnapshotInputStream inputStream = new SnapshotInputStream(snapshotFilename);
  // Open a SnapshotOutputStream to write to a snapshot file at newSnapshotFilename.
  SnapshotOutputStream outputStream = new SnapshotOutputStream(newSnapshotFilename);

  Improbable.EntityId entityId;
  Entity entity;
 
  // Iterate over every entity in the snapshot file.
  while (inputStream.HasNext()) 
  {
    // Read the next (entityId, entity) pair from the snapshot file.
    var errorOpt = inputStream.ReadEntity(out entityId, out entity);
    if (errorOpt.HasValue)
    {
      throw new System.SystemException("Error loading snapshot: " + errorOpt.Value);
    }
    if (entity.Get<Creature>().HasValue)
    {
      var status = entity.Get<Creature>().Value.Get();
      // Add the "LowHealth" effect to all entities that have a Creature component 
      // and less than 10 health points.
      if (status.Value.health < 10)
      {
        status.Value.effects.Add(new StatusEffect("LowHealth", 100));
      }
    }
    // Write the (entityId, entity) pair to the snapshot file.
    errorOpt = outputStream.WriteEntity(entityId, entity);
    if (errorOpt.HasValue)
    {
      throw new System.SystemException("Error saving snapshot: " + errorOpt.Value);
    }
  }
  // Dispose of the inputStream to release the stream's resources.
  inputStream.Dispose();
  // Dispose of the outputStream to write the end of the Snapshot and release the stream's 
  // resources.
  outputStream.Dispose();
}

Note that the assemblies that contain the generated code for the components present in the snapshots your program manipulates must be loaded (not just referenced); otherwise, the SDK will report “undefined component ID” errors. These assemblies are loaded automatically whenever your program uses any class defined in them, but if this is not the case, you can manually load them using the Assembly.Load() method.

Using Dynamic

The Improbable.Worker.Dynamic class provides a way to execute arbitrary component-related logic in a type-safe way even when the component is not known statically. Dynamic.ForComponent will invoke a custom handler for a component with a specific ID; Dynamic.ForEachComponent will invoke a custom handler for every known component. These handlers have enough information to register Dispatcher callbacks, manipulate component updates and data, and so on.

Dynamic allows you to implement, for example, a custom View with any desired semantics from scratch:

using Improbable;
using Improbable.Collections;
using Improbable.Worker;

public sealed class CustomView : Dispatcher
{
    // Maintain a Map of the entities in this worker's view.
    private readonly Map<EntityId, Entity> entities = new Map<EntityId, Entity>();

    private readonly Map<EntityId, Map<uint, Authority>> authorityMap =
        new Map<EntityId, Map<uint, Authority>>();

    // Current component data for all entities in the worker's view.
    public Map<EntityId, Entity> Entities
    {
        get { return entities; }
    }

    // Current authority delegations.
    public Map<EntityId, Map<uint, Authority>> AuthorityMap
    {
        get { return authorityMap; }
    }


    // Helper function that checks if the worker has authority over a particular component of a
    // particular entity.
    public Authority GetAuthority<C>(EntityId entityId) where C : IComponentMetaclass, new()
    {
        Map<uint, Authority> entityAuthority;
        if (!authorityMap.TryGetValue(entityId, out entityAuthority))
        {
            return Authority.NotAuthoritative;
        }

        Authority componentAuthority;
        return entityAuthority.TryGetValue(Dynamic.GetComponentId<C>(), out componentAuthority) ? componentAuthority : Authority.NotAuthoritative;
    }

    // An implementation of Dynamic.Handler to provide type-safe behaviour for 
    // the components.
    private class TrackComponent : Dynamic.Handler
    {
        private readonly CustomView view;

        public TrackComponent(CustomView view)
        {
            this.view = view;
        }

        // Define the type-safe behaviour in the Accept method. 
        // Here, we are specifying callbacks to update our view's entities map
        // when components are added or removed, when the worker's authority
        // over an entity changes or when we receive component updates.
        public void Accept<C>(C metaclass) where C : IComponentMetaclass
        {
            view.OnAddComponent<C>(op =>
            {
                if (view.Entities.ContainsKey(op.EntityId))
                {
                    view.Entities[op.EntityId].Add<C>(op.Data);
                }
                if (view.AuthorityMap.ContainsKey(op.EntityId))
                {
                    view.AuthorityMap[op.EntityId][metaclass.ComponentId] = Authority.NotAuthoritative;
                }
            });
            view.OnRemoveComponent<C>(op =>
            {
                if (view.Entities.ContainsKey(op.EntityId))
                {
                    view.Entities[op.EntityId].Remove<C>();
                }
                if (view.AuthorityMap.ContainsKey(op.EntityId))
                {
                    view.AuthorityMap[op.EntityId].Remove(metaclass.ComponentId);
                }
            });
            view.OnAuthorityChange<C>(op =>
            {
                if (view.AuthorityMap.ContainsKey(op.EntityId))
                {
                    view.AuthorityMap[op.EntityId][metaclass.ComponentId] = op.Authority;
                }
            });
            view.OnComponentUpdate<C>(op =>
            {
                if (view.Entities.ContainsKey(op.EntityId))
                {
                    view.Entities[op.EntityId].Update<C>(op.Update);
                }
            });
        }
    }

    public CustomView()
    {
        // When entities are added or removed, the Entities map is updated.
        OnAddEntity(op =>
        {
            Entities.Add(op.EntityId, new Entity());
            AuthorityMap.Add(op.EntityId, new Map<uint, Authority>());
        });
        OnRemoveEntity(op =>
        {
            Entities.Remove(op.EntityId);
            AuthorityMap.Remove(op.EntityId);
        });
        // Invoke the TrackComponent handler for every component.
        Dynamic.ForEachComponent(new TrackComponent(this));
    }
}

Was this page helpful?

Thanks for letting us know!

Thanks for your feedback

Need more help? Ask on the forums