Get SpatialOS

Sites

Menu

Add your first feature: A Health Pick-up tutorial for the FPS Starter Project

In-game view of the health pickup prefab

Before starting this tutorial, make sure you have followed the Get started guide which sets up the FPS Starter Project. This tutorial follows on from the Get Started guide.

What does the tutorial cover?

As part of this tutorial, you implement a simple health pack pick-up which grants health to players who walk over it. The amount of health granted is fixed and players consume the health pack through use; it then respawns a little later in play.

To implement this feature you:

  • Define a new health pack entity and its data.
  • Add health pack entities to the snapshot so they appear in the SpatialOS world.
  • Write pick-up logic to let health packs grant health to players.

Open the FPS Starter Project in your Unity Editor

Most of your interactions with the SpatialOS GDK will be from within the Unity Editor.

To get started, from your Unity Editor’s file browser, open the workers/unity directory inside the FPS Starter Project.

Define a new entity type

Every SpatialOS entity consists of SpatialOS components, defined in the project’s schema.

Existing entities in the FPS Starter Project, such as the Player entity type, have components for movement, health, shooting and other mechanics. The project is constructed from GDK packages, such as the SpatialOS GDK Health package, which you can open from within the Unity project. In many of these packages you will find a schema folder, with files like health.schema.

Entity components

We will give your health pack entities two pieces of data by defining it in a new component:

  • The amount of health the pack will grant.
  • A flag indicating whether the health pack is active, or has been consumed.

Create a schema directory in your project root, if it doesn’t already exist. Then create a pickups directory within that.

Within your schema/pickups/ directory, create a new file called health_pickup.schema and paste in the following definition:

package pickups;

component HealthPickup {
    id = 21001;
    bool is_active = 1;
    uint32 health_value = 2;
}

This defines a new SpatialOS component called HealthPickup, and adds two properties: is_active and health_value. The component id must be chosen by you, and the only requirement is that it is unique among all other components in the project. Each of the properties has a property number (e.g. = 1) associated with it, which is not an initial value. It is a number that identifies the order in which these properties will appear within the component.

Any time you modify your schema files you must then run code generation. To do this, click Generate code from the SpatialOS menu in the Unity Editor menu bar.

Generate code menu bar option

Code generation creates C# helper classes to allow you to make use of your schema within your game’s code. It therefore must be run in order to make used of your newly defined HealthPickup component within your game logic. It’s worth noting that when writing schema files, your properties must use snake case (e.g. “health_value”), and the code generation process will create the helper classes with PascalCase (i.e. “HealthValue”).

Entity templates

Health pickups are a new type of entity, and we must next define which components should be instantiated each time a new ‘health pickup’ entity is created.

As is typical for SpatialOS GDK projects, the FPS Starter Project contains a C# file that declares a function for each type of entity. This function defines what components are used to construct an entity. The object it returns is an Entity Template. Extending an existing entity type is as easy as adding additional components while the entity type is being constructed.

You can find this file in your Unity project: Assets/Fps/Scripts/Config/FpsEntityTemplates.cs.

To define an entirely new entity type we will need to add a new function within the FpsEntityTemplates class:

public static EntityTemplate HealthPickup(Vector3f position, uint healthValue)
{
    var gameLogic = WorkerUtils.UnityGameLogic;

    var healthPickupComponent = Pickups.HealthPickup.Component.CreateSchemaComponentData(true, healthValue);

    return EntityBuilder.Begin()
        .AddPosition(position.X, position.Y, position.Z, gameLogic)
        .AddMetadata("HealthPickup", gameLogic)
        .SetPersistence(true)
        .SetReadAcl(AllWorkerAttributes)
        .AddComponent(healthPickupComponent, gameLogic)
        .Build();
}

The EntityBuilder syntax provides a compact way to declare the relevant components. You may notice that .AddPosition, .AddMetadata, and .SetPersistence appear in the entity template function of every entity type. This is because these are mandatory “well-known components” that SpatialOS expects.

Add components

