Thinking in multiplayer: How I shifted my mindset to be better at network game development

by Rob Staples 2025-11-20

So I've been working on a multiplayer game in my spare time for a few years now, and to be honest this isn't my first attempt... one sticking point in my coding journey was getting used to how to manage state between devices. It literally took me months to get used to this and I had to do a lot of research along the way, look at different docs and just code a lot to nail it into my head. I figure if I write up a cheat sheet here, someone might come across this and it might help them speed up their learning and get confidence to start coding earlier.

When I started writing networking code I did what most other millenial’s do, I watched a YT video, I chose a high-level API because for the examples I was looking at I don't see myself pushing the limits of networking and being able stay focused on the game while encapsulating away the networking seemed like a great compromise. Here is where some more seasoned developers will spot my mistake. High level APIs are great at abstracting away transport level code, but you can never fully encapsulate a feature, and I ran into data problems after Null exception after reference error. Constantly crashing out my playtests.

my first attempt

I'm hoping this will become a series where as I encounter and solve issues with my programming I can write a small blog so others can benefit from my knowledge and learnings.

For this example I'll be using Mirror, a high-level networking library for Unity. Whether you're building a simple co-op puzzle game or a competitive arena shooter, Mirror handles the complex networking so you can focus on your gameplay. Now you don't have to use Mirror just because you are using unity. There are so many great frameworks out there for networking and all with their pros and cons, but mirror is the one I'm familiar with and I've been very happy with it so far for the projects I've been working on.

An intro to network communication

You can get pretty far without knowing the theory behind network encapsulation, but I've found just knowing a little but helps when designing

The OSI model is the main model in which we understand data moving throughout the internet. Data moves up and down these layers wrapping and unwrapping as different contexts are needed. This allows for better security, resiliency and in some areas redundancy so that your experience when using the internet for your various activities.

OSI Model

  • Application layer - This is the user facing layer that you will spend the most of your time coding in. think about a single user's experience here.
  • Presentation layer - The encryption layer, it prepares the data for being sent over the network. It also controls file types, but that's not really relevant for us.
  • Session layer - Think TCP, UDP this manages the session between two computers. also controls ports, you deal with this layer when establishing connections
  • Transport layer - Packets and messaging, this layer controls the data packets moving through the network. This is where low level APIs sit a lot of the time.
  • Network layer - The logical addressing, think IP, DNS and availability zones. this layer is what we think of when we think of the internet or a LAN network.
  • Data link layer - Node-to-node transfer. how your data bounces around the network or the internet to get to its destination.
  • Physical layer - The cables on the wire. as a game developer if you are ever worrying about this layer something has gone very very wrong

The layers we care most about when dealing with multiplayer games from a development perspective are the application layer and the transport layer, the latter being mostly abstracted away unless we run into issues with complex datatypes (we can cover how and when to do this in a future series).

When connecting to another computer you will also use the Session layer, ideally you would use a service for this (Steam handles this by default on their storefront). having to create your own services for this takes time away from development.

How the code (and your paradigms) should be structured

