Get SpatialOS

Sites

Menu
You are viewing the docs for 11.0, an old version of SpatialOS. 12.0 is the newest →

The JavaScript worker SDK is currently experimental. We’re very open to feedback — don’t hesitate to get in touch on the forums.

Using the JavaScript worker SDK

Overview

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

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

Connecting to SpatialOS

To perform any work in the simulation, the worker must connect to SpatialOS. Currently, a JavaScript worker must connect using the sdk.Locator object, and can only connect to a deployment launched with the spatial local launch command.

The example below illustrates a very basic connection setup. Here we use the sdk.Locator to connect to a local instance of a SpatialOS simulation, using a special local development URL and login token. Usually, the locator would be used to query for and connect to remote cloud deployments, but it can also be used to connect to a local deployment. When this happens, the project name specified in the locator parameters and the deployment name in locator.connect(...) are ignored, so in this example they are set to arbitrary values.

const sdk = require("spatialos_worker_sdk");

let locatorParameters = new sdk.LocatorParameters();
locatorParameters.projectName = "my_project";
locatorParameters.credentialsType = sdk.LocatorCredentialsType.LOGIN_TOKEN;
locatorParameters.loginToken = {
  token: sdk.DefaultConfiguration.LOCAL_DEVELOPMENT_LOGIN_TOKEN
};

let workerType = "javascript";
const connectionParameters = new sdk.ConnectionParameters();
connectionParameters.workerType = workerType;

const locator = sdk.Locator.create(sdk.DefaultConfiguration.LOCAL_DEVELOPMENT_LOCATOR_URL, locatorParameters);
locator.getDeploymentList((err, deploymentList) => {
  locator.connect("my_deployment", connectionParameters, (err, queueStatus) => {
      return true;
    },
    (err, connection) => {
      if (err) {
        console.log("Error when connecting", err);
        return;
      }
      connection.sendLogMessage(sdk.LogLevel.WARN, workerType, "Hello from JavaScript!");

      let dispatcher = sdk.Dispatcher.create();
      dispatcher.onDisconnect(op => {
        console.log("---> Disconnected", op);
      });
      connection.attachDispatcher(dispatcher);
    });
});

document.addEventListener("DOMContentLoaded", function (event) {
  // Code which depends on the HTML DOM content.
  console.log("Hello World!");
});

Unlike the other worker SDKs, the JavaScript SDK is designed to run within a event driven environment, such as a browser. As a result, the event loop is handled directly by the browser. We attach a dispatcher to a connection by calling connection.attachDispatcher(dispatcher);, and when an op is triggered, the browser will trigger an event, which will use your dispatcher object to handle the op.

Generated code

The JavaScript 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 extending sdk.ComponentMetaclass. These metaclasses are used when referring to specific components when using the API. Each metaclass will have a static field called COMPONENT, that must be used for methods which require a metaclass parameter.
  • An Update class, having the same name as the metaclass, but with an _Update suffix. This has an optional field for each field in the component, as it represents a diff.
  • A command metaclass for each command, extending sdk.CommandMetaclass. These have the same name as the component metaclass, but have a _Commands_<CommandName> suffix. More information below under Sending and receiving component commands.

Data representation

  • Strings are represented as a JavaScript string.
  • list<T> fields are represented as Array<T>.
  • map<Key, Value> fields are represented as Map<Key, Value>.
  • option<T> fields are represented as sdk.Option<T>.

Example

Consider the following simple schema:

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 ComponentMetaclass with Data and Update classes. Moreover, there will be a StatusEffect type representing the StatusEffect schema-level type, and a CreatureData type for the CreatureData schema-level component.

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

const {Creature, Creature_Update} = require("./generated/example/Creature");

function examples(connection) {
  let dispatcher = new Dispatcher();
  dispatcher.onAuthorityChange(Creature.COMPONENT, op => {
    if (op.hasAuthority) {
      // We were granted authority over the creature component. Send an update.
      let update = new Creature_Update();
      update.setHealth(10);
      connection.sendComponentUpdate(Creature.COMPONENT, op.entityId, update);
    } else {
      // Authority was revoked.
    }
  });

  dispatcher.onComponentUpdate(Creature.COMPONENT, op => {
    let update = op.update;
    if (update.effects.isPresent()) {
      // The `effects` field was updated.
    }
  });
}

Handling data received from SpatialOS

Dispatcher callbacks

