Tutorial: Build an Enemy Whose HP Is Managed by the Server

Steps to write room rules in C# and let multiple players attack the same enemy in Unity

In this tutorial, you build a small room in which everyone who joins can attack the same enemy.

  • There is one enemy, with 100 HP.
  • Each time a player attacks, its HP drops by 25.
  • When its HP reaches 0, it revives with 100 HP after 8 seconds.

The server calculates HP. Unity only sends an “attack” request and displays the HP the server has confirmed. As a result, even when multiple players attack at the same time, everyone sees the same HP.

You use the stateMachine server type. For the differences between server types, see Server Types and How to Choose.

Workflow

StepWhere you workWhat you build
1. Write the rulesText editor / IDEC# that spawns, attacks, and revives the enemy
2. Generate GSL and the sync schemaUnity Editormain.gsl, EnemyFields.realtimeschema
3. Register the definitionGS2StateMachineMaster and the Realtime namespace settings
4. Create the enemy PrefabUnity EditorA Prefab that displays HP and sends attacks
5. Connect to the roomUnity EditorA scene that displays the enemy from the received state
6. Verify the behaviorUnity (2 clients)—

You work in a Unity project (Unity 2021.3 or later) with the GS2 SDK for Unity and a version of LocalStateMachineKit that includes the C# generation feature.

1. Write the Rules

Outside your Unity project’s Assets, create the following folders and files. The C# you write here is not code that runs in Unity; it is input for generating GSL.

GameRules/
└── EnemyRoom/
    ├── project.json
    └── Rules/
        └── EnemyRoom.cs

Write the room rules in EnemyRoom.cs.

using Gs2.LocalStateMachine.Authoring;
using Step = Gs2.LocalStateMachine.Authoring.Step<
    GameRules.RoomState, GameRules.RoomResult, GameRules.RoomError>;

namespace GameRules;

// Sync fields for the enemy. Prefab ID 7; delivers position and HP to clients
[RoomSchema(7, StandardFields = RoomSync.Position)]
public enum EnemyFields
{
    Health = 6,
}

public record RoomState(int EnemyId);
public record RoomResult(bool Finished);
// Rejection reasons reach the client's CommandCompleted as is. Use values that do not overlap with system result codes
public enum RoomError { InvalidTarget = 1001, AlreadyDefeated = 1002 }

public static class EnemyRoom
{
    // Runs once when the room starts
    public static Step Start()
    {
        Room.DefineServerPrefab<EnemyFields>();
        var enemy = Room.Spawn<EnemyFields>(0, 0, 0);
        Room.Set(enemy, EnemyFields.Health, 100);
        return Step.WaitFor(nameof(Interact), new RoomState(enemy))
            .Or(nameof(Timer));
    }

    // When a player calls SharedObject.Interact
    public static Step Interact(RoomState state, int objectId, int method)
    {
        if (objectId != state.EnemyId || method != 1)
            return Step.Ignore(RoomError.InvalidTarget);

        var hp = Room.Get(objectId, EnemyFields.Health);
        if (hp == 0)
            return Step.Ignore(RoomError.AlreadyDefeated);

        hp = System.Math.Max(0, hp - 25);
        Room.Set(objectId, EnemyFields.Health, hp);
        if (hp == 0)
            Room.After(objectId, 8000, 1); // Receive Timer (eventId 1) after 8 seconds
        return Step.Stay(state);
    }

    // When the time scheduled with Room.After arrives
    public static Step Timer(RoomState state, int objectId, int eventId)
    {
        if (objectId != state.EnemyId || eventId != 1)
            return Step.Ignore(RoomError.InvalidTarget);

        Room.Set(objectId, EnemyFields.Health, 100);
        return Step.Stay(state);
    }
}

