7. Server-side representation

The client-side logic we want to implement for this feature is:

  • Detect player collisions with the health pack.
  • Grant health to the player that collides with the health pack.
  • Turn the health pack to inactive on collision.
  • After a period of time, turn the health pack back to active.

Step 1. In your Unity Editor, locate Assets/Fps/Prefabs/HealthPickup.prefab.

Step 2. Select this prefab and press Ctrl+D/Cmd + D to duplicate it.

Step 3. Move this duplicated prefab to Assets/Fps/Prefabs/Entities/UnityGameLogic.

Step 4. Rename the duplicated prefab to HealthPickupGameLogic (the process of duplication will have appended an unnecessary 1 to the file name).

Step 5. To map this prefab for the Gamelogic worker, select Assets/Fps/Config/GamelogicPrefabMapping

Step 6. Select New Entity Type, and then select SimpleEntityResolver to get a new element in the mapping list.

Step 7. Expand the new element and change Entity Type to HealthPickup, and set Prefab to the newly created HealthPickupGameLogic.

Step 8. Select the prefab HealthPickupGameLogic again to open it in the editor.

Step 9. Still in your Unity Editor, add a new script component to the root of your HealthPickupGameLogic prefab by selecting Add Component > New Script in the Inspector window.

Step 10. Name this script HealthPickupServerBehaviour and open in in your code editor.

This script will contain the logic to listen for collisions, grant health to players, and toggle the active state.

Step 11. Replace the contents of HealthPickupServerBehaviour with the following snippet:

using System.Collections;
using Fps.Config;
using Improbable.Gdk.Subscriptions;
using Pickups;
using UnityEngine;

namespace Fps
{
    [WorkerType(WorkerUtils.UnityGameLogic)]
    public class HealthPickupServerBehaviour : MonoBehaviour
    {
        [Require] private HealthPickupWriter healthPickupWriter;
        [Require] private HealthComponentCommandSender healthCommandRequestSender;

        private Coroutine respawnCoroutine;
        private Collider collider;

        private void OnEnable()
        {
            collider = gameObject.GetComponentInChildren<Collider>();

            // If the pickup is inactive on initial checkout - turn off collisions and start the respawning process.
            if (!healthPickupWriter.Data.IsActive)
            {
                collider.enabled = false;
                respawnCoroutine = StartCoroutine(RespawnHealthPackRoutine());
            }
        }

        private void OnDisable()
        {
            if (respawnCoroutine != null)
            {
                StopCoroutine(respawnCoroutine);
            }
        }

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

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

            HandleCollisionWithPlayer(other.gameObject);
        }

        private void SetIsActive(bool isActive)
        {
            collider.enabled = isActive;
            healthPickupWriter?.SendUpdate(new Pickups.HealthPickup.Update
            {
                IsActive = isActive
            });
        }

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

            if (playerSpatialOsComponent == null)
            {
                return;
            }

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

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

            // Begin cool-down period before re-activating health pack
            respawnCoroutine = StartCoroutine(RespawnHealthPackRoutine());
        }

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

Step 12. Copy the following snippet into the HealthPickup method inside FpsEntityTemplates class to add Interest for players around the HealthPickup entity.

var query = InterestQuery.Query(Constraint.RelativeCylinder(radius: 25)).FilterResults(new[]
{
		Position.ComponentId, Metadata.ComponentId, OwningWorker.ComponentId, ServerMovement.ComponentId,
    ClientRotation.ComponentId, HealthComponent.ComponentId, ShootingComponent.ComponentId
});

var interest = InterestTemplate.Create().AddQueries<Pickups.HealthPickup.Component>(query);

entityTemplate.AddComponent(interest.ToSnapshot());

Let’s break down what HealthPickupServerBehaviour does:

  • [WorkerType(WorkerUtils.UnityGameLogic)]

This WorkerType annotation marks this MonoBehaviours to only be enabled for a specific worker-type. In this case, this MonoBehaviour will only be enabled on UnityGameLogic server-workers, ensuring that it will never run on your client-workers.

  • [Require] private HealthPickupWriter healthPickupWriter;

This is a Writer object, which allows you to interact with and modify your SpatialOS components easily at runtime. In particular, this is a HealthPickupWriter, which allows you to access and write to the value of the HealthPickup component of the underlying linked entity. For more information about Readers, see the Writer API.

The [Require] annotation on the HealthPickupWriter is very important. This tells the GDK to inject this object when its requirements are fulfilled. A Writer's requirements is that the underlying SpatialOS component is checked out on your worker-instance, and your worker-instance is authoritative over that component.

A Monobehaviour will only be enabled if all required objects have their requirements satisfied.

  • private void OnTriggerEnter(Collider other)

Most functions will only be called if the MonoBehaviour is enabled, but OnTriggerEnter is called even when it is disabled. It is unusual in this sense. For this reason, scripts which use OnTriggerEnter must check whether objects that are have annotations [Require] are null (indicating that the requirements were not met) before using functions on those objects.

  • healthPickupWriter?.SendUpdate(new HealthPickup.Update(...));

Here, we send a component update to the SpatialOS Runtime. The fields within the Update struct indicate whether the corresponding field should be updated. If the Option is empty, the field will not be updated. If the Option is not empty, the field will be updated.

  • private void HandleCollisionWithPlayer(GameObject player)

This function will be called any time a player walks through a health pack. It handles cross-worker interaction using commands. When you send a command it acts as a request, which SpatialOS delivers to the worker-instance that has write-access for the component that the command is intended for.

Cross-worker interactions can be necessary when your game has multiple UnityGameLogic server-workers, because the worker with write-access for the HealthPack entity may not be the same worker that has write-access to the Player entity who has collided with that health pack.

  • private IEnumerator RespawnHealthPackRoutine()

This coroutine re-activates consumed health packs after a cool-down period. It starts at the end of the HandleCollisionWithPlayer function as well as in OnEnable for any health pack entities which are inactive. Any running coroutines are stopped in OnDisable.

Only one worker at a time is able to have write-access for a component. This prevents simultaneous changes putting the world into an inconsistent state. It is known as the single writer principle. If you want to learn more when you're done with the tutorial, have a look at Authority and interest.

Any worker can send a command. However, only the worker which has write access to the component holding a command is allowed to handle it. Each command request contains information about the caller of the command which could be used to enforce restrictions. Have a look at ServerHealthModifierSystem.cs in the Health feature module.

In a large game, where there are multiple workers of each type (such as multiple UnityGameLogic workers), the SpatialOS load-balancer decides how to divide write-access for components between the available workers.

The coroutine is local only to the worker that started it. If the worker loses write-access to the component then the script will become disabled for that entity (and remember, we call StopCoroutine in OnDisable to cancel the coroutine). In theory, this leaves the entity in a state where InActive is false, and no coroutine is running that will eventually change that.

Just as one worker lost write-access, another will be granted it by the load-balancer. That newly authoritative worker now meets the criteria specified by the [Require] syntax in the HealthPickupServerBehaviour class, and so the script will become enabled.

This is why in OnEnable() you should start the coroutine if healthPickupWriter.Data.IsActive is false.

The newly authoritative worker will not know how long the cool-down had already been running on the previous worker, so the cool-down timer is ultimately "refreshed" at this point. If this was a problem for our mechanic then we could store the timer's progress in a new property, but in this case we will keep it simple.

Updated about a year ago



7. Server-side representation


Suggested Edits are limited on API Reference Pages

You can only suggest edits to Markdown body content, but not to the API spec.