๐ŸŽฎ Soup to Nuts

Unity Cloud
Engineering

A hands-on masterclass building a full Unity Gaming Services proof of concept โ€” Auth, Lobby, Relay, Netcode, Cloud Save, Remote Config โ€” with every gotcha, fix, and scaling truth documented.

๐Ÿ—“ Unity 2023.2+
๐Ÿ“ฆ UGS SDK 2.x
๐ŸŒ NGO 1.9+
โšก Relay + Lobby
01

Architecture Overview

Before writing a single line of C#, you need a mental map of what Unity Cloud actually is. The umbrella brand is Unity Gaming Services (UGS). It's a suite of cloud backend services that Unity maintains so you don't have to spin up your own servers. Here's what we're building and how the pieces connect:

Player A Unity Client Player B Unity Client UGS CLOUD Authentication Player ID + Token Lobby Room Discovery Relay P2P Punch-through Cloud Save Player Persistence Remote Config Live Tuning NGO Netcode Host/Client Logic P2P via Relay Auth flow Lobby flow Relay/P2P flow
๐Ÿง  Core Insight

The mental model that unlocks everything: Lobby handles discovery (finding each other), Relay handles transport (talking to each other), and Netcode handles logic (what you say). Authentication is the passport that allows access to all three. These are separate services with separate SDKs that you glue together in your code.

The Full Flow in Words

1
Sign In (Authentication)
Both players sign in anonymously or via platform. UGS assigns each a permanent PlayerId and a short-lived JWT access token. Every subsequent API call uses this token.
2
Create / Join a Lobby
Player A creates a Lobby on UGS servers โ€” a JSON document in the cloud listing room settings and players. Player B queries for lobbies and joins by Lobby ID or a short join code.
3
Allocate a Relay Server
The Host (Player A) calls Relay to allocate a relay server slot. Relay returns a JoinCode. Host saves this JoinCode into the Lobby's custom data so all lobby members can read it.
4
Client Fetches & Joins Relay
Player B polls or subscribes to the Lobby, reads the JoinCode, and calls Relay to join. Both sides now have RelayServerData objects they can hand to Netcode.
5
Start Netcode Transport
Both sides configure UnityTransport with their Relay data and call StartHost() / StartClient(). All game traffic now flows through the Relay DTLS tunnel โ€” no IP exposure.
6
Persist & Configure
Cloud Save stores per-player data (XP, settings, progress). Remote Config lets you tweak game parameters (spawn rates, prices) without a client update.
02

Prerequisites & Setup

Before touching Unity, set up your accounts and tools. Missing any of these is the #1 reason tutorials fail silently.

What You Need

โœ“
Unity Account (free)
Go to id.unity.com and create an account. This is the identity that owns your cloud project. One account = one dashboard. Keep it secure โ€” it controls your billing.
โœ“
Unity Hub 3.x
Download from unity.com/download. Hub manages editor versions. Never install Unity editors directly โ€” you'll lose version-switching superpowers.
โœ“
Unity Editor 2023.2 LTS or 6 (6000.x)
Install via Hub. UGS SDK 2.x is best-supported on these versions. Do not use 2022.x โ€” several UGS packages require minimum 2023.1 API surface. LTS = Long Term Support = patches keep coming.
โœ“
Unity Dashboard
Navigate to cloud.unity.com. This is the UGS web console where you create projects, enable services, and set credentials. You will live here.
โœ“
Git (Optional but Strongly Recommended)
Initialize a git repo before adding packages. The Library/ folder will be 3โ€“5 GB after imports. Your .gitignore must exclude it or you'll commit hundreds of megabytes.
โš  Billing Gotcha

UGS has a free tier (Spark plan) that covers ~100 MAU. You will NOT be charged during development, but you need to watch your dashboard. The Relay service bills per GB of relayed data. A single developer testing will never exceed the free tier โ€” but add real players and monitor it.

03

Unity Project Setup

Create a fresh 3D (or 3D Core) project. The template doesn't matter much for cloud work โ€” we care about the scripting backend and package manager.

Critical Project Settings

After creating the project, immediately go to Edit โ†’ Project Settings โ†’ Player and configure:

Project Settings
Company Name:  YourStudio           // affects bundle ID
Product Name:  CloudPOC
Bundle ID:     com.yourstudio.cloudpoc

Scripting Backend:   IL2CPP          // REQUIRED for production builds
API Compatibility:   .NET Standard 2.1
โš  Why IL2CPP Matters

