DEV Community

Cover image for How to Integrate Mirror and SocketIO in Unity — A Hybrid Multiplayer Architecture
Magithar Sridhar
Magithar Sridhar

Posted on

How to Integrate Mirror and SocketIO in Unity — A Hybrid Multiplayer Architecture

If you've built multiplayer in Unity, you've probably hit this question at some point:

"Do I use Mirror, or do I use a backend with Socket.IO?"

The answer most tutorials give you is pick one. But they're solving different problems — and using both is not only possible, it's the right architecture for most production multiplayer games.

This guide covers exactly how to integrate socketio-unity with Mirror — what each system owns, where the boundary is, and how to implement the integration without the two systems bleeding into each other.

The Core Principle: Two Systems, Two Jobs

Socket.IO is a WebSocket client. It connects your game to a Node.js backend and handles everything that requires a server to broker: matchmaking, lobbies, session identity, authoritative scores, reconnection recovery. The backend is the source of truth. Socket.IO is the pipe.

Mirror is an in-scene networking stack. It synchronises transforms, physics, and animation state between players at frame rate. It has no concept of a backend server and cannot validate gameplay events.

The key insight: Mirror never validates — it only synchronises. Socket.IO never touches transforms — it only brokers.

Socket.IO (Node.js Backend)          Mirror (In-Scene)
─────────────────────────────        ──────────────────
Matchmaking, lobbies, session ──►    StartClient() → server
Scores, kills, round state           NetworkTransform, Rigidbody
Reconnect recovery, host ID          NetworkBehaviour lifecycle
Enter fullscreen mode Exit fullscreen mode

Decision Table: What Goes Where

Concern Socket.IO Mirror
Matchmaking / lobby rooms
Session identity across reconnects
Player transform / position
Rigidbody / physics sync
Animation state
Scores, kill feed, round state
Host migration
Reconnect recovery
Anti-cheat / server validation
WebGL browser support ✅ (via SimpleWebTransport)

When you're unsure which system owns something, ask: does this need a server to broker it, or does it need low-latency peer sync? The answer tells you where it goes.


Installation

socketio-unity (v1.4.0+):

https://github.com/Magithar/socketio-unity.git?path=/package
Enter fullscreen mode Exit fullscreen mode

NativeWebSocket (required dependency):

https://github.com/endel/NativeWebSocket.git#upm
Enter fullscreen mode Exit fullscreen mode

Mirror — install via Package Manager or .unitypackage from the Mirror repo.

Architecture

The session has four distinct phases. Understanding the phase boundaries is more important than any individual line of code.

Session Timeline

Phase 1 — Lobby (Socket.IO only)

Players connect to the /lobby namespace, create or join rooms, exchange session credentials. Mirror is not active at all.

Phase 2 — Match Start (handoff)

The host emits start_match. The backend broadcasts match_started to all room members. This single event is the handoff point — it triggers MirrorGameOrchestrator, which starts Mirror.

Phase 3 — In-Game (both layers active)

Mirror syncs positions via NetworkTransform every frame. Socket.IO delivers authoritative backend events (score_update, player_killed) via the /game namespace. The two systems run in parallel, never touching each other's concerns.

Phase 4 — Teardown (mandatory order)

// Step 1 — Mirror first
if (NetworkServer.active) mirrorNetworkManager.StopHost();
else mirrorNetworkManager.StopClient();

// Step 2 — Clean /game namespace handlers
gameEventBridge.Cleanup();

// Step 3 — Clear netId ↔ playerId mappings
GameIdentityRegistry.Clear();

// Step 4 — Signal intentional leave
lobbyNetworkManager.LeaveRoom();
Enter fullscreen mode Exit fullscreen mode

Reversing steps 1 and 4 is the most common mistake. If you call LeaveRoom() (which closes the socket) before StopHost(), Mirror tries to send disconnect packets over a closed transport. Silent failure, maddening to debug.

The Bridge: GameIdentityRegistry

Mirror speaks in netId (uint). Socket.IO speaks in playerId (string). They need a translation layer.

GameIdentityRegistry is a static lookup table that maps between the two:

// Register on Mirror player spawn
GameIdentityRegistry.Register(netId, playerId);

// Resolve when a Socket.IO event arrives
var identity = GameIdentityRegistry.GetNetworkObject(playerId);
if (identity != null)
{
    // apply event to Mirror object
}

