~4 min read

Multiplayer — Netcode for GameObjects

NetworkObject, NetworkVariable, ServerRpc/ClientRpc — the basics of a networked game.

Multiplayer is a separate universe of complexity, but Unity Netcode for GameObjects (NGO) greatly simplifies getting started. It’s Unity’s official package for synchronizing GameObjects between the server and clients in a client-server architecture.

Web

A WebSocket server with a custom protocol + your state management. Every field that needs to be visible to everyone you synchronize by hand: emit → on → update Redux/Zustand.

Unity

NetworkObject automatically synchronizes the Transform and NetworkVariable between the server and clients. RPCs are method calls over the network, like a remote emit. The transport is abstracted away (Unity Transport, Relay, Steam, etc.).

NGO architecture

NGO works in authoritative server mode (the server is the source of truth). The role options:

  • Server — a dedicated server with no local player. For competitive games and dedicated servers.
  • Host — a client that is also the server at the same time. Convenient for P2P and co-op.
  • Client — a connected player.

NetworkManager is the main singleton that starts the connection: StartHost(), StartServer(), StartClient(). There’s one per scene.

NetworkObject — the main component

For a GameObject to live on the network it needs a NetworkObject. This gives:

  • A unique network ID, identical on the server and the clients.
  • A binding to an owner (usually the client that “controls” this object).
  • Spawning — NetworkObject.Spawn() on the server creates the object on all clients too.
using UnityEngine;
using Unity.Netcode;

public class EnemySpawner : NetworkBehaviour
{
    [SerializeField] private GameObject enemyPrefab;

    public override void OnNetworkSpawn() {
        if (!IsServer) return;

        var enemy = Instantiate(enemyPrefab, transform.position, Quaternion.identity);
        enemy.GetComponent<NetworkObject>().Spawn();
    }
}

A prefab you spawn over the network must be registered in the NetworkPrefabsList on the NetworkManager. Otherwise the clients don’t know how to assemble it on their side.

NetworkBehaviour — the networked MonoBehaviour

Instead of MonoBehaviour, derive networked components from NetworkBehaviour. This gives:

  • IsServer, IsClient, IsOwner, IsHost — where the code is currently running.
  • OnNetworkSpawn() / OnNetworkDespawn() — the network-context equivalents of Start/OnDestroy.
  • Access to NetworkManager.Singleton.

NetworkVariable — a synchronized field

Want the enemy’s health to be identical on all clients? Declare it as a NetworkVariable<T>:

public class NetEnemy : NetworkBehaviour
{
    public NetworkVariable<int> Health = new NetworkVariable<int>(
        100,
        readPerm: NetworkVariableReadPermission.Everyone,
        writePerm: NetworkVariableWritePermission.Server
    );

    public override void OnNetworkSpawn() {
        Health.OnValueChanged += (oldVal, newVal) => {
            Debug.Log($"Health: {oldVal}{newVal}");
            if (newVal <= 0) Die();
        };
    }

    public void TakeDamage(int amount) {
        if (!IsServer) return; // only the server can write
        Health.Value = Mathf.Max(0, Health.Value - amount);
    }
}

Parameters:

  • readPerm — who can read (Everyone, Owner).
  • writePerm — who can write (Server or Owner).

A NetworkVariable is transmitted every “tick” (by default ~30 times/s) if the value has changed.

RPC — remote calls

NGO 2.x (shipped with Unity 6) supports a unified [Rpc] attribute with a SendTo parameter. This is the recommended syntax for new projects:

using Unity.Netcode;
using UnityEngine;

public class Shooter : NetworkBehaviour
{
    [SerializeField] private GameObject bulletPrefab;
    [SerializeField] private AudioClip shotClip;

    // Client → server. The server creates the bullet and broadcasts the sound.
    [Rpc(SendTo.Server)]
    public void FireRpc(Vector3 origin, Vector3 direction) {
        var bullet = Instantiate(bulletPrefab, origin, Quaternion.LookRotation(direction));
        bullet.GetComponent<NetworkObject>().Spawn();

        PlayShotSoundRpc(origin);
    }

    // Server → all clients (including the host). Presentational sound.
    [Rpc(SendTo.ClientsAndHost)]
    private void PlayShotSoundRpc(Vector3 position) {
        AudioSource.PlayClipAtPoint(shotClip, position);
    }
}

