Skip to main content

๐Ÿ† Lesson 6.5: Mini-Project โ€” A Small Authoritative Multiplayer Prototype

Everything Module 6 taught, in one playable game. Two or more players share an arena, move their avatars, and race to collect coins โ€” with a synced score. Every rule runs on the authoritative server: clients request movement, the server owns positions and pickups, and scores replicate to all. It's small, but it's correct netcode, end to end.

๐ŸŽฏ What You'll Build

  • Server-authoritative movement โ€” clients send input via RPC, the server moves and replicates
  • Server-spawned coins as NetworkObjects
  • Server-validated pickups that award a synced score NetworkVariable
  • A scoreboard driven by OnValueChanged, playable host + client

Estimated Time: 120 minutes  ยท  Prerequisite: Lessons 6.1โ€“6.4 (all of Module 6)

In This Lesson

The Architecture

Every piece maps onto the "server writes, clients request" model. Here's the whole loop:

The authoritative loop of the prototype Client reads input and sends a MoveRpc to the server; the server moves the avatar and NetworkTransform replicates it; the server detects a coin pickup, despawns the coin, and increments a Score NetworkVariable that replicates to clients, which update the scoreboard via OnValueChanged. Client (owner) read input scoreboard UI OnValueChanged Server (authority) move avatar detect pickup (trigger) despawn coin Score.Value += 10 All clients NetworkTransform avatars move coins despawn Score replicates MoveRpc replicate Score NetworkVariable โ†’ OnValueChanged โ†’ every scoreboard
Figure 1: The loop. Input becomes a request (RPC) โ†’ the server acts authoritatively โ†’ results replicate as transform + NetworkVariable โ†’ clients present them. Nothing shared is ever written by a client.

Step 1: Scene & Prefabs

  1. A scene with a ground plane, a NetworkManager (Unity Transport), and the ConnectionMenu from Lesson 6.1.
  2. Player prefab: a capsule + NetworkObject + a NetworkTransform (leave it server-authoritative) + a trigger collider (Is Trigger on) + the scripts below. Assign it as the NetworkManager's Player Prefab.
  3. Coin prefab: a small sphere + NetworkObject + NetworkTransform + the Coin script + a (non-trigger) collider. Register it in the NetworkManager's network prefabs list.
  4. An empty CoinSpawner object with a NetworkObject and the spawner script.

Step 2: Authoritative Movement

The owner reads input and requests movement; the server applies it, and the server-authoritative NetworkTransform replicates the new position to everyone. The client never moves its own transform directly:

using Unity.Netcode;
using UnityEngine;

public class PlayerMovement : NetworkBehaviour
{
    [SerializeField] float speed = 5f;

    void Update()
    {
        if (!IsOwner) return;   // only drive the avatar I own

        var input = new Vector3(Input.GetAxis("Horizontal"), 0f, Input.GetAxis("Vertical"));
        if (input.sqrMagnitude > 0.001f)
            MoveRpc(input.normalized);   // request โ†’ runs on the server
    }

    [Rpc(SendTo.Server)]
    void MoveRpc(Vector3 direction)
    {
        // Executes on the SERVER. It owns the transform; NetworkTransform replicates it.
        transform.position += direction * speed * Time.deltaTime;
    }
}

โš ๏ธ Per-frame RPCs are a prototype convenience

Sending a move RPC every frame is fine for learning but chatty for a real game โ€” production code sends input at a fixed tick rate (or uses client prediction). Keep that in mind, but for this prototype the clarity is worth it: you can see the request โ†’ authoritative move โ†’ replicate flow directly.

Step 3: Coins & Pickups

The coin is a trivial NetworkBehaviour carrying a value. The server spawns a field of them on start:

using Unity.Netcode;
using UnityEngine;

public class Coin : NetworkBehaviour
{
    public int Value = 10;
}

public class CoinSpawner : NetworkBehaviour
{
    [SerializeField] GameObject coinPrefab;
    [SerializeField] int count = 20;
    [SerializeField] float range = 8f;

    public override void OnNetworkSpawn()
    {
        if (!IsServer) return;   // ONLY the server spawns

        for (int i = 0; i < count; i++)
        {
            var pos = new Vector3(Random.Range(-range, range), 0.5f, Random.Range(-range, range));
            var go  = Instantiate(coinPrefab, pos, Quaternion.identity);
            go.GetComponent<NetworkObject>().Spawn();   // replicate to all clients
        }
    }
}

Pickup detection runs on the server via the player's trigger. Because movement is server-authoritative, the physics overlap happens on the server โ€” so the trigger fires there, and we guard with IsServer to be safe:

using Unity.Netcode;
using UnityEngine;

public class PlayerScore : NetworkBehaviour
{
    // server writes, everyone reads
    public NetworkVariable<int> Score = new NetworkVariable<int>(
        0, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Server);

    void OnTriggerEnter(Collider other)
    {
        if (!IsServer) return;   // authoritative pickup only on the server

        if (other.TryGetComponent(out Coin coin) && coin.NetworkObject.IsSpawned)
        {
            Score.Value += coin.Value;        // NetworkVariable โ†’ replicates to all
            coin.NetworkObject.Despawn();      // remove the coin for everyone
        }
    }
}

Notice there's no client-supplied number anywhere โ€” the server reads the coin's own value, checks it's still spawned, awards points, and despawns it. A cheat-client can't fake a pickup because it can't run this code; it only ever moves (a validated request), and the server decides the rest.

Step 4: Synced Score & UI

The score is already a NetworkVariable, so the UI just subscribes to changes โ€” no polling, and correct on every machine including late joiners:

using Unity.Netcode;
using UnityEngine;
using TMPro;   // or UnityEngine.UI.Text

public class ScoreLabel : NetworkBehaviour
{
    [SerializeField] TMP_Text label;
    PlayerScore score;

    public override void OnNetworkSpawn()
    {
        score = GetComponent<PlayerScore>();
        score.Score.OnValueChanged += OnScoreChanged;
        OnScoreChanged(0, score.Score.Value);   // init to current (late joiners)
    }

    public override void OnNetworkDespawn()
    {
        if (score != null) score.Score.OnValueChanged -= OnScoreChanged;
    }

    void OnScoreChanged(int previous, int current) =>
        label.text = $"{OwnerClientId}: {current}";
}

Each player object shows its own score above its head, updated the instant the server changes it โ€” and every client sees every player's score, because the NetworkVariable replicates to all.

Step 5: Play It

  1. Build the project. Run one instance, click Host; run a second, click Client (connects to 127.0.0.1).
  2. Move each avatar with WASD. Each window controls only its own player, but both avatars move in both windows โ€” server-authoritative movement replicating.
  3. Drive over coins. They vanish for everyone, and the collector's score ticks up on every scoreboard.
  4. Confirm authority: nothing a client does changes shared state except through the server. That's a correct multiplayer game.

โš ๏ธ Testing tip

The Multiplayer Play Mode package spins up multiple virtual players inside one Editor, so you can test host + client without making a build each time. Highly recommended for iterating on netcode.

What You Built & Extending

โœ… Every Module 6 concept, working together

Sessions and roles (6.1), NetworkObjects + auto-spawned players + ownership (6.2), a synced Score NetworkVariable (6.3), and a movement-request RPC with server validation (6.4) โ€” assembled into a playable, cheat-resistant game. The authority discipline is the throughline: server writes, clients request.

  • Win condition. Add a match timer and a server-side check that ends the round and broadcasts the winner with a SendTo.ClientsAndHost RPC.
  • Respawning coins. Have the server spawn a new coin whenever one is collected, keeping the field full.
  • Smoother movement. Send input at a fixed tick rate instead of every frame, or explore client prediction (a specialisation โ€” see the finale's "where to go next").
  • Player names. A FixedString32Bytes NetworkVariable set on spawn, shown on the scoreboard.

Quick Quiz

Question 1: How does a client move its avatar in this prototype?

Question 2: Where does coin-pickup logic run, and why?

Question 3: Why is the score a NetworkVariable rather than sent by RPC?

Summary

๐ŸŽ‰ Key Takeaways

  • A correct multiplayer game is the four pieces working together: session/roles, spawning/ownership, NetworkVariables, and RPCs.
  • Movement: owner requests via [Rpc(SendTo.Server)]; the server moves; server-authoritative NetworkTransform replicates.
  • Spawning: only the server instantiates + Spawn()s coins; clients receive them by replication.
  • Pickups: validated on the server (trigger + IsServer guard); award a server-written Score NetworkVariable and despawn the coin for all.
  • UI: driven by OnValueChanged, correct on every machine including late joiners.
  • The throughline is authority: clients request, the server decides, results replicate.

๐Ÿš€ What's Next?

That completes the Netcode pillar. The final pillar is about your productivity and shipping. Module 7: Editor Tooling & Addressables begins with Lesson 7.1: Custom Inspectors & Property Drawers โ€” bending the Editor itself to your workflow.

๐Ÿ† A real multiplayer game

Players move, coins collect, scores sync โ€” all server-authoritative and cheat-resistant. You've built genuine netcode from foundations to a finished loop.