// Clear on ReturnToLobby
GameIdentityRegistry.Clear();
Enter fullscreen mode Exit fullscreen mode

GetNetworkObject checks NetworkServer.spawned first, then NetworkClient.spawned, so it works correctly in all Mirror roles — host, server, or client.

Call Clear() in two places: ReturnToLobby() and store.OnDisconnected. Stale mappings after a disconnect cause events to fire against destroyed objects.

GameEventBridge

GameEventBridge subscribes to /game namespace events and routes them to Mirror objects via GameIdentityRegistry:

public class GameEventBridge : MonoBehaviour
{
    private Action<string> _scoreHandler;
    private Action<string> _killHandler;

    public void Subscribe()
    {
        var game = lobbyNetworkManager.Socket.Of("/game");

        _scoreHandler = (string json) =>
        {
            var obj = JObject.Parse(json);
            string playerId = obj["playerId"]?.ToString();
            int score = obj.Value<int>("score");

            var identity = GameIdentityRegistry.GetNetworkObject(playerId);
            if (identity != null)
                identity.GetComponent<PlayerScore>()?.SetScore(score);
        };

        _killHandler = (string json) =>
        {
            var obj = JObject.Parse(json);
            string victimId = obj["victimId"]?.ToString();

            var identity = GameIdentityRegistry.GetNetworkObject(victimId);
            if (identity != null)
                identity.GetComponent<PlayerHealth>()?.Die();
        };

        game.On("score_update", _scoreHandler);
        game.On("player_killed", _killHandler);
    }

    public void Cleanup()
    {
        var game = lobbyNetworkManager.Socket.Of("/game");
        game.Off("score_update", _scoreHandler);
        game.Off("player_killed", _killHandler);
    }

    void OnDestroy() => Cleanup();
}
Enter fullscreen mode Exit fullscreen mode

Critical: never call Subscribe() in Start(). The socket may not be initialized yet. Call it from MirrorGameOrchestrator.HandleMatchStarted(), which is guaranteed to run after the socket is fully connected.

Always cache handler references and call Off() in Cleanup(). The event registry holds delegate references — failing to unsubscribe causes callbacks to fire against destroyed MonoBehaviours.

MirrorGameOrchestrator

MirrorGameOrchestrator listens for match_started and coordinates the startup sequence:

public class MirrorGameOrchestrator : MonoBehaviour
{
    [SerializeField] private LobbyStateStore store;
    [SerializeField] private LobbyNetworkManager lobbyNetworkManager;
    [SerializeField] private NetworkManager mirrorNetworkManager;
    [SerializeField] private GameEventBridge gameEventBridge;
    [SerializeField] private ServerMode serverMode;
    [SerializeField] private GameObject lobbyLayer;
    [SerializeField] private GameObject gameLayer;

    private bool _inGame;

    void Start() => store.OnMatchStarted += HandleMatchStarted;

    private void HandleMatchStarted(string sceneName, string hostAddress,
                                     int? kcpPort, int? wsPort)
    {
        if (_inGame) return; // dual guard against duplicate events
        if (NetworkClient.active || NetworkServer.active) return;
        _inGame = true;

        gameEventBridge.Subscribe(); // must happen before StartHost/Client

        lobbyLayer.SetActive(false);
        gameLayer.SetActive(true);

        switch (serverMode)
        {
            case ServerMode.PeerToPeer:
                // Host starts Mirror, others connect to host's LAN IP
                if (store.IsHost)
                    mirrorNetworkManager.StartHost();
                else
                    mirrorNetworkManager.networkAddress = hostAddress;
                    mirrorNetworkManager.StartClient();
                break;

            case ServerMode.DedicatedKCP:
                // All clients connect to dedicated server
                SetKcpPort(kcpPort);
                mirrorNetworkManager.networkAddress = hostAddress;
                mirrorNetworkManager.StartClient();
                break;

            case ServerMode.DedicatedWebSocket:
                SetWsPort(wsPort);
                mirrorNetworkManager.networkAddress = hostAddress;
                mirrorNetworkManager.StartClient();
                break;
        }
    }