There are three key points.

  • Declare synced values with RoomSchema. Both the server definition and the Unity sync schema are generated from the EnemyFields declaration. The enum values (Health = 6) are the field numbers on the wire. Position uses 0 to 2, so use 6 or higher for your own values.
  • Keep HP on a shared object. What is delivered to clients is the values of shared objects; RoomState is not delivered. Put values that only the server uses, such as the enemy’s object ID, in RoomState.
  • The server decides whether an attack is allowed. Write the validation of the target and the count in Interact. In a real game, you also validate the attack range and cooldown here.

2. Generate GSL and the Sync Schema

Save the generation settings in GameRules/EnemyRoom/project.json. Paths are relative to this file. Specifying RealtimeRoomV1 for hostProfile generates GSL that runs in a Realtime room.

{
  "format": 1,
  "source": "Rules",
  "output": "../../Assets/Generated/EnemyRoom",
  "hostProfile": "RealtimeRoomV1"
}
  1. From the Unity menu, select GS2 > Local State Machine > Generate from C#.
  2. From the … of Existing definition > Project, select GameRules/EnemyRoom/project.json.
  3. Press Generate.

The following files are generated in Assets/Generated/EnemyRoom.

FilePurpose
main.gslThe rules you register with GS2 in step 3
package.zipA distribution file containing only main.gsl
unity/EnemyFields.realtimeschemaThe sync schema you specify on the enemy Prefab in step 4
source-map.jsonA development file for tracing generated locations back to the original C#

Generation errors are shown in the Console with the file name and line number of the original C#. For the subset of C# you can use, see the C# Definition Reference.

3. Register the Definition

Register the generated rules with GS2-StateMachine’s UpdateStateMachineMaster.

ItemValue
namespaceNameThe GS2-StateMachine namespace that stores the rules
mainStateMachineNameEnemyRoom (the class name of the rules)
payloadThe contents of main.gsl (as a string)

Next, in the Realtime namespace, specify the rules that run in the room.

SettingFieldValue
Server typeserverTypestateMachine
Where the rules are storedstateMachineSetting.namespaceIdThe GS2-StateMachine namespace you registered above
Rules versionstateMachineSetting.versionOmit (use the latest)

If you omit the version, the room loads the latest rules registered at the time it is allocated, once, and runs with those rules until the room ends. When you register the rules again, rooms allocated after that use the new rules.

To pin a specific version, specify the version returned by the registration. While it is pinned, registering the rules again is not reflected in rooms.

4. Create the Enemy Prefab

Import the Sync Schema

Assets/Generated/EnemyRoom/unity/EnemyFields.realtimeschema becomes a sync schema asset when Unity imports it. Since the output is inside Assets, you can use it as is.

Create the Prefab

  1. Create a Prefab for the enemy’s appearance, and add the following components to its root.

    ComponentSetting
    NetworkEntitySpecify EnemyFields for Schema
    NetworkTransformPosition Axes is All, Rotation Axes is None (the server does not deliver rotation)
    SharedObjectNo settings. Used to send operations to the server
  2. Place a Slider that displays HP as a child, add the following script to the root, and assign the Slider. Set the Slider’s Max Value to 100.

    using UnityEngine;
    using UnityEngine.UI;
    
    public sealed class EnemyHealthView : MonoBehaviour
    {
        [SerializeField] private Slider health;
    
        public void SetHealth(int value)
        {
            health.value = value;
        }
    }
  3. Add an entry with NetworkEntity > Property bindings > Add binding, and select Health for Field. Register EnemyHealthView in Apply to and select Dynamic int > SetHealth. The received HP is now reflected in the Slider.

Send Attacks

Send attacks to the server with SharedObject.Interact. Add the following script to the Prefab’s root and call Attack from an attack button or similar.

using Gs2.Realtime.Sync;
using UnityEngine;

[RequireComponent(typeof(SharedObject))]
public sealed class EnemyAttack : MonoBehaviour
{
    public void Attack()
    {
        var target = GetComponent<SharedObject>();
        if (target.Object != null)
            target.Interact(1); // Reaches the rules' Interact as method 1
    }
}

Interact(1) is a request to “perform operation method 1 on this enemy”. Instead of rewriting HP directly, the server’s rules validate the attack and update HP, and the result is delivered to everyone.