From your HealthComponent, the GDK has generated a Pickups.HealthPickup.Component.CreateSchemaComponentData() function. The property numbers in your schema file (i.e. = 1 and = 2) determine the order of properties expected as the function’s parameters.

This component can then be added to the HealthPickup entity using the line: .AddComponent(healthPickupComponent, gameLogic). The three “well-known components” (Position, Metadata and Persistence) must appear in that order, but after that you are free to add your remaining components in any order you like. Just remember that to complete the pattern the final call must be to .Build();.

Set permissions (ACLs)

Access Control Lists are how SpatialOS specifies which workers have permission to read-from or write-to the values of certain components. There may be data which you want to be kept private to server-side workers (because clients might use that knowledge to cheat!). Some components should definitely restrict their write-access to specific workers (e.g. a particular player’s client) or to server-side workers only, to prevent exploits. For example, in an RPG a player should probably not be able to update the amount of gold they are carrying (at least, not without the server-side validating they aren’t cheating!).

In the EntityBuilder syntax, the .SetReadAcl(AllWorkerAttributes) function call stated that all worker types should be able to read the data for this entity.

For each of the other components, such as your newly added HealthPickup component, the worker type which is given write-access is specified as a second argument to the component-adding function, e.g. WorkerUtils.UnityGameLogic. This is simply a string which identifies which worker type should be granted the relevant permission.

For this project, UnityGameLogic indicates that the UnityGameLogic worker is the one that handles server-side game logic. The identifier WorkerUtils.UnityClient would indicate that all clients are granted the relevant permission, but in this case we don’t want clients to be able to alter how much health is granted to players by a health pack, so we pass WorkerUtils.UnityGameLogic as the second parameter when adding the healthPickupComponent.

Add entities to the world

Once an entity template function exists you have a way of constructing the template of an entity. You now have a couple of ways of adding a health pack entity to the world:

  • At runtime, by passing an Entity object to an entity creation function.
  • At start-time, by adding an entity instance to the Snapshot so it is already in the world when the game begins.

For health packs we will do the latter, so that when the game begins there will already be health packs in pre-defined locations.

Edit snapshot generation

The SpatialOS menu option in the Unity Editor include an item “Generate FPS Snapshot”. This runs the script Assets/Fps/Scripts/Editor/SnapshotGenerator/SnapshotMenu.cs, which you can find from within your Unity Editor. We will now modify the snapshot generation logic to add a HealthPack entity to our snapshot.

Within the SnapshotMenu class, add a new method that will contain logic for adding health pack entities to the snapshot object:

private static void AddHealthPacks(Snapshot snapshot)
{
    var healthPack = FpsEntityTemplates.HealthPickup(new Vector3f(5, 0, 0), 100);
    snapshot.AddEntity(healthPack);
}

Additionally, add the following line at the top of the file to ensure that the Vector3f type can be resolved:

using Improbable;

This script now creates a health pack entity at position (5, 0, 0), and sets the amount of health it will restore to 100. Don’t forget to call your new function from within GenerateDefaultSnapshot() (and pass it the snapshot object) or else it won’t be run during snapshot generation!

[MenuItem("SpatialOS/Generate FPS Snapshot")]
private static void GenerateDefaultSnapshot()
{
    var snapshot = new Snapshot();

    var spawner = FpsEntityTemplates.Spawner();
    snapshot.AddEntity(spawner);

    var SimulatedPlayerCoordinatorTrigger = FpsEntityTemplates.SimulatedPlayerCoordinatorTrigger();
    snapshot.AddEntity(SimulatedPlayerCoordinatorTrigger);

    AddHealthPacks(snapshot);

    SaveSnapshot(snapshot);
}

You may want to consider separating default values (such as health pack positions, and health values) into a settings file. But for now, we will keep this example simple.

Update the snapshot

Snapshot files are found in your project root directory, in a directory named snapshots. The FPS Starter Project includes a snapshot called default.snapshot.

If you have updated the snapshot generation function (as you just did in the step above), or if you’ve altered which components are specified in one of your entity templates, then your snapshot will be out of date. The snapshot is a big collection of entities, and all their individual component values, so any change to these and the snapshot file must be regenerated.

