Building an Online FPS in Unity: Multiplayer, Server Authority and Testing

An online FPS puts several players in the same match over a network. This article uses **Netcode for GameObjects** (NGO), Unity's official networking package, to build the basics: starting as a host or client, player objects that sync across machines, server-decided shooting, respawning and kill scores.

The most important rule in online games is **server authority**. A client may say it fired, but it cannot decide whether the shot hit or how much health a player has. The code below follows that rule.

Important: the match rules (damage, death, respawn, scoring) were compiled and run in this environment: 8 tests, all passing. The networked part (NGO) was not run here, because it needs the Unity Editor and at least two players. Follow the steps and test it yourself before relying on it.

Note: the code keeps the Thai comments from the original version.

What you need

Step 1: install Netcode for GameObjects

1. Open the FPS project and go to **Window → Package Manager**.

2. Select **Unity Registry**, search for **Netcode for GameObjects** and click **Install**.

3. Install **Unity Transport** as NGO recommends. It is usually installed automatically.

4. Check **Edit → Project Settings → Player**. Active Input Handling should still be Both or Input Manager (Old), as in the FPS article.

Step 2: create the NetworkManager and the start menu

Schematic of the scene (not a real screenshot): 1 the NetworkManager GameObject 2 the NetworkBoot GameObject 3 the Start Host button 4 the Start Client button 5 the server address field
Schematic of the scene (not a real screenshot): 1 the NetworkManager GameObject 2 the NetworkBoot GameObject 3 the Start Host button 4 the Start Client button 5 the server address field

1. Right-click in the Hierarchy, choose **Create Empty** and name it `NetworkManager`.

2. Click **Add Component** and add **Network Manager** and **Unity Transport**.

3. Create another empty object named `NetworkBoot` and add the `NetworkBoot` script below.

4. Press **Play**. Two buttons appear in the top-left corner.

using Unity.Netcode;
using UnityEngine;

// ปุ่มเริ่มเกม: เลือกเป็นโฮสต์ (เซิร์ฟเวอร์ + ผู้เล่น) หรือไคลเอนต์ (เข้าร่วม)
// วางบน GameObject ชื่อ NetworkBoot ในฉาก และให้มี NetworkManager อยู่ในฉากด้วย
public class NetworkBoot : MonoBehaviour
{
    [SerializeField] private string address = "127.0.0.1";  // ที่อยู่ของโฮสต์ (ใส่ IP ของเครื่องโฮสต์เมื่อเล่นข้ามเครื่อง)

    private void OnGUI()
    {
        if (NetworkManager.Singleton.IsClient || NetworkManager.Singleton.IsServer) return;

        GUILayout.BeginArea(new Rect(10, 10, 220, 120));
        if (GUILayout.Button("Start Host (server + player)")) NetworkManager.Singleton.StartHost();
        if (GUILayout.Button("Start Client (join)")) NetworkManager.Singleton.StartClient();
        address = GUILayout.TextField(address);
        GUILayout.EndArea();
    }
}

The address field here is only a text box for testers. Set the real address on the **Unity Transport** component when you play across machines.

Step 3: make the Player prefab

Schematic of the Player prefab Inspector (not a real screenshot): 1 Network Object 2 Network Transform 3 Player Network 4 Owner Setup 5 the Camera Object field
Schematic of the Player prefab Inspector (not a real screenshot): 1 Network Object 2 Network Transform 3 Player Network 4 Owner Setup 5 the Camera Object field

1. Start from the `Player` GameObject of the FPS project (Character Controller, FPSMovement, MouseLook, and a child camera with a Muzzle).

2. Add **Network Object**. This tells NGO that the object is synced.

3. Add **Network Transform** to sync position and rotation to every machine.

4. Remove the `Weapon` component. Shooting moves into `PlayerNetwork`.

5. Add the `PlayerNetwork` and `OwnerSetup` scripts (below).

6. Drag `Player/Camera/Muzzle` into the **Muzzle** field and the child camera into the **Camera Object** field of `OwnerSetup`.

7. In `OwnerSetup`, add `FPSMovement` and `MouseLook` to **Owner Only**, so only the owning player controls them.

8. Drag `Player` from the Hierarchy into `Assets/Prefabs/` to make it a prefab, then delete it from the scene.

9. Select `NetworkManager` and drag this prefab into its **Player Prefab** field.

Step 4: match rules in plain C#

The rules live in a plain class that does not use `UnityEngine`, so it can be tested without the editor. This is the logic the server uses.

