diff --git a/Assets/Resources.meta b/Assets/Resources.meta
new file mode 100644
index 0000000..a2c15a0
--- /dev/null
+++ b/Assets/Resources.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 4e34b833ea5898745829ede65be71e5b
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Resources/UnityPlayerAccountSettings.asset b/Assets/Resources/UnityPlayerAccountSettings.asset
new file mode 100644
index 0000000..19d196c
--- /dev/null
+++ b/Assets/Resources/UnityPlayerAccountSettings.asset
@@ -0,0 +1,19 @@
+%YAML 1.1
+%TAG !u! tag:unity3d.com,2011:
+--- !u!114 &11400000
+MonoBehaviour:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 0}
+ m_Enabled: 1
+ m_EditorHideFlags: 0
+ m_Script: {fileID: 11500000, guid: b58a3bc647e70774b8c7225eb3b51e7e, type: 3}
+ m_Name: UnityPlayerAccountSettings
+ m_EditorClassIdentifier: Unity.Services.Authentication.PlayerAccounts::Unity.Services.Authentication.PlayerAccounts.UnityPlayerAccountSettings
+ clientId:
+ scopeMask: 7
+ useCustomDeepLinkUri: 0
+ customScheme:
+ customHost:
diff --git a/Assets/Resources/UnityPlayerAccountSettings.asset.meta b/Assets/Resources/UnityPlayerAccountSettings.asset.meta
new file mode 100644
index 0000000..75785b2
--- /dev/null
+++ b/Assets/Resources/UnityPlayerAccountSettings.asset.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: da17151fe2da8bc4a89aebf8f35a9bbe
+NativeFormatImporter:
+ externalObjects: {}
+ mainObjectFileID: 11400000
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Scenes/Main.unity b/Assets/Scenes/Main.unity
index c082255..d6275cd 100644
--- a/Assets/Scenes/Main.unity
+++ b/Assets/Scenes/Main.unity
@@ -365,6 +365,7 @@ GameObject:
- component: {fileID: 284910515}
- component: {fileID: 284910514}
- component: {fileID: 284910518}
+ - component: {fileID: 284910519}
m_Layer: 0
m_Name: NetworkManager
m_TagString: Untagged
@@ -494,6 +495,20 @@ MonoBehaviour:
spectatorPrefab: {fileID: 2945604797830805689, guid: 6e001668808a3d64680522e4c8d582b0, type: 3}
lobbySpawnCenter: {x: 0, y: 1, z: 0}
matchSpawnCenter: {x: 0, y: 1, z: 0}
+--- !u!114 &284910519
+MonoBehaviour:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 284910513}
+ m_Enabled: 1
+ m_EditorHideFlags: 0
+ m_Script: {fileID: 11500000, guid: 64babb133b483be49972dd184b31d84b, type: 3}
+ m_Name:
+ m_EditorClassIdentifier: Assembly-CSharp::OnlineSessionManager
+ maxPlayers: 8
+ showGui: 1
--- !u!1 &330585543
GameObject:
m_ObjectHideFlags: 0
diff --git a/Assets/Scripts/OnlineSessionManager.cs b/Assets/Scripts/OnlineSessionManager.cs
new file mode 100644
index 0000000..c529477
--- /dev/null
+++ b/Assets/Scripts/OnlineSessionManager.cs
@@ -0,0 +1,243 @@
+using System;
+using System.Collections.Generic;
+using Unity.Netcode;
+using Unity.Services.Authentication;
+using Unity.Services.Core;
+using Unity.Services.Multiplayer;
+using UnityEngine;
+
+///
+/// Online hosting/joining over Unity Gaming Services so players can connect across the internet
+/// without port forwarding. Uses the unified Multiplayer Services "Sessions" API, which bundles
+/// Relay (NAT-free messaging via Unity's relay servers) + Lobby (the public server list) + anonymous
+/// Authentication behind single calls, and configures + starts the NGO transport itself - so we do
+/// NOT call /StartClient here; CreateSessionAsync starts
+/// the host and JoinSession* starts the client.
+///
+/// Terminology: the UGS "session" is the entry in the public server list (network layer). It is a
+/// different thing from the in-game "Lobby" scene (the pre-match staging area that
+/// loads after the connection is up). This component does not touch the match flow at all - the usual
+/// NetworkManager callbacks fire exactly as they do for loopback hosting, so MatchManager, spawning and
+/// authority are unaffected.
+///
+/// This is a quick IMGUI server browser for clone testing; replace with proper uGUI later. The loopback
+/// dev path (see , H/C/J/K) stays for fast local testing - online is additive.
+/// Requires the project to be linked to a UGS cloud project (Project Settings > Services) with Relay,
+/// Lobby and Authentication (Anonymous) enabled; otherwise sign-in fails with a clear status message.
+/// Attach to the same GameObject as the NetworkManager.
+///
+[RequireComponent(typeof(NetworkManager))]
+[DisallowMultipleComponent]
+public class OnlineSessionManager : MonoBehaviour
+{
+ [Tooltip("Maximum players per hosted session (host included).")]
+ public int maxPlayers = 8;
+
+ [Tooltip("Draw the on-screen online server-browser panel (below the dev panel).")]
+ public bool showGui = true;
+
+ private NetworkManager _net;
+ private ISession _session; // active session (host or client); null when not in one
+ private IList _browse = new List();
+ private string _hostName = "";
+ private string _status = "Idle";
+ private bool _busy;
+ private bool _leaving; // suppress the defensive leave while an explicit leave is in flight
+ private Vector2 _scroll;
+
+ private void Awake()
+ {
+ _net = GetComponent();
+ _hostName = $"{SystemInfo.deviceName} game";
+ }
+
+ private void OnEnable()
+ {
+ if (!_net) return;
+ _net.OnServerStopped += OnNetworkStopped;
+ _net.OnClientStopped += OnNetworkStopped;
+ }
+
+ private void OnDisable()
+ {
+ if (!_net) return;
+ _net.OnServerStopped -= OnNetworkStopped;
+ _net.OnClientStopped -= OnNetworkStopped;
+ }
+
+ // The match-end / disconnect / Shutdown paths stop the NetworkManager without telling UGS. Leave the
+ // session here too so the Relay allocation and the public lobby entry are released.
+ private void OnNetworkStopped(bool _)
+ {
+ var stale = _session;
+ _session = null;
+ if (stale != null && !_leaving) LeaveSilently(stale);
+ }
+
+ private async void LeaveSilently(ISession session)
+ {
+ try { await session.LeaveAsync(); }
+ catch (Exception e) { Debug.LogWarning($"[OnlineSession] leave-on-stop failed: {e.Message}"); }
+ }
+
+ private async System.Threading.Tasks.Task EnsureSignedInAsync()
+ {
+ if (UnityServices.State != ServicesInitializationState.Initialized)
+ {
+ // Anonymous tokens are cached per machine under company/product, NOT per project path - so
+ // junction-mirror clones (shared ProjectSettings) would otherwise resolve to one UGS player id
+ // and collide on join (409 LobbyConflict). Namespacing the auth cache by a hash of this install's
+ // path gives each clone a stable, distinct identity. Harmless in production - separate machines
+ // already have separate caches; this only matters for same-machine clone testing.
+ var options = new InitializationOptions();
+ options.SetProfile(ProfileForThisInstall());
+ await UnityServices.InitializeAsync(options);
+ }
+ if (!AuthenticationService.Instance.IsSignedIn)
+ await AuthenticationService.Instance.SignInAnonymouslyAsync();
+ }
+
+ // Deterministic, regex-safe (^[a-zA-Z0-9_-]{1,30}$) profile derived from the install path, so the same
+ // clone is always the same UGS player across restarts - no random per-launch identity, no orphaned ghost
+ // players/lobbies piling up. SHA1 over dataPath -> "mirror_" + 12 hex chars = 19 chars (fits the limit).
+ private static string ProfileForThisInstall()
+ {
+ using (var sha1 = System.Security.Cryptography.SHA1.Create())
+ {
+ var bytes = sha1.ComputeHash(System.Text.Encoding.UTF8.GetBytes(Application.dataPath));
+ var name = new System.Text.StringBuilder("mirror_", 19);
+ for (int i = 0; i < 6; i++) name.Append(bytes[i].ToString("x2"));
+ return name.ToString();
+ }
+ }
+
+ private async void HostAsync()
+ {
+ if (_busy) return;
+ _busy = true;
+ try
+ {
+ _status = "Signing in…";
+ await EnsureSignedInAsync();
+ _status = "Creating session…";
+ var options = new SessionOptions
+ {
+ Name = string.IsNullOrWhiteSpace(_hostName) ? "Chameleon game" : _hostName.Trim(),
+ MaxPlayers = Mathf.Max(2, maxPlayers)
+ }.WithRelayNetwork();
+ _session = await MultiplayerService.Instance.CreateSessionAsync(options);
+ _status = $"Hosting \"{_session.Name}\" (code {_session.Code})";
+ }
+ catch (Exception e)
+ {
+ _status = $"Host failed: {e.Message}";
+ Debug.LogException(e);
+ }
+ finally { _busy = false; }
+ }
+
+ private async void RefreshAsync()
+ {
+ if (_busy) return;
+ _busy = true;
+ try
+ {
+ _status = "Signing in…";
+ await EnsureSignedInAsync();
+ _status = "Querying sessions…";
+ var results = await MultiplayerService.Instance.QuerySessionsAsync(new QuerySessionsOptions());
+ _browse = results.Sessions;
+ _status = $"{_browse.Count} session(s) found";
+ }
+ catch (Exception e)
+ {
+ _status = $"Refresh failed: {e.Message}";
+ Debug.LogException(e);
+ }
+ finally { _busy = false; }
+ }
+
+ private async void JoinAsync(string sessionId)
+ {
+ if (_busy) return;
+ _busy = true;
+ try
+ {
+ _status = "Signing in…";
+ await EnsureSignedInAsync();
+ _status = "Joining session…";
+ _session = await MultiplayerService.Instance.JoinSessionByIdAsync(sessionId);
+ _status = $"Joined \"{_session.Name}\"";
+ }
+ catch (Exception e)
+ {
+ _status = $"Join failed: {e.Message}";
+ Debug.LogException(e);
+ }
+ finally { _busy = false; }
+ }
+
+ private async void LeaveAsync()
+ {
+ if (_busy || _session == null) return;
+ _busy = true;
+ _leaving = true;
+ try
+ {
+ _status = "Leaving…";
+ await _session.LeaveAsync(); // also tears down the NGO connection
+ _status = "Left session";
+ }
+ catch (Exception e)
+ {
+ _status = $"Leave failed: {e.Message}";
+ Debug.LogException(e);
+ }
+ finally { _session = null; _leaving = false; _busy = false; }
+ }
+
+ private void OnGUI()
+ {
+ if (!showGui || !_net) return;
+
+ GUILayout.BeginArea(new Rect(10, 180, 340, 360), GUI.skin.box);
+ GUILayout.Label("Online (UGS Relay + Lobby)");
+ GUILayout.Label(_status);
+
+ GUI.enabled = !_busy;
+ if (!_net.IsListening)
+ {
+ GUILayout.Label("Session name:");
+ _hostName = GUILayout.TextField(_hostName);
+ if (GUILayout.Button("Host Online")) HostAsync();
+
+ GUILayout.Space(6);
+ if (GUILayout.Button("Refresh List")) RefreshAsync();
+
+ _scroll = GUILayout.BeginScrollView(_scroll, GUILayout.Height(180));
+ if (_browse.Count == 0)
+ GUILayout.Label("No sessions. Click Refresh.");
+ foreach (var s in _browse)
+ {
+ GUILayout.BeginHorizontal();
+ int taken = Mathf.Max(0, s.MaxPlayers - s.AvailableSlots);
+ GUILayout.Label($"{s.Name} ({taken}/{s.MaxPlayers})");
+ if (GUILayout.Button("Join", GUILayout.Width(60))) JoinAsync(s.Id);
+ GUILayout.EndHorizontal();
+ }
+ GUILayout.EndScrollView();
+ }
+ else if (_session != null)
+ {
+ GUILayout.Label($"In session: {_session.Name}");
+ if (GUILayout.Button("Leave Session")) LeaveAsync();
+ }
+ else
+ {
+ GUILayout.Label("Local (loopback) session active.");
+ }
+ GUI.enabled = true;
+
+ GUILayout.EndArea();
+ }
+}
diff --git a/Assets/Scripts/OnlineSessionManager.cs.meta b/Assets/Scripts/OnlineSessionManager.cs.meta
new file mode 100644
index 0000000..c1ce29c
--- /dev/null
+++ b/Assets/Scripts/OnlineSessionManager.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 64babb133b483be49972dd184b31d84b
\ No newline at end of file
diff --git a/Packages/manifest.json b/Packages/manifest.json
index 5e2abd8..8f88e58 100644
--- a/Packages/manifest.json
+++ b/Packages/manifest.json
@@ -7,6 +7,7 @@
"com.unity.multiplayer.center": "1.0.1",
"com.unity.netcode.gameobjects": "2.13.0",
"com.unity.render-pipelines.universal": "17.3.0",
+ "com.unity.services.multiplayer": "2.2.4",
"com.unity.timeline": "1.8.12",
"com.unity.ugui": "2.0.0",
"com.unity.visualscripting": "1.9.11",
diff --git a/Packages/packages-lock.json b/Packages/packages-lock.json
index 685dd7c..af8ced0 100644
--- a/Packages/packages-lock.json
+++ b/Packages/packages-lock.json
@@ -39,7 +39,7 @@
},
"com.unity.collections": {
"version": "2.6.2",
- "depth": 2,
+ "depth": 1,
"source": "registry",
"dependencies": {
"com.unity.burst": "1.8.23",
@@ -152,6 +152,87 @@
"dependencies": {},
"url": "https://packages.unity.com"
},
+ "com.unity.services.authentication": {
+ "version": "3.6.0",
+ "depth": 1,
+ "source": "registry",
+ "dependencies": {
+ "com.unity.ugui": "1.0.0",
+ "com.unity.services.core": "1.15.1",
+ "com.unity.nuget.newtonsoft-json": "3.2.1",
+ "com.unity.modules.unitywebrequest": "1.0.0"
+ },
+ "url": "https://packages.unity.com"
+ },
+ "com.unity.services.core": {
+ "version": "1.16.0",
+ "depth": 1,
+ "source": "registry",
+ "dependencies": {
+ "com.unity.modules.androidjni": "1.0.0",
+ "com.unity.nuget.newtonsoft-json": "3.2.1",
+ "com.unity.modules.unitywebrequest": "1.0.0"
+ },
+ "url": "https://packages.unity.com"
+ },
+ "com.unity.services.deployment": {
+ "version": "1.7.1",
+ "depth": 1,
+ "source": "registry",
+ "dependencies": {
+ "com.unity.services.core": "1.15.1",
+ "com.unity.services.deployment.api": "1.1.2"
+ },
+ "url": "https://packages.unity.com"
+ },
+ "com.unity.services.deployment.api": {
+ "version": "1.1.3",
+ "depth": 2,
+ "source": "registry",
+ "dependencies": {},
+ "url": "https://packages.unity.com"
+ },
+ "com.unity.services.multiplayer": {
+ "version": "2.2.4",
+ "depth": 0,
+ "source": "registry",
+ "dependencies": {
+ "com.unity.transport": "2.6.0",
+ "com.unity.collections": "2.2.1",
+ "com.unity.services.qos": "1.4.1",
+ "com.unity.services.core": "1.16.0",
+ "com.unity.services.wire": "1.4.4",
+ "com.unity.services.deployment": "1.7.1",
+ "com.unity.nuget.newtonsoft-json": "3.2.2",
+ "com.unity.modules.unitywebrequest": "1.0.0",
+ "com.unity.services.authentication": "3.6.0"
+ },
+ "url": "https://packages.unity.com"
+ },
+ "com.unity.services.qos": {
+ "version": "1.4.1",
+ "depth": 1,
+ "source": "registry",
+ "dependencies": {
+ "com.unity.collections": "1.2.4",
+ "com.unity.services.core": "1.12.5",
+ "com.unity.nuget.newtonsoft-json": "3.0.2",
+ "com.unity.modules.unitywebrequest": "1.0.0",
+ "com.unity.services.authentication": "3.5.2"
+ },
+ "url": "https://packages.unity.com"
+ },
+ "com.unity.services.wire": {
+ "version": "1.4.4",
+ "depth": 1,
+ "source": "registry",
+ "dependencies": {
+ "com.unity.services.core": "1.12.5",
+ "com.unity.nuget.newtonsoft-json": "3.2.1",
+ "com.unity.services.authentication": "2.7.4"
+ },
+ "url": "https://packages.unity.com"
+ },
"com.unity.settings-manager": {
"version": "2.1.1",
"depth": 2,
@@ -191,7 +272,7 @@
},
"com.unity.test-framework.performance": {
"version": "3.2.0",
- "depth": 3,
+ "depth": 2,
"source": "registry",
"dependencies": {
"com.unity.test-framework": "1.1.33",
@@ -249,6 +330,12 @@
"com.unity.modules.subsystems": "1.0.0"
}
},
+ "com.unity.modules.androidjni": {
+ "version": "1.0.0",
+ "depth": 2,
+ "source": "builtin",
+ "dependencies": {}
+ },
"com.unity.modules.animation": {
"version": "1.0.0",
"depth": 0,