You can regenerate the default.snapshot file from the SpatialOS menu option in the Unity Editor, by running “Generate FPS Snapshot”.

If you launch a local deployment (Ctrl + L in Unity), you should be able to see one HealthPickup entity in the world view of the Inspector. You won’t see the pickup in-game yet - this is the next step.

World view in the Inspector showing the HealthPickup entity

Represent the entity on your workers

If we were to test the game at this point, the health pack entity would appear in the inspector but not in-game. This is because we have not yet defined how to represent the entity on your client or server workers.

SpatialOS will manage which subset of the world’s entities each worker knows about, and provide them with the corresponding component data. You must define what the worker will do when it finds out about an entity it isn’t currently tracking. Fortunately the SpatialOS GDK for Unity provides some great tools for exactly that!

Plan your entity representations

First we must think about how each of the workers will want to represent the entity, so let’s return to how we want our game mechanic to play out:

  • Players should see a hovering health pack.
  • When a player runs through the health pack it is consumed and “disappears”, leaving just a marker on the ground.
  • A health pack should only be consumed if the player has taken damage.
  • Consumed health packs re-appear after a cool-down, and are ready for use again.

We can neatly separate this logic between the client-side and server-side representations:

  • The UnityClient worker should display a visual representation for each health pack, based on whether the health pack is currently “active”.
  • The UnityGameLogic worker should react to collisions with players, check whether they are injured, and consume the health pack if they are.

Create GameObject representations

The FPS Starter Project uses the SpatialOS GDK’s MonoBehaviour workflow, which is the familiar way of working with Unity. (Note that the GDK offers both a MonoBehaviour and ECS workflow.)

In the MonoBehaviour workflow you can associate a Unity prefab with your entity type, with separate prefabs for your UnityClient and UnityGameLogic workers. All entity prefabs should be added to Assets/Fps/Resources/Prefabs/UnityClient and Assets/Fps/Resources/Prefabs/UnityGameLogic respectively.

The FPS Starter Project uses the “GDK GameObject Creation” package which handles the instantiation of GameObjects to represent SpatialOS entities. This tracks associations between entities and prefabs by matching their Metadata component’s metadata string to the names of prefabs in the Assets/Fps/Resources/Prefabs/ directory. If the worker receives information about a new SpatialOS entity then the GameObject Creation package immediately instantiates a GameObject of the appropriate type to represent that entity.

Create a UnityClient entity prefab

The FPS Starter Project contains a health pack prefab named HealthPickup.prefab in the folder: Assets/Fps/Prefabs/. Have a look at it and create a copy in the Assets/Fps/Resources/Prefabs/UnityClient/ folder.

When creating entity prefabs it is usually a great idea to create a root GameObject which will contain your SpatialOS components and behaviours, with art assets added as children (which will also help with disabling inactive health packs later!).

Add a new script component to the root of your HealthPickup prefab, name it HealthPickupClientVisibility, and replace its contents with the following code snippet:

using Improbable.Gdk.GameObjectRepresentation;
using Pickups;
using UnityEngine;

namespace Fps
{
    [WorkerType(WorkerUtils.UnityClient)]
    public class HealthPickupClientVisibility : MonoBehaviour
    {
        [Require] private HealthPickup.Requirable.Reader healthPickupReader;

        private MeshRenderer cubeMeshRenderer;

        private void OnEnable()
        {
            cubeMeshRenderer = GetComponentInChildren<MeshRenderer>();
            healthPickupReader.ComponentUpdated += OnHealthPickupComponentUpdated;
            UpdateVisibility();
        }

        private void UpdateVisibility()
        {
            cubeMeshRenderer.enabled = healthPickupReader.Data.IsActive;
        }

        private void OnHealthPickupComponentUpdated(HealthPickup.Update update)
        {
            UpdateVisibility();
        }
    }
}

This script is mostly standard C# code that you could find in any game built with Unity Engine. There are a few annotations which are specific to the SpatialOS GDK, which we can look at more closely.