Several kinds of callbacks can be registered on the sdk.Dispatcher. Each method takes a function of type (T) => void, where the parameter type T depends on the particular kind of callback being registered. Additionally, onAddComponent, onRemoveComponent, onAuthorityChange, onComponentUpdate, onCommandRequest and onCommandResponse require an instance of the ComponentMetaclass or CommandMetaclass as the first argument (obtained via <ComponentType>.COMPONENT or <ComponentType>.Commands.<COMMAND_NAME> respectively). The following table has the details:

Method Parameter type (and fields) Invoked when…
onDisconnect sdk.DisconnectOp (reason: string) the Connection is no longer connected and can no longer be used. Check the log for errors relating to the disconnection.
onLogMessage sdk.LogMessageOp (message: string, level: sdk.LogLevel) the SDK issues a log message for the worker to print. This does not include messages sent using Connection.sendLogMessage.
onCriticalSection sdk.CriticalSectionOp (inCriticalSection: boolean) a critical section is about to be entered or has just been left.
onAddEntity sdk.AddEntityOp (entityId: sdk.EntityId) an entity is added to the worker’s view of the simulation.
onRemoveEntity sdk.RemoveEntityOp (entityId: sdk.EntityId) an entity is removed from the worker’s view of the simulation.
onReserveEntityIdResponse sdk.ReserveEntityIdResponseOp (requestId: sdk.RequestId<sdk.ReserveEntityIdRequest>, statusCode: sdk.StatusCode, message: string, entityId: sdk.EntityId) the worker has received a response for an entity ID reservation it had requested previously.
onCreateEntityResponse sdk.CreateEntityResponseOp (requestId: sdk.RequestId<sdk.CreateEntityRequest>, statusCode: sdk.StatusCode, message: string, entityId: sdk.EntityId) the worker has received a response for an entity creation it had requested previously.
onDeleteEntityResponse sdk.DeleteEntityResponseOp (requestId: sdk.RequestId<sdk.DeleteEntityRequest>, entityId: sdk.EntityId, statusCode: sdk.StatusCode, message: string) the worker has received a response for an entity deletion it had requested previously.
onAddComponent sdk.AddComponentOp<Data> (entityId: sdk.EntityId, data: Data) a component is added to an existing entity in the worker’s view of the simulation.
onRemoveComponent sdk.RemoveComponentOp (entityId: sdk.EntityId) a component is removed from an existing entity in the worker’s view of the simulation.
onAuthorityChange sdk.AuthorityChangeOp (entityId: sdk.EntityId, hasAuthority: boolean) the worker is granted authority over an entity’s component, or the worker’s authority over an entity’s component is revoked.
onComponentUpdate sdk.ComponentUpdateOp<Update> (entityId: sdk.EntityId, update: Update) a component for an entity in the worker’s view of the simulation has been updated.
onCommandRequest sdk.CommandRequestOp<Metaclass, Request> (requestId: sdk.RequestId<sdk.IncomingCommandRequest<Metaclass>>, entityId: sdk.EntityId, timeoutMillis: number, callerWorkerId: string, callerAttributeSet: string[], request: Request) the worker has received a command request for a component on an entity over which it has authority.
onCommandResponse sdk.CommandResponseOp<Metaclass, Response> (requestId: sdk.RequestId<sdk.OutgoingCommandRequest<Metaclass>>, entityId: sdk.EntityId, statusCode: sdk.StatusCode, response?: Response) the worker has received a command response for a request it issued previously.

Here’s an example of registering callbacks:

function registerCallbacks(dispatcher) {
    dispatcher.onAddEntity(op => {
        // Do something with op.entityId
    });
}

If you want to unregister a callback, call removeCallback() with the sdk.CallbackId returned from the registration method:

function registerAndUnregisterCallbacks(dispatcher) {
    let key = dispatcher.onAddEntity(op => {
        // Do something with op.entityId
    });

    // ...

    dispatcher.remove(key);
}

Dispatcher invocation order

sdk.AddEntityOp callbacks are invoked in the order they were registered with the sdk.Dispatcher, while sdk.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, sdk.AuthorityChangeOp callbacks are invoked in the order they were registered when authority is granted, but in reverse order when authority is revoked.

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 sdk.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 flags

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

getWorkerFlag(flagName: string): string | undefined;

For example:

let workSpeedFlag = connection.getWorkerFlag("mycompany_theproject_work_speed");
if (workSpeedFlag !== undefined) {
  setWorkSpeed(workSpeedFlag);
} else {
  setDefaultWorkSpeed();
}

