This recipe shows how to define and implement a command (SpatialOS documentation) with the SpatialOS SDK for Unity.
We'll use the example of a game where players can take damage from the spells of wizard characters. The taking of damage is implemented by wizards invoking a command that applies damage to the player's entity.
Commands are defined as part of a component, in schema.
The first component to define is Combat
, in the file Combat.schema
, which wizard entities have:
package improbable.wizard;
component Combat {
id = 1050;
uint32 attack_power = 1;
}
The second component to define is Health
, in the file Health.schema
, which the player entities have:
package improbable.player;
component Health {
id = 1051;
uint32 amount = 1;
command DamageResponse take_damage(DamageRequest);
}
This Health
component defines a take_damage
command which takes an argument of type
DamageRequest
as input and returns an object of type DamageResponse
. Like declaring
a function in a public interface, this means that any entity can invoke the take_damage
command on
an entity containing the Health
component.
The input and response types aren't defined yet, so you need to define those in schema too. You can do this
at the bottom of the file Health.schema
:
type DamageRequest {
uint32 amount = 1;
}
type DamageResponse {
uint32 dealt_damage = 1;
}
Now run spatial worker codegen
(SpatialOS documentation).
You should always run this after you've changed your schema, to generate the code that workers use to interact with components.
The entry in the schema file is effectively just a function signature. What's next is to actually write
what the behaviour of the take_damage
command will be.
Which worker should implement the command? This depends on the entity's access control list (ACL) (SpatialOS documentation). Whichever worker has write access to the component should implement the command.
In this example, let's say that'll be the Unity worker. So, to write what the command will do:
Create a new MonoBehaviour
, TakeDamage.cs
, and attach it to the prefab used for the player.
This MonoBehaviour will listen for incoming take_damage
command requests, execute some logic, and send
back a response.
You need to import:
- the package for the component (defined earlier as
improbable.player
) Improbable.Unity.Visualizer
, so you can use[Require]
Improbable.Entity.Component
, for various bits of the commands syntax
using Improbable.Player;
using Improbable.Unity.Visualizer;
using Improbable.Entity.Component;
The [Require]
syntax lets you specify that a MonoBehaviour should only be enabled if an object is available. In this
case, you only want to enable this MonoBehaviour on workers that have write access to Health
.
In the TakeDamage
class, require a writer:
[Require] private Health.Writer healthWriter;
Health.Writer
, and all the methods available through it, are generated byspatial worker codegen
(SpatialOS documentation).
If a component includes a command, the component writer contains a CommandReceiver
member. This
exposes callbacks, one for each command, that run when the command is invoked. You should add a callback
for each command to listen for incoming command requests.
You can add either a synchronous or an asynchronous callback. This section will cover both options.
This example adds a synchronous callback to CommandReceiver.OnTakeDamage
.
Synchronous callbacks are a straightforward way to respond to commands. When an incoming command request is received, a command response is generated using your callback and sent back to the sender immediately.
To respond to a command synchronously, implement a function with the following signature
<TResponse> <Callback_Function_Name>(TRequest request, ICommandCallerInfo callerInfo)
. The callerInfo
can be used
to access information about the command caller (its WorkerId
and
attributes (SpatialOS documentation)) in CallerDetails
.
Register the callback to CommandReceiver.OnTakeDamage
in OnEnable
, and deregister it in OnDisable
:
public void OnEnable()
{
healthWriter.CommandReceiver.OnTakeDamage.RegisterResponse(TakeDamage);
}
private DamageResponse TakeDamage(DamageRequest request, ICommandCallerInfo callerInfo)
{
uint desiredDamage = request.amount;
uint dealtDamage = System.Math.Min(desiredDamage, healthWriter.Data.amount);
healthWriter.Send(new Health.Update().SetAmount(healthWriter.Data.amount - dealtDamage));
return new DamageResponse(dealtDamage);
}
public void OnDisable()
{
healthWriter.CommandReceiver.OnTakeDamage.DeregisterResponse();
}
Alternatively, you can add an asynchronous callback to CommandReceiver.OnTakeDamage
.
When handling commands asynchronously, the callback is passed a ResponseHandle
object containing the method Respond()
which you use to respond to the command.
Unlike synchronous command responses, a response is not sent immediately. Instead, you can cache the ResponseHandle
object
and call ResponseHandle.Respond()
when you like.
Make sure that:
- you respond to a command within its timeout period
- all code paths following the callback method eventually lead to a
ResponseHandle.Respond()
call.
To respond to a command synchronously, implement a function with the following signature
void <Callback_Function_Name>(ResponseHandle<Command_Name, TRequest, TResponse> responseHandle)
.
The responseHandle
object contains additional information about the command sender in callerInfo
.
Register the callback to CommandReceiver.OnTakeDamage
in OnEnable
, and deregister it in OnDisable
:
public void OnEnable()
{
healthWriter.CommandReceiver.OnTakeDamage.RegisterAsyncResponse(TakeDamageAsync);
}
private void TakeDamageAsync(ResponseHandle<Health.Commands.TakeDamage, DamageRequest, DamageResponse> responseHandle)
{
uint desiredDamage = responseHandle.Request.amount;
uint dealtDamage = System.Math.Min(desiredDamage, healthWriter.Data.amount);
healthWriter.Send(new Health.Update().SetAmount(healthWriter.Data.amount - dealtDamage));
responseHandle.Respond(new DamageResponse(dealtDamage));
}
public void OnDisable()
{
healthWriter.CommandReceiver.OnTakeDamage.DeregisterResponse();
}
You've defined the signature of take_damage
in schema, and implemented the logic in a MonoBehaviour on the Unity worker.
Next, the wizard should invoke this command on the player.
Create a new a MonoBehaviour
, DealDamage.cs
, to the prefab you're using for the wizard.
You need to import:
- the package for the component (defined earlier as
improbable.wizard
) Improbable.Unity.Visualizer
, so you can use[Require]
Improbable.Unity.Core
, so you can use theSpatialOS
utility classImprobable.Entity.Component
, for various bits of the commands syntaxImprobable
, so you can refer toEntityId
using Improbable.Wizard;
using Improbable.Unity.Visualizer;
using Improbable.Unity.Core;
using Improbable.Entity.Component;
using Improbable;
Require a component writer for the Combat
component:
[Require] private Combat.Writer combatWriter;
The SendCommand
method requires you to pass in a component writer. This is for the same reason that the
CommandReceiver
object is defined on component writers: since an instance of this MonoBehaviour
will be
enabled on at most one worker across your simulation, it prevents sending the same command more than once.
Note that you can have multiple MonoBehaviours
on the same entity pointing to the same
instance of the writer.
You use the SendCommand
method on SpatialOS.Commands
to invoke commands.
The SendCommand
method looks like this:
ICommandResponseHandler<TResponse> SendCommand<TCommand, TRequest, TResponse>(IComponentWriter writer,
ICommandDescriptor<TCommand, TRequest, TResponse> commandDescriptor, TRequest request, EntityId entityId,
TimeSpan? timeout = null, CommandDelivery commandDelivery = CommandDelivery.RoundTrip)
where TCommand : ICommandMetaclass
Let's break that down a bit.
-
SendCommand()
returns anICommandResponseHandler
object which exposes the following methods:-
OnSuccess(CommandSuccessCallback<TResponse> successCallback)
is the callback triggered if the command succeeds. -
OnFailure(CommandFailureCallback failureCallback)
is the callback triggered if theStatusCode != StatusCode.Success
.This means SpatialOS can't guarantee that the command succeeded, rather than it necessarily failed.
You don't have to specify either of these callbacks if you don't want to.
TResponse Response
is the response object, of the type defined in the schema. In this case, it'sDamageResponse Response
.
-
-
writer
is the component writer you injected, for the reasons explained above. -
commandDescriptor
is a special object identifying a command. Command descriptors are generated for each command defined in your schema, and are named as{ComponentName}.Commands.{CommandName}.Descriptor
. So in this case, it'sHealth.Commands.TakeDamage.Descriptor
.To access the available list of commands you can invoke on a component, try typing "
{ComponentName}.Commands.
" and looking at your code completion results. (This will only work once you've runspatial worker codegen
). -
request
is the request argument to the command, of the type specified in the schema. Here, that'sDamageRequest
. -
entityId
is the ID of the entity you are sending the command to. -
timeout
(optional, default isnull
) is the timeout period for a command response. It's aTimeSpan
. -
commandDelivery
(optional) denotes whether the worker will attempt to short-circuit entity commands if possible.CommandDelivery
is an enum: the available values areCommandDelivery.RoundTrip
andCommandDelivery.ShortCircuit
.If you don't specify, the default value (
CommandDelivery.RoundTrip
) will be used: no short-circuiting.
Use this method to invoke the take_damage
command on the entity with the given entity ID:
void AttackPlayer(EntityId playerEntityId)
{
SpatialOS.Commands
.SendCommand(combatWriter, Health.Commands.TakeDamage.Descriptor, new DamageRequest(combatWriter.Data.attackPower), playerEntityId)
.OnSuccess(OnDamageRequestSuccess)
.OnFailure(OnDamageRequestFailure);
}
void OnDamageRequestSuccess(DamageResponse response)
{
Debug.Log("Take damage command succeeded; dealt damage: " + response.dealtDamage);
}
void OnDamageRequestFailure(ICommandErrorDetails response)
{
Debug.LogError("Failed to send take damage command with error: " + response.ErrorMessage);
}
To find the player's entity ID, you could send a query.