The SendTo values:

  • SendTo.Server — to the server (any client can call it).
  • SendTo.ClientsAndHost — to all clients + the host.
  • SendTo.NotServer — to everyone except the server.
  • SendTo.Owner / SendTo.NotOwner — to the owner / to everyone except the owner.
  • SendTo.Me, SendTo.Everyone, SendTo.SpecifiedInParams — flexible options.
Legacy [ServerRpc] / [ClientRpc] still work

The old [ServerRpc] / [ClientRpc] attributes (with the mandatory ServerRpc / ClientRpc suffixes in the method name) are supported for compatibility with NGO 1.x, but in new code use [Rpc] — it’s more flexible and has no strict naming requirement. Also, as of NGO 2.9+ [ServerRpc(RequireOwnership=...)] is deprecated in favor of the InvokePermission parameter with the RpcInvokePermission.Everyone / Authority values.

// The old style (still valid):
[ServerRpc]
public void FireServerRpc(Vector3 origin) { /* ... */ }

[ClientRpc]
private void PlayShotSoundClientRpc(Vector3 position) { /* ... */ }
Don't write state from a ClientRpc

ClientRpc is for presentational things (sounds, effects, floating labels). Game state is always on the server and synchronized via NetworkVariable. Otherwise the clients will diverge.

NetworkTransform — synchronizing the Transform

The simple case is to synchronize an object’s position and rotation. Add the NetworkTransform component to a GameObject. The options:

  • Sync Position X/Y/Z — which axes to synchronize.
  • Sync Rotation X/Y/Z — the same for rotation.
  • Interpolation — whether to smooth the movement on the client.
  • Threshold — how much the position has to change to send an update (minimizes traffic).

For a player-controlled object in NGO 2.x use the built-in field NetworkTransform.AuthorityMode = Owner — that’s the main path. By default AuthorityMode = Server (only the server writes). ClientNetworkTransform from the Multiplayer Samples is a legacy/sample from the 1.x era; avoid it in NGO 2.x.

Distributed Authority — an alternative to pure client-server

NGO 2.x also supports the Distributed Authority model (via Unity Cloud Multiplayer Services / the Multiplayer Services Package). The idea: ownership of nodes is distributed among the clients rather than concentrated on the server. One client owns its player, another the shared inventory, a third the boss.

This gives:

  • Lower latency for the local player’s actions (no need to wait for the server).
  • Cheaper infrastructure (a relay server, not an authoritative host).
  • Harder to validate (anti-cheat) — there’s no single source of truth.

It’s suited for co-op and party games, and not for competitive shooters.

It’s activated via NetworkConfig.NetworkTopology = NetworkTopologyTypes.DistributedAuthority on the NetworkManager + connecting to Unity Cloud Multiplayer Services (the Multiplayer Services Package). The [Rpc] syntax with SendTo stays the same, but the semantics of “who has authority” change.

Transport and connection

UnityTransport is the standard transport layer. For a direct connection by IP:

public class ConnectMenu : MonoBehaviour
{
    public void HostGame() {
        NetworkManager.Singleton.StartHost();
    }

    public void JoinGame(string ip) {
        var transport = NetworkManager.Singleton.GetComponent<Unity.Netcode.Transports.UTP.UnityTransport>();
        transport.SetConnectionData(ip, 7777);
        NetworkManager.Singleton.StartClient();
    }
}

For a real game, IP addresses are inconvenient (NAT, firewalls). The solution is Unity Relay + Lobby: Unity Services provide a relay server and matchmaking, and clients connect via a “join code” like “ABCD12”.

What usually goes wrong

  1. Logic in Update without an IsServer/IsOwner check. Spawning enemies, calculating damage, AI movement — server only. Otherwise each client has its own “enemy” and they don’t match.
  2. Passing large objects through RPCs. RPC parameters should be simple (primitives, simple structs with INetworkSerializable). Large data transmits poorly.
  3. Too-frequent NetworkVariable updates. Every field that changes 60 times per second is a load. Group them, throttle, or send them via an RPC on an event.
  4. Ignoring lag and prediction. At 200 ms ping, without prediction everything feels rubbery. Study Client-Side Prediction and Server Reconciliation (that’s already an advanced topic).

In the next chapter — Addressables: the modern asset-loading system.