Mono scripting backend works fine in the editor, but UGS SDK packages internally depend on IL2CPP code stripping behaviour in builds. Always test your actual build โ€” editor play mode can mask missing assembly definitions that IL2CPP strips away. Add a link.xml if you see serialization errors in builds.

The link.xml File

Create this at Assets/link.xml to prevent IL2CPP from stripping UGS assemblies:

XML โ€” Assets/link.xml
<linker>
  <assembly fullname="Unity.Services.Core" preserve="all"/>
  <assembly fullname="Unity.Services.Authentication" preserve="all"/>
  <assembly fullname="Unity.Services.Lobby" preserve="all"/>
  <assembly fullname="Unity.Services.Relay" preserve="all"/>
  <assembly fullname="Unity.Services.CloudSave" preserve="all"/>
  <assembly fullname="Unity.Services.RemoteConfig" preserve="all"/>
  <assembly fullname="Unity.Netcode.Runtime" preserve="all"/>
</linker>

Connect to Unity Cloud

Go to Edit โ†’ Project Settings โ†’ Services. Sign in with your Unity account, then either create a new cloud project or link to an existing one. This writes a ProjectID and EnvironmentID into your project settings โ€” the SDK reads these at runtime to know which UGS tenant to hit.

๐Ÿง  Environments Insight

UGS has Environments (Production, Development, custom). Always develop in a non-Production environment. Your Lobby data, Cloud Save data, Remote Config values and even your player accounts are siloed per environment. Switching from Dev to Production resets everyone's data. Create a "Development" environment first and never ship builds that point to Production until you're ready.

04

UGS Dashboard

The UGS Dashboard at cloud.unity.com is your operations center. Here's what you must do before writing code:

1
Create a Project
Dashboard โ†’ Projects โ†’ New Project. Name it clearly. The Project ID (a UUID) is what the SDK uses โ€” don't confuse it with the display name.
2
Enable Each Service
Navigate to each service (Authentication, Lobby, Relay, Cloud Save, Remote Config) and click "Enable". Services that aren't enabled return 403 Forbidden โ€” a confusing error when you don't know why.
3
Create Your Environments
Dashboard โ†’ Environments. Create "development" and later "production". Set development as the active environment in Unity editor via Project Settings โ†’ Services โ†’ Environment.
4
Configure Authentication Providers
Dashboard โ†’ Authentication โ†’ Settings. Enable "Anonymous" for now. Later add Apple, Google, Steam, etc. Each provider needs its own client ID/secret configured here.
5
Seed Remote Config Values
Dashboard โ†’ Remote Config. Add a key like max_players = 4. You'll fetch this at runtime. If the key doesn't exist in the dashboard, the SDK returns a default you define in code.
๐Ÿ”ฅ Do Not Ship

