Building a multiplayer lobby is where most Unity real-time projects fall apart.
Not the networking part — that's the easy bit. The hard part is everything around it: players joining and leaving mid-session, the host disconnecting and taking the room with them, a player's phone screen locking for 30 seconds and losing their slot forever. Most tutorials stop at "connected". This one doesn't.
This is a complete walkthrough of building a production-ready lobby in Unity using socketio-unity — room creation, join by code, ready states, host migration, and reconnect with a grace window. Every line of code is taken from the real Samples~/Lobby/ sample in the repo, so you can run it locally and pull it apart.
Prerequisites
- Unity 2020.1+
- Node.js (for the test server)
- socketio-unity installed via
Window → Package Manager → Add package from git URL:
https://github.com/Magithar/socketio-unity.git
Architecture Overview
Before writing a line of code, the architecture matters. A lobby has three distinct concerns — networking, state, and UI — and mixing them is how you end up with spaghetti that breaks every time the server sends an unexpected event.
The sample uses a clean three-layer split:
LobbyNetworkManager ← Transport: connects, emits, receives
↓ writes
LobbyStateStore ← State: single source of truth, fires C# events
↓ events
LobbyUIController ← View: subscribes to events, drives UI
The UI never touches the socket directly. The network layer never knows about GameObjects. When something breaks — and in multiplayer, something always breaks — you know exactly which layer to look at.
Step 1 — Connect to the /lobby Namespace
Create a LobbyNetworkManager MonoBehaviour. Use a namespace socket (_root.Of("/lobby")) rather than the root socket — this lets the server scope events cleanly per feature without event name collisions.
private SocketIOClient _root;
private NamespaceSocket _lobby;
private void Start()
{
_root = new SocketIOClient(TransportFactoryHelper.CreateDefault());
_root.ReconnectConfig = new ReconnectConfig { autoReconnect = false };
_lobby = _root.Of("/lobby");
_root.Connect("http://localhost:3001");
}
Set autoReconnect = false so you control reconnect logic — this is essential for restoring sessions with saved credentials rather than connecting as a fresh player.
Step 2 — Define Your Data Models
The server sends JSON snapshots of the room state. Two things are critical here:
[Preserve] prevents IL2CPP from stripping fields that are only referenced via JSON deserialization — without this, WebGL builds will silently lose data. [JsonProperty] ensures the fields survive minification.
[Serializable, Preserve]
public class RoomState
{
[Preserve, JsonProperty("roomId")] public string roomId;
[Preserve, JsonProperty("hostId")] public string hostId;
[Preserve, JsonProperty("version")] public int version;
[Preserve, JsonProperty("players")] public List<LobbyPlayer> players;
}
[Serializable, Preserve]
public class LobbyPlayer
{
[Preserve, JsonProperty("id")] public string id;
[Preserve, JsonProperty("name")] public string name;
[Preserve, JsonProperty("ready")] public bool ready;
[Preserve, JsonProperty("status")] public string status; // "connected" | "disconnected"
}
Step 3 — Build the State Store
LobbyStateStore holds authoritative state and exposes C# events. The UI subscribes here — never to the socket directly.
public class LobbyStateStore : MonoBehaviour
{
public RoomState CurrentRoom { get; private set; }
public string LocalPlayerId { get; private set; }
public string SessionToken { get; private set; }
public bool IsHost => CurrentRoom != null && CurrentRoom.hostId == LocalPlayerId;
public event Action OnConnected;
public event Action OnDisconnected;
public event Action<RoomState> OnRoomStateChanged;
public event Action<LobbyPlayer> OnPlayerJoined;
public event Action<string> OnPlayerLeft; // playerId
public event Action<string, string, string> OnPlayerRemoved; // id, name, reason
public event Action<SocketError> OnError;
public event Action<string> OnMatchStarted; // sceneName
private int _lastRoomVersion;
public void ApplyRoomState(RoomState newState)
{
if (newState == null) return;
if (newState.version > 0 && newState.version <= _lastRoomVersion) return; // dedupe
_lastRoomVersion = newState.version;
DiffAndFirePlayerEvents(CurrentRoom, newState);
CurrentRoom = newState;
OnRoomStateChanged?.Invoke(CurrentRoom);
}
private void DiffAndFirePlayerEvents(RoomState old, RoomState next)
{
if (old == null) return;
var oldIds = new HashSet<string>(old.players.Select(p => p.id));
var newIds = new HashSet<string>(next.players.Select(p => p.id));
foreach (var p in next.players)
if (!oldIds.Contains(p.id)) OnPlayerJoined?.Invoke(p);
foreach (var p in old.players)
if (!newIds.Contains(p.id)) OnPlayerLeft?.Invoke(p.id);
}
public void Reset()
{
CurrentRoom = null; LocalPlayerId = null; SessionToken = null; _lastRoomVersion = 0;
}
}
The version check is not optional. During reconnect, the server broadcasts the current room snapshot to the rejoining client — which means the same state can arrive twice. Without the version guard, your UI fires double join/leave events and gets out of sync.
DiffAndFirePlayerEvents lives on LobbyStateStore, called by ApplyRoomState — not on the UI controller. Keep all player diffing logic inside the store.
Step 4 — Wire Up Incoming Events
Subscribe to the four server events in LobbyNetworkManager:
// Server identifies you before room_state arrives
_lobby.On("player_identity", (string json) =>
{
var obj = JObject.Parse(json);
store.SetLocalPlayerId(obj.Value<string>("playerId"));
store.SetSessionToken(obj.Value<string>("sessionToken"));
});
// Authoritative full snapshot
_lobby.On("room_state", (string json) =>
{
var state = JsonConvert.DeserializeObject<RoomState>(json);
store.ApplyRoomState(state);
});
// Host started the match
_lobby.On("match_started", (string json) =>
{
string scene = JObject.Parse(json)["sceneName"]?.ToString();
store.FireMatchStarted(scene);
});
// Player permanently removed (left or reconnect window expired)
_lobby.On("player_removed", (string json) =>
{
var obj = JObject.Parse(json);
store.FirePlayerRemoved(
obj["playerId"]?.ToString(),
obj["name"]?.ToString(),
obj["reason"]?.ToString()
);
});
Identity ordering matters: the server sends player_identity before the ACK and before room_state. By the time ApplyRoomState fires, IsHost evaluates correctly.
Step 5 — Emit Lobby Actions
Use ACK callbacks for create_room and join_room — they confirm success and return credentials:
Note on
ParseAck: The realLobbyNetworkManager.cshandles three response shapes — raw string, JSON array, and JSON object. The snippets below use a simplified version for readability. For production use, copy the fullParseAckimplementation directly from the sample to avoid silent failures on the array case.
public void CreateRoom(string playerName)
{
_lobby.Emit("create_room", new { name = playerName }, ack =>
{
var result = ParseAck(ack);
if (result?.Value<bool>("ok") == true)
{
store.SetLocalPlayerId(result.Value<string>("playerId"));
store.SetSessionToken(result.Value<string>("sessionToken"));
}
});
}
public void JoinRoom(string roomId, string playerName)
{
_lobby.Emit("join_room", new { roomId = roomId.ToUpper(), name = playerName }, ack =>
{
var result = ParseAck(ack);
if (result?.Value<bool>("ok") != true)
store.FireError(new SocketError(ErrorType.Auth, result?.Value<string>("error")));
});
}
public void SetReady(bool ready) => _lobby.Emit("player_ready", new { ready });
public void StartMatch(string scene) => _lobby.Emit("start_match", new { sceneName = scene });
public void LeaveRoom()
{
_lobby.Emit("leave_room", new { }, _ => store.Reset());
}
Step 6 — Build the Player List UI
Instantiate one prefab row per player. Update without full rebuilds by diffing — recreating all rows on every state update causes flicker and loses UI state:
private readonly Dictionary<string, GameObject> _playerRows = new();
private void HandlePlayerJoined(LobbyPlayer player)
{
var row = Instantiate(playerRowPrefab, playerListContent);
_playerRows[player.id] = row;
UpdateRow(row, player);
}
private void HandlePlayerLeft(string playerId)
{
if (_playerRows.TryGetValue(playerId, out var row)) Destroy(row);
_playerRows.Remove(playerId);
}
// Called on every room_state to reconcile the authoritative list
private void RefreshPlayerRows(RoomState state)
{
var stateIds = new HashSet<string>(state.players.Select(p => p.id));
foreach (var id in _playerRows.Keys.Except(stateIds).ToList())
{
Destroy(_playerRows[id]);
_playerRows.Remove(id);
}
foreach (var player in state.players)
{
if (!_playerRows.TryGetValue(player.id, out var row))
row = _playerRows[player.id] = Instantiate(playerRowPrefab, playerListContent);
UpdateRow(row, player);
}
}
private void UpdateRow(GameObject row, LobbyPlayer player)
{
bool disconnected = player.status == "disconnected";
var nameText = row.transform.Find("NameText").GetComponent<TextMeshProUGUI>();
nameText.text = player.id == store.CurrentRoom?.hostId
? $"{player.name} [Host]{(disconnected ? " (Reconnecting...)" : "")}"
: $"{player.name}{(disconnected ? " (Reconnecting...)" : "")}";
nameText.color = disconnected ? Color.gray : Color.white;
var icon = row.transform.Find("ReadyIcon").GetComponent<Image>();
icon.color = disconnected ? Color.yellow : (player.ready ? Color.green : Color.gray);
}
Step 7 — Handle Disconnects and Session Restore
This is where most lobby implementations give up. The server holds a player's slot for 10 seconds after disconnect — their row stays visible with status = "disconnected". On reconnect, send saved credentials to reclaim the slot:
private const string PREF_ROOM_ID = "Lobby_LastRoomId";
private const string PREF_PLAYER_ID = "Lobby_PlayerId";
private const string PREF_SESSION_TOKEN = "Lobby_SessionToken";
// Save on every room_state
PlayerPrefs.SetString(PREF_ROOM_ID, state.roomId);
PlayerPrefs.SetString(PREF_PLAYER_ID, store.LocalPlayerId);
PlayerPrefs.SetString(PREF_SESSION_TOKEN, store.SessionToken);
// On reconnect — try to restore
private void HandleConnected()
{
string pid = PlayerPrefs.GetString(PREF_PLAYER_ID, "");
string room = PlayerPrefs.GetString(PREF_ROOM_ID, "");
string token = PlayerPrefs.GetString(PREF_SESSION_TOKEN, "");
if (!string.IsNullOrEmpty(pid) && !string.IsNullOrEmpty(token))
{
networkManager.ReconnectSession(pid, room, token);
StartCoroutine(RejoinTimeout(5f)); // give up after 5 sec
}
}
public void ReconnectSession(string playerId, string roomId, string sessionToken)
{
_lobby.Emit("reconnect_player", new { playerId, roomId, sessionToken }, ack =>
{
var result = ParseAck(ack);
if (result?.Value<bool>("ok") != true)
{
// Room expired — clear credentials and return to lobby selection
PlayerPrefs.DeleteKey(PREF_ROOM_ID);
store.FireError(new SocketError(ErrorType.Auth, result?.Value<string>("error")));
}
});
}
The 5-second rejoin timeout is important. If the room expired while the player was disconnected, you don't want to hang on a reconnect attempt forever — clear the stale credentials and return them to the lobby selection screen.
Step 8 — Start the Match
Only the host sees the Start button. On match_started, all clients load the scene:
// In Update() — simpler than event-driven for a toggle
startMatchButton.gameObject.SetActive(store.IsHost);
private void HandleMatchStarted(string sceneName)
{
if (!string.IsNullOrEmpty(sceneName))
SceneManager.LoadScene(sceneName);
}
Scene Setup
Canvas
├── LobbySelectionPanel
│ ├── PlayerNameInput (TMP_InputField)
│ ├── CreateRoomButton
│ ├── JoinRoomCodeInput (TMP_InputField)
│ └── JoinRoomButton
├── RoomPanel
│ ├── RoomCodeText (TextMeshProUGUI)
│ ├── CopyRoomCodeButton
│ ├── LeaveRoomButton
│ ├── ReadyButton
│ ├── StartMatchButton ← toggled by IsHost
│ ├── PlayerList / Content ← Vertical Layout Group
│ └── ReconnectPanel (overlay)
└── ConnectionStatusText
Assign LobbyNetworkManager and LobbyStateStore to LobbyUIController in the Inspector. Both should be on a DontDestroyOnLoad GameObject if you carry them across scenes.
Key Patterns
| Pattern | Why |
|---|---|
room_state.version check |
Ignores duplicate snapshots during reconnect |
player_identity before ACK |
Ensures IsHost is correct by the first room_state
|
_joinInFlight guard |
Prevents double-emit during reconnect sequence |
PlayerPrefs session storage |
Survives app backgrounding, not just network blips |
| 5-second rejoin timeout | Clears stale credentials if the room already expired |
Running the Demo
The Samples~/Lobby/ sample includes a full working server:
cd TestServer~
npm install
npm run start:lobby # or: npm run dev:lobby (auto-restart via nodemon)
The server lives at TestServer~/lobby-server.js. The package.json at TestServer~/package.json already declares express and socket.io as dependencies — no manual installs needed.
Then open the Unity scene and press Play. The sample is the fastest way to see all of this in action before adapting it to your own project.
The Repo
github.com/Magithar/socketio-unity
MIT licensed. Socket.IO v4 only. WebGL verified. Zero paid dependencies.
What's the hardest part of multiplayer lobbies you've had to solve? Host migration, latency compensation, or something else entirely? Drop it in the comments.
Top comments (0)