// กติกาของแมตช์ เขียนเป็น C# ธรรมดา (ไม่ใช้ UnityEngine) เพื่อให้ทดสอบได้โดยไม่ต้องเปิด Unity
// เซิร์ฟเวอร์เป็นผู้ตัดสินทุกอย่างด้วยกติกานี้ ไคลเอนต์ไม่มีสิทธิ์เปลี่ยนเลือดหรือคะแนนเอง
public class MatchRules
{
    public const int MaxHealth = 100;
    public const int DamagePerShot = 25;
    public const int KillsToWin = 10;
    public const float RespawnSeconds = 3f;

    public int Health { get; private set; }
    public int Kills { get; private set; }
    public int Deaths { get; private set; }
    public float RespawnTimer { get; private set; }

    public bool IsDead => Health <= 0;

    public MatchRules()
    {
        Health = MaxHealth;
    }

    // คืน true ถ้าการยิงนี้ทำให้ผู้ถูกยิงตาย
    public bool ApplyDamage(int amount)
    {
        if (IsDead) return false;
        Health -= amount;
        if (Health <= 0)
        {
            Health = 0;
            Deaths++;
            RespawnTimer = RespawnSeconds;
            return true;
        }
        return false;
    }

    // เรียกทุกเฟรม (หรือทุกรอบของเซิร์ฟเวอร์) คืน true เมื่อครบเวลาและฟื้นคืนชีพแล้ว
    public bool Tick(float deltaSeconds)
    {
        if (!IsDead) return false;
        RespawnTimer -= deltaSeconds;
        if (RespawnTimer > 0f) return false;

        Health = MaxHealth;
        RespawnTimer = 0f;
        return true;
    }

    public void AddKill()
    {
        Kills++;
    }

    public bool HasWon()
    {
        return Kills >= KillsToWin;
    }
}

Step 5: the online player script

It has three parts:

using Unity.Netcode;
using UnityEngine;

// ผู้เล่นออนไลน์: ผู้เล่นเจ้าของเครื่องส่งคำสั่งยิง แต่เซิร์ฟเวอร์เป็นคนตรวจการยิง คิดดาเมจ และนับคะแนน
// ต้องติดอยู่บน Player prefab พร้อม NetworkObject และ NetworkTransform
public class PlayerNetwork : NetworkBehaviour
{
    [SerializeField] private Transform muzzle;                 // ปากกระบอกปืน (อยู่ใต้กล้องของผู้เล่น)
    [SerializeField] private float range = 100f;
    [SerializeField] private float fireInterval = 0.2f;
    [SerializeField] private LayerMask hitMask;                // เลเยอร์ที่ยิงโดน (ผู้เล่นและกำแพง)
    [SerializeField] private Transform[] spawnPoints;          // จุดเกิดที่เซิร์ฟเวอร์เลือกตอนฟื้นคืนชีพ

    // เลือดและคะแนน: ทุกเครื่องอ่านได้ แต่มีเพียงเซิร์ฟเวอร์ที่เขียนค่าได้
    public NetworkVariable<int> Health = new NetworkVariable<int>(
        MatchRules.MaxHealth, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Server);
    public NetworkVariable<int> Kills = new NetworkVariable<int>(
        0, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Server);

    private float nextFireTime;
    private float respawnTimer;

    public bool IsAlive => Health.Value > 0;

    private void Update()
    {
        // ฝั่งผู้เล่น: รับปุ่มยิง แล้วส่งคำขอไปยังเซิร์ฟเวอร์
        if (IsOwner && Input.GetMouseButtonDown(0) && Time.time >= nextFireTime && IsAlive)
        {
            nextFireTime = Time.time + fireInterval;
            FireServerRpc(muzzle.position, muzzle.forward);
        }

        // ฝั่งเซิร์ฟเวอร์: นับเวลาฟื้นคืนชีพ
        if (IsServer && Health.Value <= 0)
        {
            respawnTimer -= Time.deltaTime;
            if (respawnTimer <= 0f) Respawn();
        }
    }

    // ทำงานบนเซิร์ฟเวอร์เท่านั้น เมื่อผู้เล่นส่งคำขอยิง
    [ServerRpc]
    private void FireServerRpc(Vector3 origin, Vector3 direction)
    {
        if (!IsAlive) return;                                  // คนตายยิงไม่ได้

        if (Physics.Raycast(origin, direction, out RaycastHit hit, range, hitMask))
        {
            PlayerNetwork target = hit.collider.GetComponentInParent<PlayerNetwork>();
            if (target != null && target != this)
            {
                target.TakeDamageOnServer(MatchRules.DamagePerShot, OwnerClientId);
            }
        }
    }