[WorkerType(WorkerUtils.UnityClient)]

This annotation decorating the class indicates the WorkerType of the script (in this case, UnityClient). This provides information for SpatialOS when it is building out your separate workers: a script with the client annotation should never be enabled on a UnityGameLogic worker. You can use these WorkerType annotations to control where your code runs. If a script should exist and be able to run on both client-side and server-side workers then this annotation can be omitted.

This HealthPickupClientVisibility script is going to rely on the is_active property of your new HealthPickup component, so the package declared in the health_pickup.schema file appears in a using statement at the top of the script:

using Pickups;

This namespace is part of the helper classes that the code generation phase created from your schema.

To make use of component data, either to read from it or write to it, we can use SpatialOS GDK syntax to inject the component into the script.

[Require] private HealthPickup.Requirable.Reader healthPickupReader;

The worker on which the code is running interprets this statement as an instruction to enable this script component on a particular entity’s associated GameObject, but only if that entity has a HealthPickup component and the worker has read-access to that component. Read-access is rarely limited, but the same syntax can be used with Writer instead of Reader, which would make the requirement even stricter. With a Writer, the script would only be enabled on the single worker that has write-access to the HealthPickup component on that entity.

These [Require] attributes are another powerful way to control where your code is executed. For the purpose of this script we only need to read the health pack’s data when deciding how to visualise it, so only a Reader is necessary.

You can see a use of the HealthPickup component’s data in the UpdateVisibility() function:

cubeMeshRenderer.enabled = healthPickupReader.Data.IsActive;

When you wrote the schema for the HealthPickup component you included a bool property called is_active, and code generation has created the IsActive member within the reader’s Data object. We’ll cover updating component property values later in this tutorial.

Setting the cubeMeshRenderer.enabled according to whether the health pack is “active” or not only works if cubeMeshRender correctly references the mesh renderer. Make sure you drag the child GameObject’s mesh renderer to this field in the Unity Inspector panel to set the reference.

The client-side representation of the health pack entity is now complete! Next we will test the game so far and make sure we are visualising the health packs correctly.

Test your changes

Our aim is to have health packs which restore lost health to players. So what have we accomplished so far?

  • You defined the schema for your health packs: a new component containing properties for how much health it will grant and whether it’s ready to do so.
  • You created an entity template function which provides a central definition of a particular entity type and can create Entity objects.
  • You added an instance of the health pack entity type to the snapshot so it will be present in the world when the game begins.
  • You associated a local representation with your new SpatialOS entity so that Unity will know how to visually represent any health pack it encounters.

You can launch a local deployment of your updated game world from the SpatialOS menu within the Unity Editor by clicking “Local launch” (Ctrl + L). This will open a terminal that should tell you when the world is ready.

Once the world is ready you can:

  • View all entities in the inspector from your browser: http://localhost:21000/inspector/
  • Open the FPS-Development scene in the Unity Editor. The scene file is located in Assets > Fps > Scene.
  • Press Play in your Unity Editor to play the game.

In-game view of the health pickup prefab

You’ll know it’s worked if you can see a HealthPickup entity in the inspector, and find a floating health pack when running around in-game. But currently it just sits there, inert. If you walk into it, nothing happens. Let’s fix that!

Our next step will be to add some game logic to the health pack so that it reacts to player collisions and grants them health.

Add health pack logic

If we were to test the game at this point we would now see the health pack entity in-game, but we’ve not yet given it the consumption behaviour.

The server-side logic we want to capture for this game mechanic is:

  • Tracking player collisions with the health pack.
  • Checking the pre-conditions: player must be injured, health pack must be active.
  • Granting health to a player when the conditions are met.

To do this we will need to create a server-side representation of the health pack, and add a script to the health pack which can both read its own component data (to check if the health pack is active) as well as that of the player entity (to check if it is injured). After that, if conditions are met, it then must also be able to update its own component data (to set itself to “inactive”), and that of the player entity (to grant it health).

Create a UnityGameLogic entity prefab

