【Unity】NGO + Relay + Lobby: Playing With a Friend Over the Internet

Created: 2026-07-16

Netcode for GameObjects syncs fine locally — but it won't connect to your friend's house. The wall is called NAT, and Unity's Relay and Lobby are the tools that climb it. Covers UGS initialization and anonymous sign-in, the host's Relay allocation and join code, client joining, a room your friend enters with a short code, heartbeats, and disconnect cleanup — the smallest online co-op session that actually reaches a friend.

The moment two windows move in sync in the Netcode for GameObjects intro is magical. Then reality arrives: "how do I connect this to my friend's house?" You send them your IP. Nothing connects. Code that runs perfectly on localhost goes silent in front of the internet.

The cause isn't your code — it's network structure. This article uses Unity Gaming Services (UGS) Relay (traffic relaying) and Lobby (a room with a short code) to build the smallest online co-op session that works without touching anyone's router.

Two players connecting across the internet

What you'll learn

  • Why it won't connect over the internet when localhost works (NAT)
  • The Lobby / Relay / NGO division of labor
  • UGS initialization and anonymous sign-in
  • The host's Relay allocation and join code; the client's join
  • A room your friend enters with a short lobby code, plus heartbeats
  • Hands-on: a two-player co-op room behind one code
  • Cleanup when someone disconnects

Tested with: Unity 6 (6000.0), Netcode for GameObjects 2.x, Relay 1.1+, Lobby 1.2+ (as of 2026-07. UGS APIs evolve quickly — check each package's official docs if something doesn't compile)

Sponsored

Why the internet says no

Localhost (127.0.0.1) and LAN connections are conversations inside the same house. Across the internet, though, both your home router and your friend's — each running NAT — stand in the way.

NAT lets traffic out, but blocks unsolicited connections coming in as intruders. An NGO host listens for connections like a server, so your friend's client gets stopped cold at your router. That's the whole story behind "I sent my IP and nothing happened."

Direct connection vs. Relay: the direct attempt is blocked by the other side's router (NAT), while with Relay both sides connect outward to Unity's relay server and both routers allow it

The answer is a relay. Both host and client connect outward, on their own initiative, to a relay server operated by Unity. Outbound traffic passes NAT freely, so no router configuration is needed on either side. All traffic then flows through the relay. No port forwarding favors to ask, no static IPs.

Three tools, three jobs

Three similarly-named tools appear in an online session. Pin their roles down first.

Lobby, Relay, and NGO division of labor: Lobby manages the room listing and its code, Relay carries traffic through NAT, and NGO synchronizes gameplay — connections proceed in that order
ToolJobThink of it as
LobbyManage room info and its passwordThe venue's front desk
RelayCarry traffic through NATThe telephone exchange
NGOSync positions and state in-gameThe conversation itself

The flow: the host reserves a Relay slot → pins its ticket (join code) to a Lobby room → the client enters the room with the room code, picks up the ticket, connects to Relay → NGO syncs exactly as it always has. Your NGO code — NetworkVariables, RPCs — doesn't change by one character. Only the way you connect changes.

Setup: UGS init and anonymous sign-in