    // เรียกได้เฉพาะบนเซิร์ฟเวอร์ คิดดาเมจและให้คะแนนผู้ยิงเมื่อฆ่าสำเร็จ
    public void TakeDamageOnServer(int amount, ulong shooterClientId)
    {
        if (!IsServer || !IsAlive) return;

        int remaining = Health.Value - amount;
        Health.Value = remaining < 0 ? 0 : remaining;   // ใช้การเช็คเอง เพราะ Mathf.Max รับ float
        if (Health.Value > 0) return;

        respawnTimer = MatchRules.RespawnSeconds;
        var shooter = NetworkManager.Singleton.ConnectedClients[shooterClientId].PlayerObject;
        if (shooter != null)
        {
            shooter.GetComponent<PlayerNetwork>().Kills.Value++;
        }
    }

    private void Respawn()
    {
        Health.Value = MatchRules.MaxHealth;
        if (spawnPoints != null && spawnPoints.Length > 0)
        {
            transform.position = spawnPoints[Random.Range(0, spawnPoints.Length)].position;
        }
    }
}

Key points:

Step 6: owner-only control and camera

Schematic (not a real screenshot): the Owner Only and Camera Object fields from step 3
Schematic (not a real screenshot): the Owner Only and Camera Object fields from step 3

Every player has a copy of the Player prefab. Without this step, one player could control another's character and see extra cameras. `OwnerSetup` handles that.

using Unity.Netcode;
using UnityEngine;

// เปิดการควบคุมและกล้องเฉพาะผู้เล่นเจ้าของตัวละครนั้น ผู้เล่นคนอื่นที่เห็นตัวละครนี้จะไม่มีกล้องและไม่รับปุ่ม
public class OwnerSetup : NetworkBehaviour
{
    [SerializeField] private Behaviour[] ownerOnly;   // สคริปต์ที่ต้องเปิดเฉพาะเจ้าของ เช่น FPSMovement และ MouseLook
    [SerializeField] private GameObject cameraObject; // กล้องลูกของผู้เล่น

    public override void OnNetworkSpawn()
    {
        foreach (Behaviour script in ownerOnly)
        {
            script.enabled = IsOwner;
        }
        if (cameraObject != null)
        {
            cameraObject.SetActive(IsOwner);
        }
    }
}

Step 7: how the data flows

Flow diagram (not a real screenshot): 1 client A sends a fire request 2 the server checks the hit and applies damage 3 every client receives the new health
Flow diagram (not a real screenshot): 1 client A sends a fire request 2 the server checks the hit and applies damage 3 every client receives the new health

When player A shoots and hits player B:

1. Player A clicks. A's client calls `FireServerRpc` with the muzzle position and direction.

2. The server receives it, checks that A is alive, and raycasts from that position.

3. If the ray hits B, the server calls `TakeDamageOnServer` and reduces B's health.

4. B's health is a `NetworkVariable`, so NGO syncs it to every machine. Everyone sees the same number.

5. If B's health reaches zero, the server gives A a kill and starts B's respawn timer.

Step 8: testing

Match rules (run in this environment)

The test file `MatchRulesTests.cs` (in the Thai version) checks damage, death, respawn timing and the win condition without Unity. Compile `MatchRules.cs` and `MatchRulesTests.cs` together with a recent C# compiler (for example `csc -langversion:latest -out:MatchRulesTests.exe *.cs`) and run the output.

PASS  one shot: health drops from 100 to 75
PASS  four shots: player dies on the fourth
PASS  four shots: death is counted
PASS  damage while dead: no extra death counted
PASS  respawn: still dead after 1 second
PASS  respawn: back to full health after 3 seconds
PASS  win: not yet at 9 kills
PASS  win: wins at 10 kills

Passed 8 / Failed 0

Networked play (do this in the Unity Editor)

This was not run here. Do it yourself:

1. Press **Play** in the editor and click **Start Host**. You are one player.

2. Open a second Unity window on the same project, or use **ParrelSync** (a popular tool for running multiple editor instances), and click **Start Client**.

3. Check that each player sees the other and that each moves only their own character.

4. Shoot the other player four times. Check that health drops on both screens and the shooter gains a kill.

5. Check that the player respawns three seconds after dying.

Common problems

Limits of this article

Study these topics before releasing a game to many players.

Summary

Online FPS games rely on server authority: clients send requests, the server decides, and the values everyone sees are written only by the server. The rules are in a plain class that can be tested. The networking uses NGO as described above. The rule tests pass here. The networked play must be tested in the Unity Editor.