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.
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:
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
PlayerId and a short-lived JWT access token. Every subsequent API call uses this token.JoinCode. Host saves this JoinCode into the Lobby's custom data so all lobby members can read it.RelayServerData objects they can hand to Netcode.UnityTransport with their Relay data and call StartHost() / StartClient(). All game traffic now flows through the Relay DTLS tunnel โ no IP exposure.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
Library/ folder will be 3โ5 GB after imports. Your .gitignore must exclude it or you'll commit hundreds of megabytes.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.
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:
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
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:
<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.
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.
UGS Dashboard
The UGS Dashboard at cloud.unity.com is your operations center. Here's what you must do before writing code:
403 Forbidden โ a confusing error when you don't know why.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.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.
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:
Alternatively, edit your Packages/manifest.json directly โ faster and reproducible:
{
"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"
}
}
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.
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):
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 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
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
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
// 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); } }
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.
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+).
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
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."); } }
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
| Protocol | Use When | Notes |
|---|---|---|
| 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. |
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
NetworkManager component. Add UnityTransport component to the same object. In NetworkManager, set the Transport field to your UnityTransport component.NetworkObject component and be registered in NetworkManager's "Network Prefabs" list. Unregistered prefabs cause a "Prefab hash mismatch" disconnect.NetworkBehaviour Fundamentals
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); } } }
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
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); } }
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.
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(); } }
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.
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.
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}"); } }
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.
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
| Error | Cause | Fix |
|---|---|---|
| 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
| Error | Cause | Fix |
|---|---|---|
| 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
| Error | Cause | Fix |
|---|---|---|
| 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
| Error | Cause | Fix |
|---|---|---|
| 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. |
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.
Scaling Insights
Your POC works. Now you want to ship it. Here's what changes when you go from 2 players to 20,000.
Connection Architecture Choices
As you scale, you'll face three architectural paths. Choose intentionally:
| Architecture | Best For | Trade-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:
- When the host leaves, the client with the lowest latency (or longest session) becomes the new host candidate.
- That client calls
NetworkManager.Shutdown(), allocates a new Relay, writes the new JoinCode to the Lobby, and callsStartHost(). - Other clients see the Lobby update, pick up the new JoinCode, and reconnect.
- Game state must be serialized and handed to the new host โ NGO does not do this automatically. Use
NetworkVariablesnapshots or a custom state sync message.
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
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
- Use indexes. Lobby Data fields have a
Visibilityand anIndexoption. Fields marked with an index can be used inQueryFilter. Non-indexed fields cannot be filtered โ you'd have to pull all lobbies and filter client-side. Index the fields you'll query on (e.g. game mode, region, ELO band). - Don't store large data in Lobby. The Lobby is limited to ~2KB of custom data. Don't put full game state or large player profiles in it. Use Cloud Save for that. Lobby is for matchmaking metadata only.
- Delete lobbies explicitly. When a game session ends naturally, call
LobbyService.Instance.DeleteLobbyAsync(id). Otherwise the lobby lingers for 30 seconds after heartbeat stops. At scale, ghost lobbies pollute your lobby list and waste query results.
Production Checklist
Before you ship anything beyond friends and family testers, work through this list:
LinkWithAppleAsync(), LinkWithGoogleAsync(), etc. This prevents account loss on reinstall.AuthenticationService.Instance.Expired and silently re-authenticate. Show a UI only if re-auth fails after 3 retries.403 errors when exceeded.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.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.