Sending data to SpatialOS

A worker can send data such as logging, metrics, and component updates to SpatialOS using the sdk.Connection.

Logging and metrics

The sdk.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 Connection periodically reports several built-in internal metrics using sdk.MetricsOp, which you would usually want to send to SpatialOS along with your custom metrics. It might look something like this:

dispatcher.onMetrics(op => {
  // Send back the SDK built-in metrics.
  connection.sendMetrics(op.metrics);

  // Send back the user-defined metrics.
  let metrics = new sdk.Metrics();
  let gaugeMetric = new sdk.GaugeMetric();
  gaugeMetric.key = "MyCustomMetric";
  gaugeMetric.value = 1.0;
  metrics.gaugeMetrics = [gaugeMetric];
  connection.sendMetrics(metrics);
});

Metrics should also be 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 of 1 and above corresponding to an overloaded worker. The reported values influence SpatialOS’s load balancing strategy. An example of load reporting could be:

let task_queue = [];
double maximum_queue_size = 200;

dispatcher.onMetrics(op => {
  // Send back the SDK built-in metrics.
  connection.sendMetrics(op.metrics);

  // Update the load metric and send it to SpatialOS.
  let metrics = new sdk.Metrics();
  metrics.load = task_queue.length / maximum_queue_size;
  connection.sendMetrics(metrics);
}

// Process tasks from the task queue.

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 sdk.Connection method sendComponentUpdate, which takes an EntityId and an instance of appropriate update class defined in the schema-generated code. The update classes are always suffixed with _Update and are defined alongside the implementation each component, which extends ComponentMetaclass. For example, if we want to update a component MyComponent, the appropriate update class is MyComponent_Update.

To be notified when a worker receives a component update on an entity in the worker’s view of the simulation, use the sdk.Dispatcher method onComponentUpdate with the same ComponentMetaclass 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.

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.

Whenever the set of interested components for an entity changes (currently just when connecting), the worker will receive the appropriate onAddComponent and onRemoveComponent callbacks to reflect the change.

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 sdk.Connection method sendCommandRequest. This function takes an instance of CommandMetaclass, an entity ID, a request object and an optional timeout. The instance of CommandMetaclass is found under <ComponentName>.Commands.COMMAND_NAME, where COMMAND_NAME is in upper case and separated by underscores. The request object must match the parameter type of the command in the schema language.

Before sending the command, a callback to handle the response should be registered with the sdk.Dispatcher with onCommandResponse. The request ID (of type sdk.RequestId) returned by sendCommandRequest can be matched up with the one in the sdk.CommandResponseOp to identify the request that is being responded to.

Note that commands may fail, so the statusCode field in the sdk.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 sdk.Dispatcher with onCommandRequest(metaclass, callback); when the callback is executed, the worker should make sure to call the sdk.Connection method sendCommandResponse, supplying the request ID (of type sdk.RequestId) provided by the sdk.CommandRequestOp and an appropriate response object, matching the response type defined in the schema. Alternatively, the worker can call sendCommandFailure to fail the command instead.

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 an entity ID, create an entity, or delete an entity, by using the sdk.Connection methods sendReserveEntityIdRequest, sendCreateEntityRequest, and sendDeleteEntityRequest, respectively.

These methods return an sdk.RequestId request ID, which can be used to match a request with its response. The response is received via a callback registered with the sdk.Dispatcher using onReserveEntityIdResponse, onCreateEntityResponse, and onDeleteEntityResponse, respectively.

Note that these operations may fail, so the statusCode field in the sdk.ReserveEntityIdResponseOp, sdk.CreateEntityResponseOp, or sdk.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 could 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.

sendCreateEntityRequest takes a sdk.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), 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:

const {Coordinates}  = require("./generated/improbable/Coordinates");
const {Position, Position_Update}  = require("./generated/improbable/Position");

...

let timeoutMillis = 500;

let entityCreationRequest;
let entityDeletionRequest;

// Reserve an entity ID.
let entityIdReservationRequestId = connection.sendReserveEntityIdRequest(timeoutMillis);

