Quick answer: Creating a lobby and establishing a network connection are two separate steps. The Lobby service handles matchmaking and metadata only.
Here is how to fix Unity multiplayer lobby not connecting. You installed the Unity Lobby and Relay packages, created a lobby, and another player joined it — but no actual network connection is established. Players can see the lobby metadata but cannot send or receive gameplay data. The lobby exists, players appear in it, yet NetworkManager.StartHost() or StartClient() times out or silently fails. This is one of the most common Unity multiplayer pitfalls because the Lobby service and the actual network transport are completely separate systems that must be manually bridged.
The Symptom
You create a lobby using the Unity Lobby SDK. The host calls LobbyService.Instance.CreateLobbyAsync() and gets a lobby object back with a valid lobby ID. Another player queries for lobbies, finds yours, and joins it with JoinLobbyByIdAsync(). Both players can see each other in the lobby’s player list. So far, everything looks correct.
But when the host starts the Netcode for GameObjects NetworkManager as a host and the client starts as a client, no connection is made. The client’s OnClientConnectedCallback never fires. The host’s OnClientConnectedCallback never fires for the remote player. After several seconds, the connection attempt times out.
In some cases, you see an error in the console about a failed transport connection or an invalid relay allocation. In other cases, there is no error at all — just silence and a connection that never completes. The lobby works, the players are matched, but the game network never forms.
This is especially confusing for developers coming from other multiplayer frameworks where matchmaking and connection are a single step. In Unity’s architecture, the Lobby service is purely a metadata and matchmaking layer. You still need Relay for NAT traversal and a transport layer to move packets.
What Causes This
1. Unity Services or Authentication not initialized. Both Lobby and Relay require the player to be authenticated. If you skip calling UnityServices.InitializeAsync() and AuthenticationService.Instance.SignInAnonymouslyAsync(), all Lobby and Relay API calls will throw exceptions. Some developers catch these exceptions broadly and silently swallow them, making it appear as though the calls succeeded when they did not.
2. No Relay allocation. The Lobby service does not create any network infrastructure. It is a REST API for listing, creating, and joining lobbies — a matchmaking directory, not a game server. To actually connect players, the host must allocate a Relay server using RelayService.Instance.CreateAllocationAsync(), obtain a join code, and share that code with the other players through the lobby’s data fields. Without this step, there is no server for clients to connect to.
3. Join code not stored in or read from lobby data. After the host creates a Relay allocation and gets a join code, that code must be transmitted to the joining players. The standard pattern is to store it in the lobby’s data dictionary using UpdateLobbyAsync(). If the host skips this update, or the client does not read the join code from the lobby data, the client has no way to find the Relay server.
4. Transport layer not configured with Relay server data. Even with a valid Relay allocation on both sides, the Unity Transport (UTP) component must be configured to route traffic through the Relay. If you start the NetworkManager without setting RelayServerData on the UnityTransport component, the transport attempts a direct connection that fails due to NAT.
The Fix
Step 1: Initialize Unity Services and sign in. This must happen before any Lobby or Relay calls. Do it once at game startup.
using Unity.Services.Core;
using Unity.Services.Authentication;
using UnityEngine;
public class ServicesInitializer : MonoBehaviour
{
private async void Awake()
{
try
{
await UnityServices.InitializeAsync();
Debug.Log("Unity Services initialized.");
if (!AuthenticationService.Instance.IsSignedIn)
{
await AuthenticationService.Instance.SignInAnonymouslyAsync();
Debug.Log($"Signed in. Player ID: {AuthenticationService.Instance.PlayerId}");
}
}
catch (System.Exception e)
{
Debug.LogError($"Failed to initialize services: {e.Message}");
}
}
}
Make sure this MonoBehaviour runs before any script that touches Lobby or Relay. Use Script Execution Order or place it in an initialization scene. The IsSignedIn check prevents redundant sign-in calls if the player returns to the menu.
Step 2: Allocate a Relay server and store the join code in the lobby. The host does this after creating the lobby. The Relay allocation gives you server connection data, and the join code is a short string that clients use to connect to the same Relay server.
using Unity.Services.Relay;
using Unity.Services.Relay.Models;
using Unity.Services.Lobbies;
using Unity.Services.Lobbies.Models;
using System.Collections.Generic;
using System.Threading.Tasks;
using UnityEngine;
public class HostLobbyManager
{
private const string RELAY_JOIN_CODE_KEY = "relayJoinCode";
public async Task<(Lobby lobby, Allocation allocation)> CreateLobbyWithRelay(
string lobbyName, int maxPlayers)
{
// Step A: Allocate a Relay server for (maxPlayers - 1) connections
Allocation allocation = await RelayService.Instance
.CreateAllocationAsync(maxPlayers - 1);
Debug.Log($"Relay allocated. Region: {allocation.Region}");
// Step B: Get the join code for this allocation
string joinCode = await RelayService.Instance
.GetJoinCodeAsync(allocation.AllocationId);
Debug.Log($"Relay join code: {joinCode}");
// Step C: Create the lobby and store the join code in lobby data
var lobbyOptions = new CreateLobbyOptions
{
Data = new Dictionary<string, DataObject>
{
{
RELAY_JOIN_CODE_KEY,
new DataObject(
DataObject.VisibilityOptions.Member,
joinCode)
}
}
};
Lobby lobby = await LobbyService.Instance
.CreateLobbyAsync(lobbyName, maxPlayers, lobbyOptions);
Debug.Log($"Lobby created: {lobby.Id}");
return (lobby, allocation);
}
}
The visibility is set to Member so that only players who have joined the lobby can see the Relay join code. This prevents random players from connecting to your Relay server without going through the lobby.
Step 3: Configure Unity Transport with Relay server data and start the NetworkManager. Both the host and the client must set the RelayServerData on the UnityTransport component before starting. The host uses the allocation directly; the client uses the join allocation from the join code.
using Unity.Netcode;
using Unity.Netcode.Transports.UTP;
using Unity.Networking.Transport.Relay;
using Unity.Services.Relay;
using Unity.Services.Relay.Models;
using Unity.Services.Lobbies;
using UnityEngine;
using System.Threading.Tasks;
public class RelayTransportSetup : MonoBehaviour
{
private const string RELAY_JOIN_CODE_KEY = "relayJoinCode";
/// Host calls this after creating the lobby and Relay allocation
public void StartHostWithRelay(Allocation allocation)
{
var transport = NetworkManager.Singleton
.GetComponent<UnityTransport>();
// Configure UTP to route through the Relay server
var relayServerData = new RelayServerData(allocation, "dtls");
transport.SetRelayServerData(relayServerData);
NetworkManager.Singleton.StartHost();
Debug.Log("Host started with Relay transport.");
}
/// Client calls this after joining the lobby
public async Task StartClientWithRelay(string lobbyId)
{
// Read the join code from the lobby data
var lobby = await LobbyService.Instance.GetLobbyAsync(lobbyId);
string joinCode = lobby.Data[RELAY_JOIN_CODE_KEY].Value;
Debug.Log($"Retrieved join code from lobby: {joinCode}");
// Join the Relay allocation using the code
JoinAllocation joinAllocation = await RelayService.Instance
.JoinAllocationAsync(joinCode);
var transport = NetworkManager.Singleton
.GetComponent<UnityTransport>();
var relayServerData = new RelayServerData(joinAllocation, "dtls");
transport.SetRelayServerData(relayServerData);
NetworkManager.Singleton.StartClient();
Debug.Log("Client started with Relay transport.");
}
}
The "dtls" parameter specifies encrypted UDP, which is the recommended connection type for Relay. You can also use "udp" for unencrypted connections during development, but always use DTLS in production.
Why This Works
Unity’s multiplayer stack is modular by design. The Lobby service is a REST-based matchmaking directory — it stores metadata about available game sessions and which players are in them, but it does not move any gameplay packets. The Relay service provides NAT-traversal infrastructure — it allocates a server that both the host and client can reach, even if they are behind restrictive firewalls. The transport layer (UTP) is the component that actually sends and receives gameplay data.
When you configure the transport with RelayServerData, you are telling UTP to route all packets through the Relay server instead of attempting a direct connection. The Relay server acts as a middleman: the host sends packets to the Relay, and the Relay forwards them to the client, and vice versa. This solves the NAT traversal problem that causes most connection failures in peer-to-peer multiplayer games.
The join code is the bridge between the Lobby and Relay systems. It is a short alphanumeric string that uniquely identifies a Relay allocation. By storing it in the lobby’s data dictionary, you create a reliable channel for the client to discover which Relay server to connect to. Without this bridge, the client knows the lobby exists but has no way to find the Relay server.
Authentication ties everything together. Both Lobby and Relay validate that the caller is an authenticated Unity player. Anonymous authentication is sufficient for development and many production use cases. The player ID generated by anonymous auth is stable per device and project, so players retain their identity across sessions.
"The Lobby is the bulletin board. The Relay is the phone line. The transport is the conversation. You need all three, and you need to wire them together yourself."
Common Pitfalls
Lobby heartbeat timeout. Lobbies expire if the host does not send heartbeats. The default timeout is 30 seconds. If the host takes too long to set up the Relay and start the NetworkManager, the lobby may expire before the client can read the join code. Send periodic heartbeats to keep the lobby alive.
using Unity.Services.Lobbies;
using UnityEngine;
using System.Collections;
public class LobbyHeartbeat : MonoBehaviour
{
private string lobbyId;
private float heartbeatInterval = 15f;
public void Begin(string id)
{
lobbyId = id;
StartCoroutine(HeartbeatLoop());
}
private IEnumerator HeartbeatLoop()
{
while (!string.IsNullOrEmpty(lobbyId))
{
yield return new WaitForSeconds(heartbeatInterval);
await LobbyService.Instance.SendHeartbeatPingAsync(lobbyId);
Debug.Log("Lobby heartbeat sent.");
}
}
}
Rate limiting. The Lobby service has rate limits on API calls. If you poll for lobby updates too frequently (such as every frame), you will get rate-limited and your calls will fail. Poll every 2 seconds at most, and use the LobbyEventCallbacks system for real-time updates when available.
Relay region mismatch. If you do not specify a region when creating the Relay allocation, the service picks the closest one to the host. This is usually fine, but in testing scenarios where the host and client are on the same machine with different Unity editor instances, both should use the same region. You can specify a region explicitly by passing it to CreateAllocationAsync.
Related Issues
If the connection establishes but players experience high latency, the issue may be Relay region selection rather than a connection failure. Consider letting the host choose a region based on the expected player locations. If you see NetworkObject synchronization issues after connecting, those are Netcode for GameObjects problems rather than transport-level connection failures — ensure all networked prefabs are registered in the NetworkManager’s prefab list.