5. Connect to the Room

Place a NetworkWorld

  1. In the Project window, create a catalog with Create > GS2 > Realtime > Prefab Catalog.
  2. In the catalog’s Prefabs, register Id 7 and the enemy Prefab from step 4. 7 is the Prefab ID you specified with RoomSchema(7).
  3. Create an empty GameObject in the scene, add NetworkWorld, and specify the catalog for Prefab Catalog.

NetworkWorld creates shared objects received from the server from the Prefabs in the catalog. GameObjects created with Unity’s Instantiate are not shared. For how a player creates their own object, see Create Player Objects.

Add the Connection Script

Add the following script to the same GameObject as the NetworkWorld. Obtain the room’s connection information (IP address, port, encryption key) and the access token as described in Obtaining connection information for the game server, and pass them in.

using System.Collections;
using System.Collections.Generic;
using Google.Protobuf;
using Gs2.Core;
using Gs2.Gs2Realtime.Message;
using Gs2.Realtime.Sync;
using Gs2.Unity.Gs2Realtime;
using UnityEngine;

[RequireComponent(typeof(NetworkWorld))]
public sealed class EnemyRoomConnection : MonoBehaviour
{
    private readonly Queue<BinaryMessage> outgoing = new Queue<BinaryMessage>();
    private RealtimeSession session;

    public IEnumerator Connect(string accessToken, string ipAddress, int port, string encryptionKey)
    {
        var world = GetComponent<NetworkWorld>();
        session = new RealtimeSession(accessToken, ipAddress, port, encryptionKey, ByteString.Empty);
        session.OnClose += _ => world.Detach();

        AsyncResult<bool> result = null;
        yield return session.Connect(null, value => result = value);
        if (result == null || result.Error != null || !session.Connected)
            yield break;

        // Register the receive handler before starting Dispatch so you don't miss the snapshot sent right after joining
        world.Attach(session.MyConnectionId, (bytes, count) =>
        {
            if (outgoing.Count >= 32 || !session.Connected)
                return false;
            outgoing.Enqueue(new BinaryMessage { Data = ByteString.CopyFrom(bytes, 0, count) });
            return true;
        });
        // Passing the server time from the metadata enables world.ServerTime and time-based interpolation
        session.OnMessageWithMetadata += (message, metadata) => world.Receive(message.Data.ToByteArray(), metadata.LifeTimeMilliSeconds);
        StartCoroutine(session.Dispatch());
        StartCoroutine(SendQueued());
    }

    private IEnumerator SendQueued()
    {
        while (session != null && session.Connected)
        {
            if (outgoing.Count == 0)
            {
                yield return null;
                continue;
            }
            var sent = false;
            yield return session.Send(result => sent = result.Error == null && result.Result, outgoing.Dequeue());
            if (!sent)
            {
                // Instead of discarding changes that could not be sent, disconnect and fetch the state again
                yield return session.Close();
                yield break;
            }
        }
    }
}

The function you pass to Attach receives the data to send, pushes it onto the send queue, and returns whether it could be queued. NetworkWorld batches changes and sends them through this function.

When the connection succeeds, the server sends the current room state (a snapshot), and NetworkWorld creates the enemy.

6. Verify the Behavior

Connect to the same room from two clients. Connecting with the same userId replaces the earlier connection, so use access tokens for different userIds.

  1. Confirm that both clients show the same single enemy with 100 HP.
  2. Attack from one client and confirm that both displays show 75.
  3. Send attacks from both clients and confirm that HP reaches 0 after 4 attacks in total.
  4. Confirm that attacks do not reduce HP while it is 0, and that both displays return to 100 after about 8 seconds.
  5. Disconnect one client and reconnect, and confirm that HP does not reset to its initial value but shows the current value.

You can check whether an attack was rejected with NetworkWorld.Client.CommandCompleted. An attack on an enemy with 0 HP returns 1002 (AlreadyDefeated), as specified in the rules.