Add the Relay and Lobby packages via the Package Manager, then link the project to UGS under Project Settings > Services (you'll need a Unity project on the dashboard, with Relay and Lobby enabled).

The first code job is initializing UGS and signing in. All UGS APIs use async/await — skim coroutines vs. async/await first if that's fuzzy.

using Unity.Services.Authentication;
using Unity.Services.Core;
using UnityEngine;

public static class UgsBootstrap
{
    public static async System.Threading.Tasks.Task EnsureSignedInAsync()
    {
        // 1. Initialize UGS once after startup
        if (UnityServices.State != ServicesInitializationState.Initialized)
        {
            await UnityServices.InitializeAsync();
        }

        // 2. Anonymous sign-in (a player ID with no account registration)
        if (!AuthenticationService.Instance.IsSignedIn)
        {
            await AuthenticationService.Instance.SignInAnonymouslyAsync();
            Debug.Log("Signed in: " + AuthenticationService.Instance.PlayerId);
        }
    }
}

Anonymous sign-in issues an on-the-spot ID with no email or password. Relay and Lobby both require it to identify who is making requests. For indie online features, starting anonymous is the norm.

Sponsored

Host: allocate Relay, mint a join code

The host does two things: reserve a relay slot (allocation) and mint its join code.

using Unity.Netcode;
using Unity.Netcode.Transports.UTP;
using Unity.Networking.Transport.Relay;
using Unity.Services.Relay;
using Unity.Services.Relay.Models;

public static async System.Threading.Tasks.Task<string> StartHostWithRelayAsync(int maxClients)
{
    // 1. Ask Relay for a slot that can carry up to N clients
    Allocation allocation = await RelayService.Instance.CreateAllocationAsync(maxClients);

    // 2. Mint the entry ticket (join code) for that slot
    string joinCode = await RelayService.Instance.GetJoinCodeAsync(allocation.AllocationId);

    // 3. Tell NGO's transport to route through Relay instead of connecting directly
    var transport = NetworkManager.Singleton.GetComponent<UnityTransport>();
    transport.SetRelayServerData(new RelayServerData(allocation, "dtls"));

    // 4. Start the host as usual
    NetworkManager.Singleton.StartHost();
    return joinCode;
}

maxClients excludes the host (1 for two-player co-op). "dtls" selects encrypted transport — leave it unless you have a reason not to. The returned joinCode is about six alphanumerics, and only someone holding it can enter this relay slot.

Client: join with the code

The client simply joins the same slot with the code it was given.

public static async System.Threading.Tasks.Task JoinWithRelayAsync(string joinCode)
{
    // 1. Join the slot the host reserved, using the join code
    JoinAllocation joinAllocation = await RelayService.Instance.JoinAllocationAsync(joinCode);

    // 2. Hand the transport the client-side relay settings
    var transport = NetworkManager.Singleton.GetComponent<UnityTransport>();
    transport.SetRelayServerData(new RelayServerData(joinAllocation, "dtls"));

    // 3. Start the client as usual
    NetworkManager.Singleton.StartClient();
}
Sequence from host creation to client join: the host creates a Relay allocation and receives a join code; the client, given the code, joins Relay, and all traffic then flows through the relay server

At this point, honestly, you can already play with a friend by pasting the join code into Discord. But asking people to copy six characters every session is clunky. So we hand room management to Lobby.

Lobby: turning the join code into a room password

Lobby is a database of rooms. A room has a name, capacity, arbitrary data — and a lobby code players can join with. Create a lobby that carries the Relay join code in its data, and players only ever share the lobby code.

Lobby has one idiosyncrasy: the heartbeat. To keep abandoned rooms from piling up, rooms that stop pinging get auto-deleted. While the room lives, the host must keep saying "still here" (every 15-20 seconds is typical).

Hands-on: a two-player co-op room behind one code

Time to assemble the parts into one manager. Press "Create room" and a code appears on screen; your friend types it in and walks into your game — the skeleton of every online co-op.

Room-by-password scene: the host's screen shows the code, a distant friend types it in, and both characters stand in the same game world
// OnlineSession.cs — one per scene (same object as the NetworkManager is fine)
using System.Collections;
using Unity.Netcode;
using Unity.Netcode.Transports.UTP;
using Unity.Networking.Transport.Relay;
using Unity.Services.Lobbies;
using Unity.Services.Lobbies.Models;
using Unity.Services.Relay;
using Unity.Services.Relay.Models;
using UnityEngine;

public class OnlineSession : MonoBehaviour
{
    public string LobbyCode { get; private set; } // the code you show in the UI

    private Lobby currentLobby;

    // ---- Host: create the room ----
    public async void CreateRoom()
    {
        await UgsBootstrap.EnsureSignedInAsync();

        // Reserve a Relay slot and get its join code (co-op for two = 1 client)
        Allocation allocation = await RelayService.Instance.CreateAllocationAsync(1);
        string relayJoinCode = await RelayService.Instance.GetJoinCodeAsync(allocation.AllocationId);

        // Create a Lobby that carries the Relay join code in its data
        var options = new CreateLobbyOptions
        {
            Data = new System.Collections.Generic.Dictionary<string, DataObject>
            {
                { "relayJoinCode", new DataObject(DataObject.VisibilityOptions.Member, relayJoinCode) }
            }
        };
        currentLobby = await LobbyService.Instance.CreateLobbyAsync("CoopRoom", 2, options);
        LobbyCode = currentLobby.LobbyCode; // ← this is what you send your friend

        // Keep the room alive with heartbeats
        StartCoroutine(HeartbeatLoop());

        // Start hosting through Relay
        var transport = NetworkManager.Singleton.GetComponent<UnityTransport>();
        transport.SetRelayServerData(new RelayServerData(allocation, "dtls"));
        NetworkManager.Singleton.StartHost();

        Debug.Log("Room created. Code: " + LobbyCode);
    }

    // ---- Client: enter with the code ----
    public async void JoinRoom(string lobbyCode)
    {
        await UgsBootstrap.EnsureSignedInAsync();

        // Enter the room by code, pick up the stored Relay join code
        currentLobby = await LobbyService.Instance.JoinLobbyByCodeAsync(lobbyCode);
        string relayJoinCode = currentLobby.Data["relayJoinCode"].Value;

        // Join Relay and start the client
        JoinAllocation joinAllocation = await RelayService.Instance.JoinAllocationAsync(relayJoinCode);
        var transport = NetworkManager.Singleton.GetComponent<UnityTransport>();
        transport.SetRelayServerData(new RelayServerData(joinAllocation, "dtls"));
        NetworkManager.Singleton.StartClient();
    }

    // ---- Host: keep the room alive ----
    IEnumerator HeartbeatLoop()
    {
        while (currentLobby != null)
        {
            LobbyService.Instance.SendHeartbeatPingAsync(currentLobby.Id);
            yield return new WaitForSeconds(15f);
        }
    }

    // ---- Cleanup (used in the next section) ----
    public async void LeaveRoom()
    {
        if (currentLobby != null)
        {
            try
            {
                if (currentLobby.HostId == Unity.Services.Authentication.AuthenticationService.Instance.PlayerId)
                {
                    await LobbyService.Instance.DeleteLobbyAsync(currentLobby.Id); // hosts fold the room
                }
                else
                {
                    await LobbyService.Instance.RemovePlayerAsync(
                        currentLobby.Id,
                        Unity.Services.Authentication.AuthenticationService.Instance.PlayerId);
                }
            }
            catch (LobbyServiceException e)
            {
                Debug.LogWarning("Lobby cleanup failed: " + e.Message);
            }
            currentLobby = null;
        }

        if (NetworkManager.Singleton.IsListening)
        {
            NetworkManager.Singleton.Shutdown();
        }
    }
}

The UI can be minimal: a "Create room" button wired to CreateRoom, a code input field plus "Join" wired to JoinRoom, and a Text showing LobbyCode.

Test by running a build next to the Editor. But the real verification is handing the build to a friend on a different network — send the six characters from your screen, and when their character appears in your world, you've beaten NAT. If JoinLobbyByCodeAsync throws, it's almost always a typo (lobby codes are case-sensitive) or a room that expired from missed heartbeats.

Two takeaways. Not one character of your NGO sync code changed (only the connection entrance did). Players only ever see the lobby code (the Relay join code hides inside the room's data).

Disconnect cleanup

Online reality is less about connecting and more about after it breaks. When your friend's connection drops, a silently frozen character is the worst version of that moment. Detect it with NGO's disconnect callback and clean up visibly.

void OnEnable()
{
    NetworkManager.Singleton.OnClientDisconnectCallback += HandleDisconnect;
}

void OnDisable()
{
    if (NetworkManager.Singleton != null)
    {
        NetworkManager.Singleton.OnClientDisconnectCallback -= HandleDisconnect;
    }
}

void HandleDisconnect(ulong clientId)
{
    if (!NetworkManager.Singleton.IsHost)
    {
        // We're a client and this fired = we lost the host
        Debug.Log("Lost connection to the host. Returning to title");
        LeaveRoom();
        // Load the title scene and show a message here
    }
    else
    {
        // As host, a participant left
        Debug.Log("Player left: " + clientId);
        // Show "your friend left the game" etc.
    }
}

The minimum rules: a client treats host loss as session over and returns to the title immediately (the NGO host doubles as the server — there's nothing to continue). The host announces the departure and keeps the room open for the next player. And when you leave, use LeaveRoom to fold the lobby before shutting down — skip that and a ghost room lingers until its heartbeats lapse.

Bonus: things worth knowing early

  • Pricing and the free tier: Relay and Lobby bill by concurrent usage, with free tiers that comfortably cover development and friend tests. Check the official pricing page before releasing — numbers change, so this article deliberately omits them
  • Regions: Relay allocations can specify a region. Unspecified, UGS picks one; for players in the same country, explicitly choosing a nearby region can cut latency
  • The Unity 6 path: the Unity 6 era ships a Multiplayer Services (Sessions API) package that bundles the Lobby + Relay + NGO wiring into one call. Understanding this article's bare wiring tells you exactly what Sessions automates for you
  • What's next: when movement jitter starts to bother you, that's the world of interpolation, prediction, and lag compensation. Start with NGO's NetworkTransform interpolation settings

Summary

  • The internet says no because NAT blocks unsolicited inbound connections; Relay solves it by having both sides connect outward
  • Three tools: Lobby = the front desk, Relay = the exchange, NGO = the conversation. Your sync code doesn't change
  • Host: allocation → join code → SetRelayServerData → StartHost. Client: join by code → StartClient
  • Lobby hides the join code in room data; players share only the room code. Hosts keep the heartbeat going
  • It's done when disconnects are handled: clients return to title, hosts announce and continue, and leavers fold the room

The first time a character controlled from another house walks into your screen, it feels nothing like two local windows. So — who gets your room code this weekend?