In the FPS Starter Project the server-side worker is called UnityGameLogic.

Create a copy of Assets/Fps/Prefabs/HealthPickup.prefab in the Assets/Resources/Prefabs/UnityGameLogic/ folder. Because this prefab will only be used for instantiating server-side game objects, the visual components are not needed, so feel free to remove the child renderers. Respectively, the Box Collider is not needed for client-side workers, so you can remove that from Assets/Resources/Prefabs/UnityClient/HealthPickup.prefab if you wish. Make sure you keep it in the UnityGameLogic copy of the prefab as we are about to use it to track player collisions with the health pack.

Then, add a script component to your new prefab called HealthPickupServerBehaviour and replace its contents with the following code snippet which contains a couple of pieces of code we still need to write:

using System.Collections;
using Improbable.Gdk.Core;
using Improbable.Gdk.GameObjectRepresentation;
using Improbable.Gdk.Health;
using Pickups;
using UnityEngine;

namespace Fps
{
    [WorkerType(WorkerUtils.UnityGameLogic)]
    public class HealthPickupServerBehaviour : MonoBehaviour
    {
        [Require] private HealthPickup.Requirable.Writer healthPickupWriter;
        [Require] private HealthComponent.Requirable.CommandRequestSender healthCommandRequestSender;

        private void OnEnable()
        {
        }

        private void OnDisable()
        {
        }

        private void OnTriggerEnter(Collider other)
        {
            // OnTriggerEnter is fired regardless of whether the MonoBehaviour is enabled or disabled.
            if (healthPickupWriter == null)
            {
                return;
            }

            if (!other.CompareTag("Player"))
            {
            	return;
            }

            HandleCollisionWithPlayer(other.gameObject);
        }

        private void SetIsActive(bool isActive)
        {
            // Replace this comment with your code for updating the health pack component's "active" property.
        }

        private void HandleCollisionWithPlayer(GameObject player)
        {
            var playerSpatialOsComponent = player.GetComponent<SpatialOSComponent>();

            if (playerSpatialOsComponent == null)
            {
                return;
            }

            // Replace this comment with your code for notifying the Player entity that it will receive health.

            // Toggle health pack to its "consumed" state.
            SetIsActive(false);
        }
    }
}

This code snippet contains two comments that are placeholders for code which we will now write.

The first placeholder is in a function called SetIsActive. IsActive is the name of the bool property in the HealthPickup component you created, and we want this function to perform a component update, setting the value of IsActive to a given value. We want this update to be synchronized across all workers - updating the true state of that entity - and that can only be done by the single worker that has write-access.

To make sure this script is only enabled on the worker with write-access it already has a statement at the top of the class which “requires” a Writer:

[Require] private HealthPickup.Requirable.Writer healthPickupWriter;

A Writer can also be used to read component data from its respective component (in this case, HealthPickup), but it provides an API for updating the values of properties too.

Unity Engine’s OnTriggerEnter function is a special-case when a script is disabled. Other functions will only be called if the script component’s enabled property is true, but OnTriggerEnter will be called even if it is false. This means it is an exception to the normal behaviour of the [Require] syntax. Because of this, scripts which use OnTriggerEnter must check whether the Writer is null (indicating a lack of authority) before using functions on the writer.

To update the HealthPickup component we must construct a HealthPickup.Update object, and send it to SpatialOS. You can replace the comment in the SetIsActive with the following snippet to handle creating and sending this update:

healthPickupWriter?.Send(new HealthPickup.Update
{
    IsActive = new Option<BlittableBool>(isActive)
});

There is one more comment we must replace with code before our logic will work, which is found in the HandleCollisionWithPlayer function. The Player entity prefab has already been given a “Player” tag, so this function will be called any time a player walks through a health pack.

Cross-worker interaction using commands

Our HandleCollisionWithPlayer function will run on a UnityGameLogic worker (because of the [WorkerType(WorkerUtils.UnityGameLogic)] annotation). That worker executes the code on behalf of each HealthPack entity for which is has HealthPickup component write-access (because of the Writer requirement). If your game has multiple UnityGameLogic workers then the worker with write-access for the HealthPack entity may not be the same worker that has write-access for the Player entity who has tried to pick up the health pack.