// When the reservation succeeds, create an entity with the reserved ID.
view.onReserveEntityIdResponse(op => {
  if (op.requestId === entityIdReservationRequestId && op.statusCode === sdk.StatusCode.SUCCESS) {
    let entity = new sdk.Entity();

    // Empty ACL - should be customised.
    let entityAcl = new EntityAclData();
    entityAcl.readAcl = new WorkerRequirementSet();
    entityAcl.readAcl.attributeSet = [];
    entityAcl.writeAcl = new Map();
    entity.Add(EntityAcl.COMPONENT, entityAcl);

    // Needed for the entity to be persisted in snapshots.
    entity.Add(Persistence.COMPONENT, new PersistenceData());

    let position = new PositionData();
    position.coordinates = new Coordinates();
    position.coordinates.x = 1;
    position.coordinates.y = 2;
    position.coordinates.z = 3;
    entity.add(Position.COMPONENT, position);

    requestIds.entityCreationRequest = connection.sendCreateEntityRequest(entity, op.entityId, timeoutMillis);
  }
});

// When the creation succeeds, delete the entity.
view.onCreateEntityResponse(op => {
  if (op.requestId === requestIds.entityCreationRequest && op.statusCode === sdk.StatusCode.SUCCESS) {
    requestIds.entityDeletionRequest = connection.sendDeleteEntityRequest(op.entityId, timeoutMillis);
  }
});

// When the deletion succeeds, we're done.
view.onDeleteEntityResponse(op => {
  if (op.requestId === requestIds.entityDeletionRequest && op.statusCode === sdk.StatusCode.SUCCESS) {
    // 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 and EntityAcl components.
  • The worker, which issued the Ops.CommandRequest, 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.

const {EntityAclData} = require("./generated/improbable/EntityAclData");
const {EntityAcl} = require("./generated/improbable/EntityAcl");
const {WorkerAttributeSet} = require("./generated/improbable/WorkerAttributeSet");
const {WorkerRequirementSet} = require("./generated/improbable/WorkerRequirementSet");

function createWorkerAttributeSet(attributes) {
  let workerAttributeSet = new WorkerAttributeSet();
  workerAttributeSet.attribute = attributes;
  return workerAttributeSet;
}

// op - sdk.CommandRequestOp
function addComponentDelegations(op, entity) {
  // This requirement set matches only the command caller, i.e. the worker that issued the command,
  // since the attribute set includes the caller's unique attribute.
  let callerWorkerAttributeSet = createWorkerAttributeSet(op.callerAttributeSet);
  let callerWorkerRequirementSet = new WorkerRequirementSet();
  callerWorkerRequirementSet.attributeSet = [callerWorkerAttributeSet];

  // Worker attribute set of a physics worker.
  let physicsWorkerAttributeSet = createWorkerAttributeSet(["physics"]);

  // Worker attribute set of a client worker.
  let clientWorkerAttributeSet = createWorkerAttributeSet(["client"]);

  // This requirement set matches any worker with the attribute "physics".
  let physicsWorkerRequirementSet = new WorkerRequirementSet();
  physicsWorkerRequirementSet.attributeSet = [physicsWorkerAttributeSet];

  // This requirement set matches any worker with the attribute "client" or "physics".
  let clientOrPhysicsRequirementSet = new WorkerRequirementSet();
  clientOrPhysicsRequirementSet.attributeSet = [clientWorkerAttributeSet, physicsWorkerAttributeSet];

  // Give authority over Position and EntityAcl to any physics worker, and over PlayerControls to the caller worker.
  let componentAcl = new Map();
  componentAcl.set(Position.COMPONENT_ID, physicsWorkerRequirementSet);
  componentAcl.set(EntityAcl.COMPONENT_ID, physicsWorkerRequirementSet);
  componentAcl.set(PlayerControls.COMPONENT_ID, callerWorkerRequirementSet);

  let entityAcl = new EntityAclData();
  entityAcl.readAcl = clientOrPhysicsRequirementSet;
  entityAcl.componentWriteAcl = componentAcl;
  entity.add(EntityAcl.COMPONENT, entityAcl);
}

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.COMPONENT_ID to callerWorkerRequirementSet (created above). This change can be made using the method below:

function delegateComponent(currentAcl, componentId, requirementSet) {
  // Take a deep copy, so that this does not modify the current EntityAcl.
  let newAcl = currentAcl.deepCopy();
  // Set the write ACL for the specified component to the specified attribute set,
  // assuming the componentAcl option is not empty.
  newAcl.componentWriteAcl.set(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 requirement sets for more information.

Was this page helpful?

Thanks for letting us know!

Thanks for your feedback

Need more help? Ask on the forums