diff --git a/Game/Scripts/Networking/LobbyGameSceneCoordinator.cs b/Game/Scripts/Networking/LobbyGameSceneCoordinator.cs
new file mode 100644
index 0000000..0b24851
--- /dev/null
+++ b/Game/Scripts/Networking/LobbyGameSceneCoordinator.cs
@@ -0,0 +1,252 @@
+using System;
+using System.Collections.Generic;
+using UnityEngine;
+using UnityEngine.SceneManagement;
+using MegaKoop.Game;
+
+#if STEAMWORKSNET
+using Steamworks;
+#endif
+
+namespace MegaKoop.Game.Networking
+{
+ ///
+ /// Handles transitioning from the lobby into the character scene and spawning player avatars.
+ /// Lives alongside the Steam services object so it survives scene loads.
+ ///
+ [DefaultExecutionOrder(-500)]
+ public class LobbyGameSceneCoordinator : MonoBehaviour
+ {
+ [SerializeField] private string characterSceneName = "CharacterScene";
+ [SerializeField] private float spawnRadius = 3f;
+ [SerializeField] private float minimumSpawnSpacing = 2.5f;
+
+ private readonly List pendingPlayers = new();
+ private string localSteamId = string.Empty;
+ private bool loadPending;
+ private bool hasSpawned;
+
+ private void Awake()
+ {
+ DontDestroyOnLoad(gameObject);
+ SceneManager.sceneLoaded += HandleSceneLoaded;
+ }
+
+ private void OnDestroy()
+ {
+ SceneManager.sceneLoaded -= HandleSceneLoaded;
+ }
+
+ ///
+ /// Captures the current lobby membership and moves everyone into the character scene.
+ ///
+ public void BeginGame(MegaKoop.Steam.SteamLobbyService steam)
+ {
+ if (steam == null)
+ {
+ Debug.LogWarning("[LobbyGameSceneCoordinator] SteamLobbyService missing; cannot begin game.");
+ return;
+ }
+
+ CapturePlayers(steam);
+
+ if (!string.Equals(SceneManager.GetActiveScene().name, characterSceneName, StringComparison.OrdinalIgnoreCase))
+ {
+ loadPending = true;
+ hasSpawned = false;
+ SceneManager.LoadScene(characterSceneName);
+ }
+ else
+ {
+ // Already in the target scene (e.g., during hot reload).
+ loadPending = false;
+ hasSpawned = false;
+ SpawnPlayersInScene(SceneManager.GetActiveScene());
+ }
+ }
+
+ private void CapturePlayers(MegaKoop.Steam.SteamLobbyService steam)
+ {
+ pendingPlayers.Clear();
+ localSteamId = steam.LocalSteamIdString ?? string.Empty;
+
+ var members = steam.GetMembers();
+ if (members != null)
+ {
+ foreach (var member in members)
+ {
+ pendingPlayers.Add(new LobbyPlayerInfo
+ {
+ SteamId = member.steamId,
+ DisplayName = string.IsNullOrWhiteSpace(member.name) ? "Player" : member.name,
+ IsLocal = member.steamId == localSteamId
+ });
+ }
+ }
+
+ // Ensure the local player is represented even if lobby data is empty (e.g., offline stub).
+ if (!pendingPlayers.Exists(p => p.IsLocal))
+ {
+ pendingPlayers.Add(new LobbyPlayerInfo
+ {
+ SteamId = localSteamId,
+ DisplayName = GetFallbackLocalName(),
+ IsLocal = true
+ });
+ }
+ }
+
+ private void HandleSceneLoaded(Scene scene, LoadSceneMode mode)
+ {
+ if (!string.Equals(scene.name, characterSceneName, StringComparison.OrdinalIgnoreCase))
+ {
+ return;
+ }
+
+ if (!loadPending && hasSpawned)
+ {
+ return;
+ }
+
+ loadPending = false;
+ hasSpawned = false;
+ SpawnPlayersInScene(scene);
+ }
+
+ private void SpawnPlayersInScene(Scene scene)
+ {
+ if (hasSpawned)
+ {
+ return;
+ }
+
+ var template = FindWizardTemplate(scene);
+ if (template == null)
+ {
+ Debug.LogWarning("[LobbyGameSceneCoordinator] Wizard template not found in CharacterScene; cannot spawn players.");
+ return;
+ }
+
+ hasSpawned = true;
+
+ Vector3 basePosition = template.transform.position;
+ Quaternion baseRotation = template.transform.rotation;
+ Transform parent = template.transform.parent;
+
+ // Hide template so only spawned copies are visible.
+ template.SetActive(false);
+
+ int total = Mathf.Max(1, pendingPlayers.Count);
+ for (int i = 0; i < total; i++)
+ {
+ LobbyPlayerInfo info = i < pendingPlayers.Count
+ ? pendingPlayers[i]
+ : new LobbyPlayerInfo { DisplayName = $"Player {i + 1}", IsLocal = i == 0 };
+
+ Vector3 spawnPosition = CalculateSpawnPosition(basePosition, i, total);
+ var clone = Instantiate(template, spawnPosition, baseRotation, parent);
+ clone.name = BuildPlayerName(info, i);
+
+ ConfigureCloneForPlayer(clone, info);
+
+ clone.SetActive(true);
+ }
+ }
+
+ private static GameObject FindWizardTemplate(Scene scene)
+ {
+ foreach (var root in scene.GetRootGameObjects())
+ {
+ if (root.name.Equals("Wizard", StringComparison.OrdinalIgnoreCase))
+ {
+ return root;
+ }
+
+ var controller = root.GetComponentInChildren(true);
+ if (controller != null)
+ {
+ return controller.gameObject;
+ }
+ }
+ return null;
+ }
+
+ private Vector3 CalculateSpawnPosition(Vector3 center, int index, int total)
+ {
+ if (total <= 1)
+ {
+ return center;
+ }
+
+ float radiusForRing = Mathf.Max(spawnRadius, minimumSpawnSpacing) * Mathf.Ceil((index + 1) / 6f);
+ float angle = (Mathf.PI * 2f / Mathf.Max(1, Mathf.Min(total, 6))) * (index % 6);
+ Vector3 offset = new Vector3(Mathf.Cos(angle), 0f, Mathf.Sin(angle)) * radiusForRing;
+ return center + offset;
+ }
+
+ private void ConfigureCloneForPlayer(GameObject clone, LobbyPlayerInfo info)
+ {
+ var controller = clone.GetComponent();
+ if (controller != null)
+ {
+ controller.enabled = info.IsLocal;
+ }
+
+ // Local player keeps the camera and audio; remote avatars are visual only.
+ var cameras = clone.GetComponentsInChildren(true);
+ foreach (var camera in cameras)
+ {
+ camera.enabled = info.IsLocal;
+ if (!info.IsLocal)
+ {
+ camera.gameObject.SetActive(false);
+ }
+ }
+
+ var listeners = clone.GetComponentsInChildren(true);
+ foreach (var listener in listeners)
+ {
+ listener.enabled = info.IsLocal;
+ if (!info.IsLocal)
+ {
+ listener.gameObject.SetActive(false);
+ }
+ }
+
+ var thirdPersonCamera = clone.GetComponentInChildren(true);
+ if (thirdPersonCamera != null)
+ {
+ thirdPersonCamera.enabled = info.IsLocal;
+ if (info.IsLocal)
+ {
+ thirdPersonCamera.SetTarget(clone.transform);
+ }
+ }
+ }
+
+ private static string BuildPlayerName(LobbyPlayerInfo info, int index)
+ {
+ string baseName = !string.IsNullOrWhiteSpace(info.DisplayName) ? info.DisplayName : $"Player {index + 1}";
+ return $"Wizard_{baseName}";
+ }
+
+ private static string GetFallbackLocalName()
+ {
+#if STEAMWORKSNET
+ if (SteamBootstrap.IsInitialized)
+ {
+ return SteamFriends.GetPersonaName();
+ }
+#endif
+ return "You";
+ }
+
+ [Serializable]
+ private struct LobbyPlayerInfo
+ {
+ public string SteamId;
+ public string DisplayName;
+ public bool IsLocal;
+ }
+ }
+}
diff --git a/Game/Scripts/Networking/LobbyGameSceneCoordinator.cs.meta b/Game/Scripts/Networking/LobbyGameSceneCoordinator.cs.meta
new file mode 100644
index 0000000..107f646
--- /dev/null
+++ b/Game/Scripts/Networking/LobbyGameSceneCoordinator.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 46f2c531c179d29e39aa831ff5290c20
\ No newline at end of file
diff --git a/Settings/Build Profiles.meta b/Settings/Build Profiles.meta
new file mode 100644
index 0000000..5f540be
--- /dev/null
+++ b/Settings/Build Profiles.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: eda40d14141ed13b99eb74bd3cbb962b
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Settings/Build Profiles/Linux.asset b/Settings/Build Profiles/Linux.asset
new file mode 100644
index 0000000..7417bc7
--- /dev/null
+++ b/Settings/Build Profiles/Linux.asset
@@ -0,0 +1,44 @@
+%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: 15003, guid: 0000000000000000e000000000000000, type: 0}
+ m_Name: Linux
+ m_EditorClassIdentifier: UnityEditor.dll::UnityEditor.Build.Profile.BuildProfile
+ m_AssetVersion: 1
+ m_BuildTarget: 24
+ m_Subtarget: 2
+ m_PlatformId: cb423bfea44b4d658edb8bc5d91a3024
+ m_PlatformBuildProfile:
+ rid: 8527041553903386862
+ m_OverrideGlobalSceneList: 0
+ m_Scenes: []
+ m_ScriptingDefines: []
+ m_PlayerSettingsYaml:
+ m_Settings: []
+ references:
+ version: 2
+ RefIds:
+ - rid: 8527041553903386862
+ type: {class: LinuxPlatformSettings, ns: UnityEditor.LinuxStandalone, asm: UnityEditor.LinuxStandalone.Extensions}
+ data:
+ m_Development: 0
+ m_ConnectProfiler: 0
+ m_BuildWithDeepProfilingSupport: 0
+ m_AllowDebugging: 0
+ m_WaitForManagedDebugger: 0
+ m_ManagedDebuggerFixedPort: 0
+ m_ExplicitNullChecks: 0
+ m_ExplicitDivideByZeroChecks: 0
+ m_ExplicitArrayBoundsChecks: 0
+ m_CompressionType: -1
+ m_InstallInBuildFolder: 0
+ m_InsightsSettingsContainer:
+ m_BuildProfileEngineDiagnosticsState: 2
diff --git a/Settings/Build Profiles/Linux.asset.meta b/Settings/Build Profiles/Linux.asset.meta
new file mode 100644
index 0000000..aa07c6b
--- /dev/null
+++ b/Settings/Build Profiles/Linux.asset.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 05f210c1ba43a51c6a548f8030a4cfec
+NativeFormatImporter:
+ externalObjects: {}
+ mainObjectFileID: 11400000
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/UI/Scripts/UGUIMultiplayerLobbyController.cs b/UI/Scripts/UGUIMultiplayerLobbyController.cs
index d4ff621..f3b0064 100644
--- a/UI/Scripts/UGUIMultiplayerLobbyController.cs
+++ b/UI/Scripts/UGUIMultiplayerLobbyController.cs
@@ -5,6 +5,7 @@ using UnityEngine.UI;
using UnityEngine.EventSystems;
using MegaKoop.Steam;
using MegaKoop.Networking;
+using MegaKoop.Game.Networking;
using TMPro;
namespace MegaKoop.UI
@@ -77,6 +78,7 @@ namespace MegaKoop.UI
// Steam service
private SteamLobbyService steam;
+ private LobbyGameSceneCoordinator lobbyGameCoordinator;
// Local state cache
private bool IsInLobby => steam != null && steam.IsInLobby;
@@ -247,6 +249,7 @@ namespace MegaKoop.UI
if (steam.IsStartSignaled() && !clientStartedFromSignal)
{
clientStartedFromSignal = true;
+ lobbyGameCoordinator?.BeginGame(steam);
#if UNITY_NETCODE || NETCODE_PRESENT
// Configure transport with host SteamID before starting client
var adapter = GetSteamAdapter();
@@ -327,6 +330,8 @@ namespace MegaKoop.UI
#region Steam
private void EnsureSteamServices()
{
+ GameObject servicesRoot = null;
+
if (steam == null)
{
// Unity 2023+: use FindFirstObjectByType; older: FindObjectOfType
@@ -335,14 +340,34 @@ namespace MegaKoop.UI
#else
steam = Object.FindObjectOfType();
#endif
+ if (steam != null)
+ {
+ servicesRoot = steam.gameObject;
+ }
+
if (steam == null)
{
- var go = GameObject.Find("SteamServices") ?? new GameObject("SteamServices");
- if (go.GetComponent() == null) go.AddComponent();
- steam = go.GetComponent() ?? go.AddComponent();
- DontDestroyOnLoad(go);
+ servicesRoot = GameObject.Find("SteamServices") ?? new GameObject("SteamServices");
+ if (servicesRoot.GetComponent() == null)
+ {
+ servicesRoot.AddComponent();
+ }
+ steam = servicesRoot.GetComponent() ?? servicesRoot.AddComponent();
+ DontDestroyOnLoad(servicesRoot);
}
}
+
+ if (steam != null)
+ {
+ servicesRoot ??= steam.gameObject;
+
+ if (servicesRoot.GetComponent() == null)
+ {
+ servicesRoot.AddComponent();
+ }
+
+ lobbyGameCoordinator = servicesRoot.GetComponent() ?? servicesRoot.AddComponent();
+ }
}
private void RegisterSteamEvents()
@@ -625,6 +650,7 @@ namespace MegaKoop.UI
return;
}
steam?.StartGameSignal();
+ lobbyGameCoordinator?.BeginGame(steam);
#if UNITY_NETCODE || NETCODE_PRESENT
// Optionally start host and load scene
var adapter = GetSteamAdapter();