Never hardcode your Project ID, API keys, or service credentials in client-side code that ships to players. Your Project ID is semi-public (it's in builds anyway), but any server-side keys or admin tokens must live in Cloud Code or your backend โ€” never in the client binary.

05

Package Installation

Open the Package Manager (Window โ†’ Package Manager). Switch to "Unity Registry". Install these packages in order โ€” some have dependencies that will resolve automatically:

com.unity.services.core
The root SDK. Initializes all UGS services. Must be installed first.
com.unity.services.authentication
Player sign-in and identity. Depends on core.
com.unity.services.lobby
Room creation, discovery, and matchmaking metadata.
com.unity.services.relay
NAT punch-through server allocation for P2P without IP exposure.
com.unity.services.cloudsave
Per-player (and public) key-value persistence in the cloud.
com.unity.services.remoteconfig
Fetch server-side config values without a client update.
com.unity.netcode.gameobjects
The Netcode for GameObjects (NGO) multiplayer framework.
com.unity.transport
Low-level transport layer. NGO + Relay both depend on this.

Alternatively, edit your Packages/manifest.json directly โ€” faster and reproducible:

JSON โ€” Packages/manifest.json (dependencies section)
{
  "dependencies": {
    "com.unity.services.core":           "1.13.0",
    "com.unity.services.authentication":  "3.3.3",
    "com.unity.services.lobby":           "1.2.2",
    "com.unity.services.relay":           "1.3.1",
    "com.unity.services.cloudsave":       "3.2.1",
    "com.unity.services.remoteconfig":   "4.1.1",
    "com.unity.netcode.gameobjects":     "1.9.1",
    "com.unity.transport":               "2.3.0"
  }
}
๐Ÿ’ก Version Pinning

Always pin exact versions in your manifest. UGS packages have had breaking API changes between minor versions (the lobby subscription API changed in 1.1.x โ†’ 1.2.x). Pin, then bump intentionally and read the changelog.

06

Authentication

Authentication must succeed before every other UGS call. It's the foundation. Initialize UGS, sign in, and you're done. The SDK caches the session token in PlayerPrefs โ€” the same player ID is restored on subsequent launches.

The Initialization Pattern

Create a UGSManager.cs singleton that bootstraps everything from a very early scene (or via a RuntimeInitializeOnLoadMethod):

C# โ€” UGSManager.cs
using Unity.Services.Core;
using Unity.Services.Authentication;
using UnityEngine;
using System.Threading.Tasks;

public class UGSManager : MonoBehaviour
{
    public static UGSManager Instance { get; private set; }

    async void Awake()
    {
        if (Instance != null) { Destroy(gameObject); return; }
        Instance = this;
        DontDestroyOnLoad(gameObject);

        await InitializeUGS();
    }

    async Task InitializeUGS()
    {
        try
        {
            // Step 1: Initialize UGS Core (required before ANY service call)
            await UnityServices.InitializeAsync();

            // Step 2: Subscribe to auth events before signing in
            AuthenticationService.Instance.SignedIn += OnSignedIn;
            AuthenticationService.Instance.SignInFailed += OnSignInFailed;
            AuthenticationService.Instance.Expired += OnTokenExpired;

            // Step 3: Sign in (anonymous for POC)
            if (!AuthenticationService.Instance.IsSignedIn)
                await AuthenticationService.Instance.SignInAnonymouslyAsync();
        }
        catch (AuthenticationException ex)
        {
            // AuthenticationErrorCode tells you exactly what went wrong
            Debug.LogError($"Auth failed: {ex.ErrorCode} โ€” {ex.Message}");
        }
        catch (RequestFailedException ex)
        {
            // Network-level or service errors (503, 429, etc.)
            Debug.LogError($"UGS request failed: {ex.ErrorCode}");
        }
    }

    void OnSignedIn()
    {
        Debug.Log($"Signed in! PlayerID: {AuthenticationService.Instance.PlayerId}");
    }

    void OnSignInFailed(RequestFailedException err)
    {
        Debug.LogError($"Sign in failed: {err.ErrorCode}");
    }

    void OnTokenExpired()
    {
        // Token expires after 1 hour. Auto-refresh usually handles this,
        // but for robust apps, show a "session expired" prompt or re-auth.
        Debug.LogWarning("Auth token expired. Re-authenticating...");
        _ = AuthenticationService.Instance.SignInAnonymouslyAsync();
    }
}
๐Ÿง  The PlayerId is Sacred

The anonymous player's PlayerId is stored in PlayerPrefs on their device. If you call ClearSessionToken() or the player clears app data, they get a new PlayerId โ€” their old Cloud Save data becomes orphaned. In production, link the anonymous account to a platform identity (Apple, Google, Steam) as soon as possible. This is the conversion funnel: anonymous โ†’ linked โ†’ can restore on any device.

Anonymous vs Platform Auth

A
SignInAnonymouslyAsync()
Fastest, no friction. Creates a session tied to the device. Lose the device = lose the account unless linked. Perfect for POC.
B
SignInWithSteamAsync(steamTicket)
Pass the Steam session ticket (retrieved from Steamworks SDK). The PlayerId is stable across devices as long as the Steam account is the same.
C
SignInWithAppleAsync() / SignInWithGoogleAsync()
Mobile-native SSO. Requires platform SDK setup but gives cross-device stability on mobile. Apple requires the Sign In with Apple entitlement in your provisioning profile.
07

Lobby Service

The Lobby is essentially a cloud-hosted JSON document that represents a room. Players can list, filter, join, and read/write properties on it. It does not send game packets โ€” that's Relay's job. The Lobby tells players how to find each other; Relay lets them talk.

Create a Lobby

C# โ€” LobbyManager.cs (Host side)
using Unity.Services.Lobbies;
using Unity.Services.Lobbies.Models;
using System.Collections.Generic;

public class LobbyManager
{
    Lobby _currentLobby;
    ILobbyEvents _lobbyEvents;   // for real-time subscriptions
    float _heartbeatTimer;

    public async Task<Lobby> CreateLobbyAsync(string lobbyName, int maxPlayers)
    {
        var options = new CreateLobbyOptions
        {
            IsPrivate = false,
            Player = GetLocalPlayer(),  // your player data for the lobby
            Data = new Dictionary<string, DataObject>
            {
                // We'll set RelayJoinCode here once we have it
                { "RelayJoinCode", new DataObject(DataObject.VisibilityOptions.Member, "") },
                { "MapName",      new DataObject(DataObject.VisibilityOptions.Public,  "Forest") }
            }
        };

        _currentLobby = await LobbyService.Instance.CreateLobbyAsync(lobbyName, maxPlayers, options);
        Debug.Log($"Lobby created: {_currentLobby.Id} | Code: {_currentLobby.LobbyCode}");

        // CRITICAL: Start heartbeating or the lobby deletes itself after 30s
        StartHeartbeat();

        return _currentLobby;
    }

    // Must be called from Update() โ€” lobby heartbeat every 15 seconds
    public async Task HandleHeartbeatAsync(float deltaTime)
    {
        _heartbeatTimer -= deltaTime;
        if (_heartbeatTimer <= 0f && _currentLobby != null)
        {
            _heartbeatTimer = 15f;
            await LobbyService.Instance.SendHeartbeatPingAsync(_currentLobby.Id);
        }
    }

    public async Task UpdateRelayCodeInLobbyAsync(string joinCode)
    {
        _currentLobby = await LobbyService.Instance.UpdateLobbyAsync(
            _currentLobby.Id,
            new UpdateLobbyOptions
            {
                Data = new Dictionary<string, DataObject>
                {
                    { "RelayJoinCode", new DataObject(DataObject.VisibilityOptions.Member, joinCode) }
                }
            }
        );
    }
}

Join & Query Lobbies

C# โ€” Client side
// Join by the 6-character short lobby code shown in your UI
public async Task<Lobby> JoinLobbyByCodeAsync(string code)
{
    var options = new JoinLobbyByCodeOptions { Player = GetLocalPlayer() };
    _currentLobby = await LobbyService.Instance.JoinLobbyByCodeAsync(code, options);
    return _currentLobby;
}

// Quick-join: finds any available lobby automatically
public async Task<Lobby> QuickJoinAsync()
{
    var options = new QuickJoinLobbyOptions
    {
        Player = GetLocalPlayer(),
        // Filter by map (using indexed lobby data)
        Filter = new List<QueryFilter>
        {
            new QueryFilter(QueryFilter.FieldOptions.AvailableSlots,
                           "0", QueryFilter.OpOptions.GT)
        }
    };
    return await LobbyService.Instance.QuickJoinLobbyAsync(options);
}

// Subscribe to real-time lobby changes (v1.2+ of Lobby SDK)
public async Task SubscribeToLobbyEventsAsync()
{
    var callbacks = new LobbyEventCallbacks();
    callbacks.LobbyChanged += OnLobbyChanged;
    callbacks.KickedFromLobby += OnKicked;
    callbacks.LobbyEventConnectionStateChanged += OnConnectionStateChanged;
    _lobbyEvents = await LobbyService.Instance.SubscribeToLobbyEventsAsync(_currentLobby.Id, callbacks);
}

void OnLobbyChanged(ILobbyChanges changes)
{
    if (changes.LobbyDeleted) { /* host left, handle cleanup */ return; }
    changes.ApplyToLobby(_currentLobby);  // mutates the local copy
    
    // Check if host set the relay code
    if (_currentLobby.Data.TryGetValue("RelayJoinCode", out var relay) && !string.IsNullOrEmpty(relay.Value))
    {
        // Time to join relay and start netcode!
        _ = JoinRelayAndStartClientAsync(relay.Value);
    }
}
๐Ÿ”ฅ The Heartbeat Trap

If the host does not send a heartbeat ping every 30 seconds, the Lobby service automatically deletes the lobby. Clients that are polling will then get a 404 and crash if you don't handle it. Always start a heartbeat coroutine when you create a lobby, and always catch LobbyServiceException with reason LobbyNotFound to redirect clients back to the main menu.

โš  Rate Limits

Lobby queries are rate-limited at 1 request/second per player. Do not poll GetLobbyAsync() in Update(). Poll at most every 1.5 seconds if not using subscriptions, or better: use SubscribeToLobbyEventsAsync() for push-based updates (available in SDK 1.2+).

08

Relay Service

Relay is Unity's NAT punch-through infrastructure. Instead of exposing the host's IP address (which is often behind a firewall and changes), traffic routes through Unity-operated relay servers. The cost is modest latency overhead (~10โ€“20ms). For most games this is acceptable. For ultra-competitive shooters requiring <50ms RTT, you'd want Multiplay dedicated servers instead.

Host: Allocate a Relay Slot

C# โ€” RelayManager.cs
using Unity.Services.Relay;
using Unity.Services.Relay.Models;
using Unity.Networking.Transport.Relay;
using Unity.Netcode;
using Unity.Netcode.Transports.UTP;

public class RelayManager
{
    public async Task<string> StartHostWithRelayAsync(int maxConnections)
    {
        // maxConnections = max number of OTHER players (not counting host)
        Allocation allocation = await RelayService.Instance.CreateAllocationAsync(maxConnections);
        
        // Get the join code clients will use to find this relay slot
        string joinCode = await RelayService.Instance.GetJoinCodeAsync(allocation.AllocationId);

        // Convert to Unity Transport relay data
        RelayServerData relayData = AllocationUtils.ToRelayServerData(allocation, "dtls");
        // Note: use "wss" protocol for WebGL builds instead of "dtls"

        // Wire up the Netcode transport with relay data
        var transport = NetworkManager.Singleton.GetComponent<UnityTransport>();
        transport.SetRelayServerData(new RelayServerData(allocation, "dtls"));

        NetworkManager.Singleton.StartHost();

        Debug.Log($"Host started. Relay join code: {joinCode}");
        return joinCode;
    }

    public async Task JoinRelayAndStartClientAsync(string joinCode)
    {
        JoinAllocation joinAllocation = await RelayService.Instance.JoinAllocationAsync(joinCode);

        var transport = NetworkManager.Singleton.GetComponent<UnityTransport>();
        transport.SetRelayServerData(new RelayServerData(joinAllocation, "dtls"));

        NetworkManager.Singleton.StartClient();
        Debug.Log("Client connected via Relay.");
    }
}
๐Ÿง  Relay Allocation Lifetime

A Relay allocation lasts 60 seconds if no one connects, then it expires. Once the host connects, the allocation stays alive as long as the host is connected. If the host disconnects, the relay allocation is destroyed and all clients are dropped. Design your reconnection flow around this: clients need to be able to return to the lobby and get a new relay join code if the host migrates or restarts.

Protocol Choice: dtls vs udp vs wss

ProtocolUse WhenNotes
dtls Desktop & Mobile builds Encrypted UDP. The default. Best performance for PC/mobile.
udp LAN / trusted networks only No encryption. Never use for internet-facing games.
wss WebGL builds WebSockets over TLS. Required by browsers. Higher overhead.
09

Netcode for GameObjects (NGO)

NGO is Unity's first-party multiplayer framework layered on top of Unity Transport. It handles object spawning, ownership, NetworkVariables, RPCs, and scene management. After wiring up Relay in the previous step, NGO runs on top with no further configuration.

Scene Setup

1
Add NetworkManager to Your Bootstrap Scene
Create an empty GameObject named "NetworkManager". Add the NetworkManager component. Add UnityTransport component to the same object. In NetworkManager, set the Transport field to your UnityTransport component.
2
Register Network Prefabs
Any GameObject you want to spawn over the network must have a NetworkObject component and be registered in NetworkManager's "Network Prefabs" list. Unregistered prefabs cause a "Prefab hash mismatch" disconnect.
3
Add Network Scenes
Any scene loaded additively after the game starts must be in NetworkManager's "Registered Scenes for Network" list, or scene-load sync will fail silently on clients.

NetworkBehaviour Fundamentals

C# โ€” PlayerController.cs (networked)
using Unity.Netcode;
using UnityEngine;

public class PlayerController : NetworkBehaviour
{
    // NetworkVariable: server writes, all clients read. Synced automatically.
    public NetworkVariable<Vector3> NetworkPosition = new(Vector3.zero,
        NetworkVariableReadPermission.Everyone,
        NetworkVariableWritePermission.Server);

    public NetworkVariable<int> Health = new(100,
        NetworkVariableReadPermission.Everyone,
        NetworkVariableWritePermission.Server);

    void Update()
    {
        // IsOwner = true only on the client that owns this object
        if (!IsOwner) return;

        Vector3 input = new(Input.GetAxisRaw("Horizontal"), 0, Input.GetAxisRaw("Vertical"));
        if (input.sqrMagnitude > 0.1f)
        {
            // Tell the server to move us โ€” server validates, then writes NetworkPosition
            MoveServerRpc(input.normalized);
        }
    }

    // [ServerRpc] runs on the server. Called from owner client.
    // RequireOwnership = true is the default. It rejects calls from non-owners.
    [ServerRpc]
    void MoveServerRpc(Vector3 direction)
    {
        Vector3 newPos = transform.position + direction * 5f * Time.deltaTime;
        // Server validates (check walls, etc.) then commits
        NetworkPosition.Value = newPos;
        transform.position = newPos;
    }

    // [ClientRpc] runs on ALL clients. Called from server only.
    [ClientRpc]
    public void PlayHitEffectClientRpc(Vector3 hitPoint)
    {
        // Spawn VFX locally on every client
        Debug.Log($"Hit effect at {hitPoint} on {gameObject.name}");
    }

    public override void OnNetworkSpawn()
    {
        // Called on all clients when this object is networked-spawned
        if (IsOwner)
        {
            // Enable camera follow, input, etc. only for local player
            Camera.main.GetComponent<CameraFollow>().SetTarget(transform);
        }
    }
}
๐Ÿง  The Ownership Model

IsServer is true on the machine running the game logic. IsOwner is true on the client that "owns" the NetworkObject (usually the spawning client). IsLocalPlayer is true for player-owned objects on the local machine. Always gate your input reads with IsOwner, not IsLocalPlayer โ€” otherwise you'll process input for player objects you don't control. Design rule: clients send intent via ServerRpcs, server validates and writes NetworkVariables, clients read NetworkVariables to render.

Spawning Players

C# โ€” GameManager.cs (server-side player spawning)
using Unity.Netcode;

public class GameManager : NetworkBehaviour
{
    [SerializeField] GameObject playerPrefab;

    public override void OnNetworkSpawn()
    {
        if (!IsServer) return;
        NetworkManager.Singleton.OnClientConnectedCallback += OnClientConnected;
    }

    void OnClientConnected(ulong clientId)
    {
        Vector3 spawnPoint = GetSpawnPoint();
        GameObject player = Instantiate(playerPrefab, spawnPoint, Quaternion.identity);
        
        // Spawn with ownership assigned to the connecting client
        player.GetComponent<NetworkObject>().SpawnAsPlayerObject(clientId);
    }
}
10

Cloud Save

Cloud Save gives each player a key-value store in the cloud. Values are JSON blobs. It survives device changes, reinstalls, and platform migrations โ€” as long as the player re-authenticates with the same identity.

C# โ€” CloudSaveManager.cs
using Unity.Services.CloudSave;
using Unity.Services.CloudSave.Models;
using System.Collections.Generic;
using Newtonsoft.Json;  // comes with UGS core

[System.Serializable]
public class PlayerSaveData
{
    public int    Level   = 1;
    public int    XP      = 0;
    public string Skin    = "default";
}

public class CloudSaveManager
{
    const string SAVE_KEY = "player_data";

    public async Task SaveAsync(PlayerSaveData data)
    {
        var payload = new Dictionary<string, object>
        {
            { SAVE_KEY, data }  // UGS serializes this to JSON automatically
        };
        await CloudSaveService.Instance.Data.Player.SaveAsync(payload);
        Debug.Log("Player data saved to cloud.");
    }

    public async Task<PlayerSaveData> LoadAsync()
    {
        var keys = new HashSet<string> { SAVE_KEY };
        var results = await CloudSaveService.Instance.Data.Player.LoadAsync(keys);

        if (results.TryGetValue(SAVE_KEY, out var item))
            return item.Value.GetAs<PlayerSaveData>();

        // First time player: return defaults
        return new PlayerSaveData();
    }
}
๐Ÿ’ก Public vs Protected vs Private Data

Cloud Save has three visibility levels: Player (only that player can read/write โ€” default), Protected (player writes via Cloud Code only, preventing cheating), and Public (any authenticated player can read it โ€” great for leaderboards or player profiles). Use Protected for anything affecting game balance.

11

Remote Config

Remote Config lets you change game parameters (prices, balance values, feature flags, event durations) from the dashboard without pushing a client update. At runtime, the SDK fetches a JSON config keyed to your project and environment. You define fallback defaults in code so it works offline or if the fetch fails.

C# โ€” RemoteConfigManager.cs
using Unity.Services.RemoteConfig;
using System.Threading.Tasks;

public class RemoteConfigManager
{
    struct UserAttributes { }   // optional: AB test targeting
    struct AppAttributes  { }

    // Local defaults โ€” used if fetch fails or key doesn't exist in dashboard
    public int   MaxPlayers    = 4;
    public float RespawnDelay  = 3f;
    public bool  EventEnabled  = false;

    public async Task FetchAndApplyAsync()
    {
        // RemoteConfigService listens to this event when fetch completes
        RemoteConfigService.Instance.FetchCompleted += OnFetchCompleted;
        await RemoteConfigService.Instance.FetchConfigsAsync(new UserAttributes(), new AppAttributes());
    }

    void OnFetchCompleted(ConfigResponse response)
    {
        switch (response.requestOrigin)
        {
            case ConfigOrigin.Default:
                Debug.Log("RemoteConfig: using hardcoded defaults"); break;
            case ConfigOrigin.Cached:
                Debug.Log("RemoteConfig: using cached values"); break;
            case ConfigOrigin.Remote:
                Debug.Log("RemoteConfig: fresh from server"); break;
        }

        MaxPlayers   = RemoteConfigService.Instance.appConfig.GetInt("max_players",   4);
        RespawnDelay = RemoteConfigService.Instance.appConfig.GetFloat("respawn_delay", 3f);
        EventEnabled = RemoteConfigService.Instance.appConfig.GetBool("event_active",   false);

        Debug.Log($"Config: MaxPlayers={MaxPlayers} Respawn={RespawnDelay} Event={EventEnabled}");
    }
}
๐Ÿง  Remote Config as a Feature Flag System

The real power of Remote Config isn't balance tweaks โ€” it's feature flags. Ship a new feature behind a boolean. Roll it out to 5% of players. If it breaks something, flip it off from the dashboard in seconds without an emergency hotfix. This pattern (ship dark, roll out gradually) is standard at every large game studio. Build that discipline from day one.

12

Errors, Gotchas & Fixes

These are the errors you will hit. Every single one. Document them here so you recognize them in <30 seconds next time.

UGS Core Errors

ErrorCauseFix
ServicesNotInitializedException Called a service before UnityServices.InitializeAsync() Always await InitializeAsync() first. Use a UGSManager that inits in Awake.
RequestFailedException (401) Not authenticated, or token expired Check IsSignedIn before calls. Handle the Expired event to re-auth silently.
RequestFailedException (403) Service not enabled in dashboard, or calling from wrong environment Go to cloud.unity.com, enable the service. Check your active Environment matches.
RequestFailedException (429) Rate limit exceeded โ€” usually polling too fast Add exponential backoff. Use subscriptions instead of polling for Lobby.
InvalidOperationException: Not initialized Calling UGS in the editor without a linked cloud project Edit โ†’ Project Settings โ†’ Services. Link your Unity account and project.

Lobby Errors

ErrorCauseFix
LobbyServiceException: LobbyNotFound Heartbeat stopped โ†’ lobby expired. Or wrong lobby ID. Start heartbeat immediately on CreateLobby. Catch this exception and return players to main menu.
LobbyServiceException: LobbyFull Lobby at max capacity Expected. Show "Lobby Full" UI. Use QuickJoin with AvailableSlots GT 0 filter.
LobbyServiceException: Unauthorized Trying to update a lobby you're not the host of, or modifying another player's data Only the host can update lobby Data. Clients can update their own Player data inside the lobby.
LobbyEventConnectionStateChanged: Unsubscribed Subscription lost (network hiccup or lobby deleted) Re-subscribe on Unsubscribed. If lobby was deleted, route to menu.
No lobbies found in QueryLobbiesAsync Wrong environment, filter too strict, or all lobbies are private Check environment. Log the QueryResponse.Results count. Remove extra filters first.

Relay Errors

ErrorCauseFix
RelayServiceException: AllocationNotFound JoinCode expired (60s with no host) or join code was wrong Host must allocate AND start hosting before sharing the code. Clients should join within ~50s.
Relay connection timeout DTLS blocked by firewall (common in enterprise/school networks) For affected networks, switch to WSS protocol. Or test on mobile data to confirm it's a firewall issue.
StartHost() returns false NetworkManager already running, or transport not configured Call NetworkManager.Shutdown() and await its completion before calling StartHost() again.

NGO (Netcode) Errors

ErrorCauseFix
Prefab hash mismatch on client NetworkObject prefab not registered in NetworkManager, or client has different version Add all network prefabs to NetworkManager's list. Ensure all clients run the same build.
ServerRpc dropped silently Calling ServerRpc from non-owner without RequireOwnership = false Set [ServerRpc(RequireOwnership = false)] for RPCs any client should call (e.g. game events).
NetworkVariable not syncing Modified NetworkVariable on client instead of server Only the server (or owner, if permission allows) can write. Route changes through a ServerRpc.
NullRef on NetworkManager.Singleton in OnDestroy Object destroyed during shutdown when NetworkManager is already null Guard with: if (NetworkManager.Singleton != null) before any NGO calls in OnDestroy.
Client immediately disconnects after connecting Server scene is different from client scene, or server-side NullRef on spawn Add logging in OnClientConnectedCallback. Wrap spawn in try/catch to surface the real error.
โœ… Debug Tip

Enable NGO's built-in logging via NetworkManager.LogLevel = LogLevel.Developer. It prints every RPC, spawn event, and connection state change. Verbose, but it tells you exactly what's happening. Turn it off before shipping โ€” it generates megabytes of logs per session.

13

Scaling Insights

Your POC works. Now you want to ship it. Here's what changes when you go from 2 players to 20,000.

100
Free MAU (Spark)
10k
Lobby max players/room
~200ms
Relay extra latency

Connection Architecture Choices

As you scale, you'll face three architectural paths. Choose intentionally:

ArchitectureBest ForTrade-offs
Relay (POC) Small groups, quick launch, no dedicated server budget Host migration pain, host cheating, extra latency, host leaves = session dies
Multiplay (Dedicated Servers) Competitive games, 10+ players, anti-cheat requirements Higher cost, ops complexity, but: authoritative server, no host advantage, host migration trivial
Cloud Code + Custom Backend Turn-based, async games, economy systems No real-time transport. Use for leaderboards, economies, matchmaking logic, not live gameplay.

Host Migration (The Hard Problem)

When the host disconnects in a Relay game, everyone is kicked. There is no automatic host migration in NGO. You must build it:

๐Ÿง  The Real Scaling Bottleneck

At scale, your bottleneck won't be Unity's infrastructure โ€” it'll be your matchmaking logic and region latency. Relay is globally distributed; automatically route players to the nearest region by calling RelayService.Instance.ListRegionsAsync() and selecting the region with the lowest ping before allocating. A player in Tokyo in a US relay server adds 150ms of artificial latency โ€” unacceptable for action games.

Region-Aware Relay Allocation

C# โ€” Best region selection
var regions = await RelayService.Instance.ListRegionsAsync();

// Ping each region and pick the fastest
string bestRegion = "auto";
long   bestLatency = long.MaxValue;

foreach (var region in regions)
{
    long ping = await PingRegionAsync(region.Id);
    if (ping < bestLatency) { bestLatency = ping; bestRegion = region.Id; }
}

var allocation = await RelayService.Instance.CreateAllocationAsync(
    maxConnections: 3,
    region: bestRegion  // null or "auto" lets Unity pick โ€” usually fine
);

Lobby Performance at Scale

14

Production Checklist

Before you ship anything beyond friends and family testers, work through this list:

โœ“
Switch to Production Environment
Create a Production environment in the UGS dashboard. Ship builds pointing to Production. Keep Development for internal QA only. Player data does not transfer between environments.
โœ“
Link Anonymous Accounts to Platform Identities
Prompt users to link to Apple/Google/Steam after they've invested time. Use LinkWithAppleAsync(), LinkWithGoogleAsync(), etc. This prevents account loss on reinstall.
โœ“
Handle Token Expiry Gracefully
Subscribe to AuthenticationService.Instance.Expired and silently re-authenticate. Show a UI only if re-auth fails after 3 retries.
โœ“
Add Exponential Backoff on 429 Errors
Wrap all UGS calls in a retry loop with jitter: wait 1s, 2s, 4s, 8s, give up. Without this, a brief rate-limit spike causes a cascade of retries that worsens the problem.
โœ“
Test IL2CPP Builds on Real Devices
Do not ship having only tested in Editor. IL2CPP strips assemblies differently. Always do at least one QA pass on a real device with a release build before launch.
โœ“
Set Up Cloud Code for Server-Authoritative Logic
Any game economy, anti-cheat validation, or leaderboard update should go through Cloud Code (Unity's serverless JS/C# functions) โ€” not directly from the client. Clients that call Cloud Save directly with hacked values will corrupt your economy.
โœ“
Monitor Your Dashboard
Set up billing alerts in the Unity Dashboard. Watch your MAU, Relay GB transferred, and Cloud Save operation counts. The Spark free tier is generous but has hard limits that will cause 403 errors when exceeded.
โœ“
Implement a Maintenance Mode
Use a Remote Config boolean maintenance_mode: true. On app launch, fetch this before showing the main menu. If true, show a "down for maintenance" screen. This lets you take the game offline without a patch.
๐Ÿง  Final Wisdom

The biggest mistake Unity Cloud beginners make is treating these services as a monolith. They're not. Each service has its own rate limits, its own failure modes, its own billing, and its own regional availability. Build defensive code from day one. Every UGS call can fail. Wrap every single one in try/catch with meaningful error handling. The games that succeed in production are not the ones that never have errors โ€” they're the ones that handle errors gracefully so players never notice.


Unity Cloud Masterclass ยท Built to be referenced again and again ยท Go ship something.