Mirror uses a networking coding pattern called Remote Procedural Calls (or RPC's), when code is shared across both the client and the server, allowing code to be run on a remote machine as if it was locally. This helps the programmer(you) to stay in application level thinking while letting the libraries handle the complexity of how the packets are transmitted over the network.

Remote Action

  • If a client needs to speak to the server and it has authority to do so it will use a command function. this actions main purporse is to validate and authenticate the request.
  • If a server needs to speak to all the clients at once it will use a clientRpc function to broadcast those changes.
  • If a server needs to speak to a specific client they will send a targetRpc to that specific client using the connectionId to determine the correct connection.

Some examples that might help:

  • When a new game starts the server needs to tell players about their opponents so sends out an RPC
  • When a player collides with a coin it needs to be marked in the server then that particular player needs their score updated.
  • A player tries to open a door so the server needs to know if it is valid (A command) and then the server will communicate that to the client (A Broadcast RPC).

For more of a Deep dive have a look at mirrors Remote Actions article.

In Practice

This is where we get to the valuable part. Ill comment on my thought processes at each step so you can understand when we need to decide what information to pass over the network.

If we want to make a basic player controller in unity using mirror we here is how we could use all the theory above to make a function that works across the network:

using Mirror;
using UnityEngine;
public class PlayerGun : NetworkBehaviour
{
    //This function subscribes the player to the input function if the current player has authority.
    void OnStartAuthority()
    {
        PlayerInput.Shoot += ClientShoot;
    }

    void OnStopAuthority()
    {
        PlayerInput.Shoot -= ClientShoot;
    }

    //functions should either run on the server or the client, rarely both.
    //Use tags to get runtime errors if you are calling your code in the wrong computer.
    //Calling on the client first isnt really necessary but can be expanded later to do pre-networking checks or error checking.
    [Client]
    void ClientShoot()
    {
        //A simple example, you would never actually get a target this way in a game lol.
        int targetIndex = GetGunTarget()
        CmdShoot(targetIndex, 5);

        //After this we might apply the visual changes around the ammo, so we arent waiting for the server
        //We will still correct this if the server overrides it later.
        AmmoUI.ReduceAmmo(1)
    }

    //A command is a function that calls from the client to the server.
    // in a command it is important to check first the validity of the call.
    [Command]
    void CmdShoot(int playerIndex, int damage)
    {
        //Checks to make sure that the request is valid you can do any security checks you need.
        if (!hasAuthority) return;
        if (!canSeeTarget) return;
        if (ammo <= 0) return;
        if (damage <= 0) return;

        //Gets the network identity from a singleton helper funciton.
        // I want to do a blog on the myriad ways to attain identity later on.
        NetworkIdentity opponentIdentity = GetPlayerWithIndex(playerIndex);

        if (opponentIdentity == null) return;

        //Once all the Authentication work is done we treat it like application code again.
        //Keeping all the transport layer calls in the commands helps seperate out the logic.
        ServerAdjustHealth(opponentIdentity, damage);
    }


    [Server]
    void ServerAdjustHealth(NetworkIdentity opponentIdentity, int damage)
    {
        //We want to communicate to the players that the player has taken damage
        RpcAdjustHealth(damage);
        ammo--;
        //We want to communicate to the target that the server ammo has changed. this could be done by an RPC call or a syncVar.
        TargetChangeAmmo(GetComponent<NetworkIdentity>().conn, ammo);
    }

    [ClientRpc]
    void RpcAdjustHealth(int playerIndex, int damage)
    {
        HealthbarUI.AdjustHealth(damage);
    }

    //You never use the conn variable. but you need it in there for the Rpc to know who to communicate with.
    //You can remove it if you only want this funciton to communicate with the owner of the object
    [TargetRpc]
    void TargetChangeAmmo(NetworkConnectionToClient conn, int newAmmo)
    {
        AmmoUI.AmmoSync(newAmmo)
    }
}

See how simple the output looks! you could easily see this slapped onto a monobehaviour, combined with some UI you would have the start of a game right there.

I have changed the order of this example to show the flow from one function to the next funciton but this would not be how it would look in an actual example. Your weekly notice to not just grab code you see directly off the internet, it will rarely work for your code architecture.

Quick Tips for Success

  • Start with Mirror's included examples. The Tanks and Pong demos demonstrate essential patterns you'll use repeatedly. They're well-commented and cover common scenarios like spawning, scoring, and game state management.

  • Test locally before going online. Use Mirror's Host mode (server + client) for rapid iteration. You can even open multiple Unity instances on the same machine to simulate multiple players.

  • Keep networked data minimal. Only sync what's absolutely necessary - position, rotation, health, and key state changes. Let clients handle their own particle effects and UI updates to reduce bandwidth.

  • Keep as much state on the server side as possible, only communicate the visual changes to the client. The more state you have to manage on the client the more you open yourself up to bugs and vulnerabilities.

  • Counterpoint to the above, things the player has authority over or things where a delay would cause a degradation in the experience should be visually handled by the client. and only overridden by the server if there is an inconsistency.

  • Use SyncVars for simple data that changes occasionally (like player health or score). For frequently updating data like position, Mirror's NetworkTransform component handles optimization automatically.

Moving Forward

The things I covered here are just the absolute basics, and barely touches the start. there are so many things to cover and times when you do need to drop down to the transport layer to define package size or if you need to send more complex or discrete data types.

I'm likely going to keep expanding this series as I get more into the game and show the examples from the game I've been working on. If you are interested, feel free to subscribe to my rss feed.