    public void ReturnToLobby()
    {
        if (NetworkServer.active) mirrorNetworkManager.StopHost();
        else mirrorNetworkManager.StopClient();

        gameEventBridge.Cleanup();
        GameIdentityRegistry.Clear();
        lobbyNetworkManager.LeaveRoom();

        gameLayer.SetActive(false);
        lobbyLayer.SetActive(true);
        _inGame = false;
    }
}
Enter fullscreen mode Exit fullscreen mode

The _inGame flag plus the Mirror state check is a dual guard against duplicate match_started events — the server can broadcast twice if a reconnect happens mid-handshake.

GameLayer must be inactive at scene start. If it's active when Play begins, NetworkManager.Awake() runs before the orchestrator can deactivate it, initialising Mirror prematurely.

ServerMode

MirrorGameOrchestrator exposes a ServerMode enum as an inspector dropdown. Switch between modes without changing code:

Mode Who hosts Mirror Use case
PeerToPeer Room creator runs StartHost(), others connect to LAN IP Local / LAN testing
DedicatedKCP All clients connect to hostAddress:kcpPort Dedicated server, native builds (UDP)
DedicatedWebSocket All clients connect to hostAddress:wsPort Dedicated server, WebGL builds

For dedicated server mode, set MIRROR_SERVER_ADDRESS, MIRROR_KCP_PORT, and MIRROR_WS_PORT as environment variables on your lobby server. The server injects them into every match_started broadcast automatically.

PlayerIdentityBridge

PlayerIdentityBridge runs on the Mirror player prefab and registers the netId ↔ playerId mapping when the player spawns:

public class PlayerIdentityBridge : NetworkBehaviour
{
    [SyncVar(hook = nameof(OnDisplayNameChanged))]
    private string _displayName;

    [SerializeField] private TMP_Text nameLabel;

    public override void OnStartLocalPlayer()
    {
        var store = FindObjectOfType<LobbyStateStore>();
        CmdRegisterIdentity(store.LocalPlayerId);

        string name = store.CurrentRoom?.players
            .FirstOrDefault(p => p.id == store.LocalPlayerId)?.name
            ?? store.LocalPlayerId;
        CmdSetDisplayName(name);
    }

    [Command]
    private void CmdRegisterIdentity(string playerId)
    {
        GameIdentityRegistry.Register(netIdentity.netId, playerId);
        // Sync to all clients
        RpcRegisterIdentity(netIdentity.netId, playerId);
    }

    [ClientRpc]
    private void RpcRegisterIdentity(uint netId, string playerId)
    {
        GameIdentityRegistry.Register(netId, playerId);
    }

    [Command]
    private void CmdSetDisplayName(string name)
    {
        _displayName = name;
        OnDisplayNameChanged("", name); // SyncVar hook doesn't fire on host
    }

    private void OnDisplayNameChanged(string _, string newName)
    {
        if (nameLabel != null) nameLabel.text = newName;
    }
}
Enter fullscreen mode Exit fullscreen mode

Note the manual hook call in CmdSetDisplayName — Mirror SyncVar hooks do not fire on the host when the value is set on the server. This is a common gotcha.

Common Pitfalls

Starting Mirror before match_started — always start Mirror inside HandleMatchStarted. Calling StartHost() from a button before the backend confirms creates an orphaned Mirror session.

Using Mirror [Command] for game validation[Command] goes to the Mirror host, which is a client and can be spoofed. Route all validation through Socket.IO. The backend emits the result; Mirror executes the visual effect.

Double-spawning when migrating from PlayerSync — if your project previously handled player_join via Socket.IO, disable those handlers during the Mirror game phase. Mirror owns all in-scene player lifecycle.

StartClient() failure leaving the player stranded — wire OnClientDisconnect to call ReturnToLobby() so players return to the lobby instead of seeing a blank screen.

The Working Sample

The socketio-unity repo ships a full Mirror Integration sample — lobby → match transition, WASD movement synced via NetworkTransform, lobby display name above each player, graceful shutdown, and a Node.js test server with HTTP endpoints to fire game events from a browser while Unity runs.

Import via Package Manager → Samples → "Mirror Integration".

cd path/to/socketio-unity-mirror-server
npm install
npm run start:mirror
Enter fullscreen mode Exit fullscreen mode

MIT licensed. Zero paid dependencies.

Live WebGL demo: magithar.github.io/socketio-unity/


Have you built a hybrid architecture like this before? What was the hardest part — the boundary between systems, the teardown order, or something else entirely?

Top comments (0)