> For the complete documentation index, see [llms.txt](/llms.txt)


# 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

| Step | Where you work | What you build |
| --- | --- | --- |
| [1. Write the rules](#rules) | Text editor / IDE | C# that spawns, attacks, and revives the enemy |
| [2. Generate GSL and the sync schema](#generate) | Unity Editor | `main.gsl`, `EnemyFields.realtimeschema` |
| [3. Register the definition](#register) | GS2 | StateMachineMaster and the Realtime namespace settings |
| [4. Create the enemy Prefab](#prefab) | Unity Editor | A Prefab that displays HP and sends attacks |
| [5. Connect to the room](#connect) | Unity Editor | A scene that displays the enemy from the received state |
| [6. Verify the behavior](#verify) | Unity (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 {#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.

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

Write the room rules in `EnemyRoom.cs`.

```csharp
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 {#generate}

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.

```json
{
  "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`.

| File | Purpose |
| --- | --- |
| `main.gsl` | The rules you register with GS2 in step 3 |
| `package.zip` | A distribution file containing only `main.gsl` |
| `unity/EnemyFields.realtimeschema` | The sync schema you specify on the enemy Prefab in step 4 |
| `source-map.json` | A 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](#language).

## 3. Register the Definition {#register}

Register the generated rules with GS2-StateMachine's [UpdateStateMachineMaster](#updatestatemachinemaster).

| Item | Value |
| --- | --- |
| `namespaceName` | The GS2-StateMachine namespace that stores the rules |
| `mainStateMachineName` | `EnemyRoom` (the class name of the rules) |
| `payload` | The contents of `main.gsl` (as a string) |

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

| Setting | Field | Value |
| --- | --- | --- |
| Server type | `serverType` | `stateMachine` |
| Where the rules are stored | `stateMachineSetting.namespaceId` | The GS2-StateMachine namespace you registered above |
| Rules version | `stateMachineSetting.version` | Omit (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 {#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.

   | Component | Setting |
   | --- | --- |
   | **NetworkEntity** | Specify `EnemyFields` for **Schema** |
   | **NetworkTransform** | **Position Axes** is `All`, **Rotation Axes** is `None` (the server does not deliver rotation) |
   | **SharedObject** | No 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.

   ```csharp
   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.

```csharp
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 {#connect}

### 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](#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](#obtaining-connection-information-for-the-game-server), and pass them in.

```csharp
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 {#verify}

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.

## What to Read Next

- [Unity Client](): position sync, control, RPC, player objects, UDP, and handling reconnection.
- [Migration Guide from Photon](): a mapping table to the PUN2 API and the migration steps.
- [Room Definition Reference](): the Room API, timers, committing and rolling back changes, and limits.
- [C# Definition Reference](): how to write states, operations, and Step.