If we want a HealthPack entity’s script to update the Player entity’s current health we must notify the responsible worker and request the update. SpatialOS allows you to do this with commands, which are defined in schema.

The Player entity already has a Health component which defines both a property representing current health and a command called modify_health. The definition of that command in health.schema is:

command improbable.common.Empty modify_health(HealthModifier);

This command definition states that a HealthModifier object must be passed as an argument with the command request. HealthModifier is a type that is also defined in health.schema, which simply bundles a few properties together.

When you send a command it acts as a request which SpatialOS will deliver to the single worker that has write-access for the component in which the command is defined. If that worker has handling logic for that type of command then it will be triggered, and many commands also return a response. A command response can provide information generated as a result of the request, or can be used to indicate whether the request was accepted or not.

For this command the return type is specified as improbable.common.Empty, as we don’t want to return any information to the sender.

In your HandleCollisionWithPlayer function, you can replace the placeholder comment with the following code snippet:

healthCommandRequestSender.SendModifyHealthRequest(playerSpatialOsComponent.SpatialEntityId, new HealthModifier
{
    Amount = healthPickupWriter.Data.HealthValue
});

The top of the HealthPickupServerBehaviour class has a [Require] statement for HealthComponent.Requireable.CommandRequestSender which, appropriately enough, is necessary for sending commands that are defined in the Health component. Even though the Health component belongs to the Player entity, and this script is on the HealthPickup entity, this statement is still required.

Command request sender objects are automatically generated for you during code generation, based on your schema, and it provides a “send” function for each of the component’s commands. Just by defining your commands in schema and running code generation all of these helper classes are generated ready for you to use.

The code snippet calls SendModifyHealthRequest, specifies the entity id of the recipient entity (in this case the player entity), and constructs a HealthModifier object with the appropriate data. In our case the amount of health we wish to grant is based on the HealthPickup entity’s HealthValue property, so we retrieve the value from healthPickupWriter.Data.HealthValue.

The ModifyHealth command is already used by the FPS Starter Project for applying damage as part of the shooting game mechanics. As this is the case we don’t need to write any additional logic for applying the health increase.

Respawn health packs

Our health packs use the IsActive property to indicate whether they can be visualised (and whether they will grant health on collision). One final feature we will add is a co-routine to re-activate consumed health packs after a cool-down period.

In your HealthPickupServerBehaviour class, define a coroutine function that looks something like this:

private IEnumerator RespawnCubeRoutine()
{
    yield return new WaitForSeconds(15f);
    SetIsActive(true);
}

Your coroutine should be started at the end of the HandleCollisionWithPlayer function. You should also start the coroutine in OnEnable for any health pack entities which are inactive (IsActive equal to false) to begin with. Running coroutines should also be stopped in OnDisable.

Optional: Ignore healthy players

The HandleCollisionWithPlayer function in your HealthPickupServerBehaviour.cs script currently attempts to heal any colliding player. If the player is already on full health we might want to ignore them so that the health pack is not consumed and the health modifier command is never sent.

At the beginning of the HandleCollisionWithPlayer function you could add an if-statement which reads the player’s current health from a Monobehaviour (that you will need to write) and returns early if their health is at the maximum. In the code that handles incoming ModifyHealthRequest commands the player’s health is clamped to the value of the Health component’s max_health property, but it’s preferable to avoid sending the request if we know it will be denied anyway.

Test health pick-ups locally

The distributed game logic is now in place, and we can test if it is working correctly. To test this feature, you can follow these steps:

If you implemented the respawn coroutine then you should also see the health pack reappear after a short time. Here’s how it should look:



That’s it! Well done, and welcome to the GDK!

We’d love to know what you think, and invite you to join our community on our forums, or on Discord.

Search results

Was this page helpful?

Thanks for letting us know!

Thanks for your feedback

Need more help? Ask on the forums