diff --git a/Assets/_Project/Scripts/Gameplay/LobbyService.cs b/Assets/_Project/Scripts/Gameplay/LobbyService.cs
new file mode 100644
index 0000000..3515407
--- /dev/null
+++ b/Assets/_Project/Scripts/Gameplay/LobbyService.cs
@@ -0,0 +1,165 @@
+// Assets/_Project/Scripts/Gameplay/LobbyService.cs
+using Unity.Netcode;
+using UnityEngine;
+using TD.Core;
+using TD.Net;
+
+namespace TD.Gameplay
+{
+ ///
+ /// Scene singleton that owns lobby-wide actions: Start Match (host),
+ /// Return to Lobby (after a match), and the "host disconnected" broadcast.
+ /// Lives in the Lobby scene with a NetworkObject; spawns when the
+ /// scene loads.
+ ///
+ ///
+ /// What lives here vs. on PlayerMatchState. Per-player state
+ /// (race, ready, slot, display name) lives on
+ /// because it carries across scene transitions with the player connection.
+ /// Lobby-wide ACTIONS (Start Match, Return to Lobby) live here because they
+ /// are scoped to the lobby scene and don't need to persist.
+ ///
+ /// Start Match flow.
+ ///
+ /// - Host clicks Start in the lobby UI.
+ /// - UI calls ; server validates:
+ /// every PlayerMatchState has a race and IsReady.
+ /// - Server calls NetworkBootstrap.LoadSceneAsHost(SceneNames.Match).
+ /// NGO replicates the scene load to every client.
+ /// - Match scene loads; spawns there; gameplay
+ /// begins as usual.
+ ///
+ ///
+ /// Return to Lobby flow. Triggered by the match-end overlay's
+ /// "Retry" button. Server calls ;
+ /// resets on every player; loads
+ /// the Lobby scene. Race picks are preserved so players don't have to
+ /// re-select.
+ ///
+ /// Host-leaves behavior (Option A). When the host quits, NGO
+ /// shuts down the connection for every client. Clients detect the disconnect
+ /// in (subscribed to
+ /// NetworkManager.OnClientDisconnectCallback on the local client)
+ /// and load the MainMenu scene locally. See roadmap §1.7-Future Steam Lobby
+ /// Migration for the Option C upgrade (lobby persists, new host elected)
+ /// once Steam SDK is in.
+ ///
+ [RequireComponent(typeof(NetworkObject))]
+ public class LobbyService : NetworkBehaviour
+ {
+ // ----- Singleton --------------------------------------------------
+
+ public static LobbyService Instance { get; private set; }
+
+ // ----- Lifecycle --------------------------------------------------
+
+ public override void OnNetworkSpawn()
+ {
+ if (Instance != null && Instance != this)
+ {
+ Debug.LogError("[LobbyService] Duplicate instance detected. " +
+ "Only one LobbyService should exist per scene.");
+ return;
+ }
+ Instance = this;
+
+ // Entering the lobby always starts everyone un-ready. Race picks
+ // are preserved from the previous lobby visit, but ready state
+ // resets so each match requires explicit re-readying.
+ if (IsServer)
+ ResetAllReady();
+ }
+
+ public override void OnNetworkDespawn()
+ {
+ if (Instance == this) Instance = null;
+ }
+
+ // ----- Public RPC API (client → server) --------------------------
+
+ ///
+ /// Host-only request to begin the match. Validates that every connected
+ /// player has picked a race and is ready, then transitions every peer
+ /// to the Match scene via NGO scene management.
+ ///
+ [Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Everyone)]
+ public void RequestStartMatchRpc(RpcParams rpcParams = default)
+ {
+ // Only the host's local client may start a match. We compare the
+ // sender's clientId to the server's clientId (which == the host's
+ // local clientId in host mode).
+ if (rpcParams.Receive.SenderClientId != NetworkManager.Singleton.LocalClientId)
+ {
+ Debug.LogWarning("[LobbyService] Non-host client attempted to start the match. Ignored.");
+ return;
+ }
+
+ if (!AreAllPlayersReady(out string reason))
+ {
+ Debug.Log($"[LobbyService] Cannot start match: {reason}");
+ return;
+ }
+
+ NetworkBootstrap.LoadSceneAsHost(SceneNames.Match);
+ }
+
+ ///
+ /// Anyone can request a return to the lobby (typically the host clicks
+ /// "Retry" after a match). Server-side: resets ready state on every
+ /// player and triggers the scene transition. Race picks are preserved.
+ ///
+ [Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Everyone)]
+ public void RequestReturnToLobbyRpc()
+ {
+ // We don't gate this on host-only currently — any client retrying is
+ // semantically equivalent (the host needs to be willing to keep
+ // running). When dedicated server / Steam migration lands this
+ // becomes host-only.
+ ResetAllReady();
+ NetworkBootstrap.LoadSceneAsHost(SceneNames.Lobby);
+ }
+
+ // ----- Public queries --------------------------------------------
+
+ ///
+ /// True if every connected player has picked a race AND marked themselves
+ /// ready. Returns the reason as when false
+ /// (for debug logs / future UI tooltips on the disabled Start button).
+ ///
+ public static bool AreAllPlayersReady(out string reason)
+ {
+ int count = 0;
+ foreach (var pms in PlayerMatchState.AllPlayers)
+ {
+ count++;
+ if (pms.RaceSelection == RaceId.None)
+ {
+ reason = $"Slot {pms.Slot} hasn't picked a race.";
+ return false;
+ }
+ if (!pms.IsReady)
+ {
+ reason = $"Slot {pms.Slot} is not ready.";
+ return false;
+ }
+ }
+
+ if (count == 0)
+ {
+ reason = "No players connected.";
+ return false;
+ }
+
+ reason = string.Empty;
+ return true;
+ }
+
+ // ----- Server helpers --------------------------------------------
+
+ private static void ResetAllReady()
+ {
+ foreach (var pms in PlayerMatchState.AllPlayers)
+ pms.SetReady(false);
+ }
+ }
+}
diff --git a/Assets/_Project/Scripts/Gameplay/LobbyService.cs.meta b/Assets/_Project/Scripts/Gameplay/LobbyService.cs.meta
new file mode 100644
index 0000000..e178ddd
--- /dev/null
+++ b/Assets/_Project/Scripts/Gameplay/LobbyService.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 46ab03868ea4bd541bd6be3446c2bd3d
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/Gameplay/PlayerMatchState.cs b/Assets/_Project/Scripts/Gameplay/PlayerMatchState.cs
index 884a5c8..212dbd7 100644
--- a/Assets/_Project/Scripts/Gameplay/PlayerMatchState.cs
+++ b/Assets/_Project/Scripts/Gameplay/PlayerMatchState.cs
@@ -89,30 +89,91 @@ namespace TD.Gameplay
writePerm: NetworkVariableWritePermission.Server
);
- // STUBBED — written by the race-pick system (Phase 1.8). None until a race is chosen.
- [Tooltip("STUBBED — not written until the race-pick system lands (Phase 1.8).")]
+ // Race chosen by this player. Written by the lobby system in response to
+ // SetRaceRpc; carries over scene transitions because PlayerMatchState is
+ // the player prefab (owned by the connection, persists across scenes).
private readonly NetworkVariable raceSelection = new NetworkVariable(
value: RaceId.None,
readPerm: NetworkVariableReadPermission.Everyone,
writePerm: NetworkVariableWritePermission.Server
);
+ // Lobby ready-state. Cleared back to false on scene transitions out of
+ // the lobby (the new lobby session starts everyone un-ready).
+ private readonly NetworkVariable isReady = new NetworkVariable(
+ value: false,
+ readPerm: NetworkVariableReadPermission.Everyone,
+ writePerm: NetworkVariableWritePermission.Server
+ );
+
/// This player's assigned slot in the match. Authoritative once spawned.
public PlayerSlot Slot => slot.Value;
/// Display name. Stub until a lobby/name system provides it.
public string DisplayName => displayName.Value.ToString();
- /// STUBBED. Race chosen by this player. until Phase 1.8.
+ /// Race chosen by this player. until selected in the lobby.
public RaceId RaceSelection => raceSelection.Value;
- /// STUBBED. Sets this player's race selection. Called by the race-pick system (Phase 1.8).
+ /// True if this player has marked themselves ready in the lobby.
+ public bool IsReady => isReady.Value;
+
+ /// Server-only. Sets this player's race selection.
public void SetRaceSelection(RaceId race)
{
if (!IsServer) { Debug.LogWarning("[PlayerMatchState] SetRaceSelection called on a client — ignored."); return; }
raceSelection.Value = race;
}
+ /// Server-only. Sets this player's ready state.
+ public void SetReady(bool ready)
+ {
+ if (!IsServer) { Debug.LogWarning("[PlayerMatchState] SetReady called on a client — ignored."); return; }
+ isReady.Value = ready;
+ }
+
+ // ----- Client → Server lobby RPCs --------------------------------
+
+ ///
+ /// Client → server: submit a race selection. Owner-only — players can
+ /// only change their own race. Validates that the caller is the owner
+ /// of this PlayerMatchState; ignored otherwise.
+ ///
+ [Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Owner)]
+ public void SubmitRaceRpc(RaceId race)
+ {
+ // Reset ready state when race changes — players shouldn't be "ready"
+ // with a stale pick.
+ if (raceSelection.Value != race)
+ {
+ raceSelection.Value = race;
+ isReady.Value = false;
+ }
+ }
+
+ ///
+ /// Client → server: submit ready state. Owner-only. A player can only
+ /// ready up if they've picked a race; the lobby UI gates this already
+ /// but the server enforces it as a safety net.
+ ///
+ [Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Owner)]
+ public void SubmitReadyRpc(bool ready)
+ {
+ if (ready && raceSelection.Value == RaceId.None)
+ {
+ Debug.Log($"[PlayerMatchState] Slot {Slot} tried to ready up without a race. Ignored.");
+ return;
+ }
+ isReady.Value = ready;
+ }
+
+ ///
+ /// Server-side helper: enumerate every currently-spawned PlayerMatchState.
+ /// Used by LobbyService to evaluate "are all players ready" and
+ /// by LobbyController on every peer to render the player list.
+ ///
+ public static IEnumerable AllPlayers => s_byClientId.Values;
+
///
/// Server-only. Fires on the server immediately after the slot is assigned in
/// . Sibling components (e.g. PlayerBuilderSpawner)
diff --git a/Assets/_Project/Scripts/Net.meta b/Assets/_Project/Scripts/Net.meta
new file mode 100644
index 0000000..409a755
--- /dev/null
+++ b/Assets/_Project/Scripts/Net.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 6bc7137210913b24da6d85326de66ed5
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/_Project/Scripts/Net/NetworkBootstrap.cs b/Assets/_Project/Scripts/Net/NetworkBootstrap.cs
new file mode 100644
index 0000000..c36241e
--- /dev/null
+++ b/Assets/_Project/Scripts/Net/NetworkBootstrap.cs
@@ -0,0 +1,190 @@
+// Assets/_Project/Scripts/Net/NetworkBootstrap.cs
+using System;
+using Unity.Netcode;
+using Unity.Netcode.Transports.UTP;
+using UnityEngine;
+
+namespace TD.Net
+{
+ ///
+ /// Static facade for starting a host, joining a host, and disconnecting.
+ /// Wraps NGO's and
+ /// so the rest of the codebase (lobby UI, main menu UI) doesn't talk to
+ /// NGO directly — and so the transport implementation can be swapped
+ /// without touching lobby code.
+ ///
+ ///
+ /// Why a static facade. Today this is a thin wrapper over
+ /// . When Steam integration lands
+ /// (see Project_Roadmap.md §1.7-Future Steam Lobby Migration), this
+ /// class becomes the seam: it grows into an IConnectionProvider
+ /// abstraction with two implementations (DirectIpConnectionProvider
+ /// for LAN / DRM-free distribution, SteamConnectionProvider for
+ /// Steam friend-invite + lobby-browser flows). The lobby UI never changes;
+ /// only this class does.
+ ///
+ /// Default port. NGO's NetworkConfig holds the canonical
+ /// transport settings (port, listen address).
+ /// is just the value we use when the user doesn't override it in the UI.
+ ///
+ /// Scene management. NGO's SceneManager.LoadScene is
+ /// called by the host AFTER succeeds — see
+ /// . The lobby and match scene names are
+ /// canonicalized in .
+ ///
+ public static class NetworkBootstrap
+ {
+ // ----- Configuration ---------------------------------------------
+
+ /// Default port used by Host / Join when the UI doesn't override.
+ public const ushort DefaultPort = 7777;
+
+ /// Default listen address for the host. 0.0.0.0 listens on all interfaces.
+ public const string DefaultListenAddress = "0.0.0.0";
+
+ /// Default address clients use when no IP is entered (loopback for solo testing).
+ public const string DefaultConnectAddress = "127.0.0.1";
+
+ // ----- Public API ------------------------------------------------
+
+ /// True if a host or client connection is currently active.
+ public static bool IsConnected =>
+ NetworkManager.Singleton != null
+ && (NetworkManager.Singleton.IsHost
+ || NetworkManager.Singleton.IsServer
+ || NetworkManager.Singleton.IsClient);
+
+ /// True if the local peer is the host (server + client in one process).
+ public static bool IsHost =>
+ NetworkManager.Singleton != null && NetworkManager.Singleton.IsHost;
+
+ ///
+ /// Starts a host on :.
+ /// Returns true on success. Logs and returns false if NetworkManager
+ /// is missing or already running.
+ ///
+ public static bool StartHost(ushort port = DefaultPort, string listenAddress = DefaultListenAddress)
+ {
+ var nm = NetworkManager.Singleton;
+ if (nm == null)
+ {
+ Debug.LogError("[NetworkBootstrap] NetworkManager.Singleton is null. " +
+ "Make sure a NetworkManager exists in the active scene.");
+ return false;
+ }
+ if (IsConnected)
+ {
+ Debug.LogWarning("[NetworkBootstrap] StartHost called while already connected. Ignored.");
+ return false;
+ }
+
+ ConfigureTransport(listenAddress, port);
+
+ bool ok = nm.StartHost();
+ if (!ok) Debug.LogError("[NetworkBootstrap] NetworkManager.StartHost() returned false.");
+ return ok;
+ }
+
+ ///
+ /// Starts a client and connects to :.
+ /// Returns true on success. Logs and returns false if NetworkManager
+ /// is missing or already running.
+ ///
+ public static bool StartClient(string address, ushort port = DefaultPort)
+ {
+ var nm = NetworkManager.Singleton;
+ if (nm == null)
+ {
+ Debug.LogError("[NetworkBootstrap] NetworkManager.Singleton is null. " +
+ "Make sure a NetworkManager exists in the active scene.");
+ return false;
+ }
+ if (IsConnected)
+ {
+ Debug.LogWarning("[NetworkBootstrap] StartClient called while already connected. Ignored.");
+ return false;
+ }
+ if (string.IsNullOrWhiteSpace(address))
+ address = DefaultConnectAddress;
+
+ ConfigureTransport(address, port);
+
+ bool ok = nm.StartClient();
+ if (!ok) Debug.LogError("[NetworkBootstrap] NetworkManager.StartClient() returned false.");
+ return ok;
+ }
+
+ ///
+ /// Shuts down the current host or client connection. Safe to call when
+ /// already disconnected.
+ ///
+ public static void Disconnect()
+ {
+ var nm = NetworkManager.Singleton;
+ if (nm == null) return;
+ if (!IsConnected) return;
+
+ nm.Shutdown();
+ }
+
+ ///
+ /// Server-only helper that transitions every peer to
+ /// via NGO's networked scene manager. Logs and no-ops if not the server,
+ /// if NGO scene management is disabled, or if the scene name is invalid.
+ ///
+ public static void LoadSceneAsHost(string sceneName)
+ {
+ var nm = NetworkManager.Singleton;
+ if (nm == null) { Debug.LogError("[NetworkBootstrap] NetworkManager null."); return; }
+ if (!nm.IsServer)
+ {
+ Debug.LogWarning($"[NetworkBootstrap] LoadSceneAsHost('{sceneName}') called on a client. Ignored.");
+ return;
+ }
+ if (nm.SceneManager == null)
+ {
+ Debug.LogError("[NetworkBootstrap] NetworkManager.SceneManager is null — " +
+ "Enable Scene Management on the NetworkManager.");
+ return;
+ }
+ nm.SceneManager.LoadScene(sceneName, UnityEngine.SceneManagement.LoadSceneMode.Single);
+ }
+
+ // ----- Internals --------------------------------------------------
+
+ // Writes connection data into the UnityTransport component attached to
+ // the NetworkManager. Doesn't allocate; reuses the transport instance
+ // that's already in the scene.
+ //
+ // FUTURE STEAM MIGRATION: when SteamConnectionProvider lands, this
+ // method's body is replaced (or split) to configure the appropriate
+ // transport. The public API above stays as-is so call sites in the
+ // lobby UI don't change.
+ private static void ConfigureTransport(string address, ushort port)
+ {
+ var nm = NetworkManager.Singleton;
+ var transport = nm.NetworkConfig?.NetworkTransport as UnityTransport;
+ if (transport == null)
+ {
+ Debug.LogError("[NetworkBootstrap] NetworkManager's transport is not UnityTransport. " +
+ "If you switched transports (e.g. Steam), update NetworkBootstrap " +
+ "to configure the new one.");
+ return;
+ }
+ transport.SetConnectionData(address, port, DefaultListenAddress);
+ }
+ }
+
+ ///
+ /// Canonical scene names used by scene transitions.
+ /// Centralized so renames or additions touch one place.
+ ///
+ public static class SceneNames
+ {
+ public const string MainMenu = "MainMenu";
+ public const string Lobby = "Lobby";
+
+ /// The gameplay scene (currently "Main" — the original prototype scene).
+ public const string Match = "Main";
+ }
+}
diff --git a/Assets/_Project/Scripts/Net/NetworkBootstrap.cs.meta b/Assets/_Project/Scripts/Net/NetworkBootstrap.cs.meta
new file mode 100644
index 0000000..ed5356d
--- /dev/null
+++ b/Assets/_Project/Scripts/Net/NetworkBootstrap.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 0f83a5afd3f38414da451d1250c92be4
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/Net/SessionFlow.cs b/Assets/_Project/Scripts/Net/SessionFlow.cs
new file mode 100644
index 0000000..38f3d6e
--- /dev/null
+++ b/Assets/_Project/Scripts/Net/SessionFlow.cs
@@ -0,0 +1,136 @@
+// Assets/_Project/Scripts/Net/SessionFlow.cs
+using Unity.Netcode;
+using UnityEngine;
+using UnityEngine.SceneManagement;
+
+namespace TD.Net
+{
+ ///
+ /// Bootstrap script — drop one on a GameObject in the MainMenu scene so it
+ /// runs once when the app boots. Subscribes to NGO disconnect callbacks
+ /// and routes the local peer back to the MainMenu scene whenever its
+ /// connection ends (host left, server kicked it, transport error, etc.).
+ ///
+ ///
+ /// Why this exists. Per Option A (see Project_Roadmap.md §1.7),
+ /// host disconnect tears down the session for everyone. Each client needs
+ /// to recover gracefully and return to its own main menu. Without this,
+ /// a client would be stuck in the Lobby or Match scene with no working
+ /// network connection after the host quits.
+ ///
+ /// Lifecycle. The NetworkManager itself is marked
+ /// DontDestroyOnLoad by NGO once it spawns. This script is also marked
+ /// DontDestroyOnLoad so it survives scene transitions and its subscription
+ /// stays alive across MainMenu → Lobby → Match → back.
+ ///
+ /// Server side. When the host (server) shuts down, the
+ /// OnServerStopped callback fires on the host too. For the host
+ /// that's fine — they've initiated the shutdown deliberately (e.g., from
+ /// the "Return to Main Menu" button) and being sent to MainMenu is what
+ /// they wanted.
+ ///
+ public class SessionFlow : MonoBehaviour
+ {
+ // ----- Singleton --------------------------------------------------
+
+ public static SessionFlow Instance { get; private set; }
+
+ private void Awake()
+ {
+ if (Instance != null && Instance != this)
+ {
+ Destroy(gameObject);
+ return;
+ }
+ Instance = this;
+ DontDestroyOnLoad(gameObject);
+ }
+
+ private void Start()
+ {
+ HookCallbacks();
+ }
+
+ private void OnDestroy()
+ {
+ UnhookCallbacks();
+ if (Instance == this) Instance = null;
+ }
+
+ // ----- NGO callback wiring ---------------------------------------
+
+ private bool callbacksHooked;
+
+ private void HookCallbacks()
+ {
+ var nm = NetworkManager.Singleton;
+ if (nm == null)
+ {
+ // NetworkManager may not be alive yet on the very first frame
+ // depending on script execution order — retry next frame.
+ Invoke(nameof(HookCallbacks), 0.1f);
+ return;
+ }
+ if (callbacksHooked) return;
+
+ nm.OnClientDisconnectCallback += HandleClientDisconnect;
+ nm.OnServerStopped += HandleServerStopped;
+ callbacksHooked = true;
+ }
+
+ private void UnhookCallbacks()
+ {
+ if (!callbacksHooked) return;
+ var nm = NetworkManager.Singleton;
+ if (nm != null)
+ {
+ nm.OnClientDisconnectCallback -= HandleClientDisconnect;
+ nm.OnServerStopped -= HandleServerStopped;
+ }
+ callbacksHooked = false;
+ }
+
+ // ----- Disconnect handlers ---------------------------------------
+
+ // Fires when ANY client disconnects on the server, and when the LOCAL
+ // client gets disconnected on a client. We only care about the latter:
+ // when the local peer loses its connection (host left, transport error,
+ // explicit Shutdown call), route back to the main menu.
+ private void HandleClientDisconnect(ulong clientId)
+ {
+ var nm = NetworkManager.Singleton;
+ if (nm == null) return;
+
+ // On the server, this fires when other clients disconnect — ignore
+ // those, the server keeps running. We only act when the LOCAL
+ // client's connection ends.
+ if (clientId != nm.LocalClientId) return;
+
+ ReturnToMainMenu();
+ }
+
+ // Fires on the server when the server shuts down (including the host).
+ // The host's own client also gets HandleClientDisconnect — but
+ // OnServerStopped is the canonical "server is gone" signal and is
+ // safer to act on for the host path.
+ private void HandleServerStopped(bool isHost)
+ {
+ ReturnToMainMenu();
+ }
+
+ // ----- Scene transition ------------------------------------------
+
+ // Returns the local peer to the MainMenu scene if they're not already
+ // there. Uses Unity's local SceneManager (NOT NGO's networked scene
+ // manager) because by the time this fires the network connection is
+ // already gone.
+ private static void ReturnToMainMenu()
+ {
+ if (SceneManager.GetActiveScene().name == SceneNames.MainMenu)
+ return;
+
+ Debug.Log("[SessionFlow] Connection ended — returning to main menu.");
+ SceneManager.LoadScene(SceneNames.MainMenu);
+ }
+ }
+}
diff --git a/Assets/_Project/Scripts/Net/SessionFlow.cs.meta b/Assets/_Project/Scripts/Net/SessionFlow.cs.meta
new file mode 100644
index 0000000..c5cd9a1
--- /dev/null
+++ b/Assets/_Project/Scripts/Net/SessionFlow.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 474003b8e462dcc479e313f3d4f1cf12
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/UI/HUDController.cs b/Assets/_Project/Scripts/UI/HUDController.cs
index 647aeb0..36772ff 100644
--- a/Assets/_Project/Scripts/UI/HUDController.cs
+++ b/Assets/_Project/Scripts/UI/HUDController.cs
@@ -1322,12 +1322,25 @@ namespace TD.UI
matchEndTitle.style.unityFontStyleAndWeight = FontStyle.Bold;
panel.Add(matchEndTitle);
+ // Action row — Retry (back to lobby with everyone who retried)
+ // and Return to Main Menu (this player only disconnects).
+ var actionRow = new VisualElement();
+ actionRow.style.flexDirection = FlexDirection.Row;
+ actionRow.style.marginTop = 8;
+ panel.Add(actionRow);
+
var retryBtn = new Button(OnRetryClicked) { text = "Retry" };
- retryBtn.style.minWidth = 120;
+ retryBtn.style.minWidth = 140;
retryBtn.style.height = 36;
retryBtn.style.fontSize = 16;
- retryBtn.style.marginTop = 8;
- panel.Add(retryBtn);
+ retryBtn.style.marginRight = 12;
+ actionRow.Add(retryBtn);
+
+ var menuBtn = new Button(OnReturnToMainMenuClicked) { text = "Return to Main Menu" };
+ menuBtn.style.minWidth = 200;
+ menuBtn.style.height = 36;
+ menuBtn.style.fontSize = 16;
+ actionRow.Add(menuBtn);
matchEndOverlay.Add(panel);
root.Add(matchEndOverlay);
@@ -1368,41 +1381,34 @@ namespace TD.UI
// sceneLoaded callback survives the scene reload (HUDController dies),
// re-arms StartHost once the fresh scene has finished loading, and
// unsubscribes itself.
+ // Retry: take everyone back to the Lobby scene via LobbyService. The
+ // lobby preserves race picks, clears ready state. Anyone who clicked
+ // Return to Main Menu instead has already disconnected — they don't
+ // come along.
private void OnRetryClicked()
{
+ var svc = LobbyService.Instance;
+ if (svc != null)
+ {
+ svc.RequestReturnToLobbyRpc();
+ return;
+ }
+
+ // Fallback: LobbyService isn't spawned (e.g. testing the gameplay
+ // scene standalone without the lobby flow). Hard-reload the scene.
+ Debug.LogWarning("[HUDController] LobbyService not found — falling back to scene reload.");
var nm = NetworkManager.Singleton;
- if (nm == null)
- {
- Debug.LogWarning("[HUDController] Retry clicked but NetworkManager is null.");
- return;
- }
- if (!nm.IsServer)
- {
- Debug.LogWarning("[HUDController] Retry only works on the host. " +
- "Clients should ask the host to retry.");
- return;
- }
-
- Scene active = SceneManager.GetActiveScene();
- s_pendingHostRestartBuildIndex = active.buildIndex;
- SceneManager.sceneLoaded += OnSceneLoadedForRetry;
-
- nm.Shutdown();
- SceneManager.LoadScene(active.buildIndex);
+ if (nm != null && nm.IsServer && nm.SceneManager != null)
+ nm.SceneManager.LoadScene(SceneManager.GetActiveScene().name, LoadSceneMode.Single);
}
- private static int s_pendingHostRestartBuildIndex = -1;
-
- private static void OnSceneLoadedForRetry(Scene loaded, LoadSceneMode mode)
+ // Return to Main Menu: disconnect only this player. SessionFlow's
+ // OnClientDisconnect handler routes us back to MainMenu locally. Other
+ // peers remain in the match (until the host quits, at which point
+ // SessionFlow on each remaining client routes them out too).
+ private void OnReturnToMainMenuClicked()
{
- if (loaded.buildIndex != s_pendingHostRestartBuildIndex) return;
-
- SceneManager.sceneLoaded -= OnSceneLoadedForRetry;
- s_pendingHostRestartBuildIndex = -1;
-
- var nm = NetworkManager.Singleton;
- if (nm != null) nm.StartHost();
- else Debug.LogWarning("[HUDController] Retry: no NetworkManager in reloaded scene.");
+ TD.Net.NetworkBootstrap.Disconnect();
}
// ----- Helpers ----------------------------------------------------
diff --git a/Assets/_Project/Scripts/UI/LobbyController.cs b/Assets/_Project/Scripts/UI/LobbyController.cs
new file mode 100644
index 0000000..e6906df
--- /dev/null
+++ b/Assets/_Project/Scripts/UI/LobbyController.cs
@@ -0,0 +1,270 @@
+// Assets/_Project/Scripts/UI/LobbyController.cs
+using System.Linq;
+using Unity.Netcode;
+using UnityEngine;
+using UnityEngine.UIElements;
+using TD.Core;
+using TD.Gameplay;
+using TD.Net;
+
+namespace TD.UI
+{
+ ///
+ /// Drives the lobby UI. Requires a on the same
+ /// GameObject. Programmatic UI for v1 — UXML can be added later if needed.
+ ///
+ ///
+ /// What's displayed. A player list with one row per connected
+ /// player showing slot, display name, race pick, and ready ✓. The local
+ /// player's row has interactive controls: a race dropdown (currently a
+ /// placeholder with just until Phase 1.8 fills
+ /// the enum) and a Ready toggle button. The host sees a Start Match
+ /// button at the bottom, enabled only when every player is ready.
+ ///
+ /// Polling vs reactive. The list is rebuilt every Update from
+ /// . PlayerMatchState's
+ /// NetworkVariables expose OnValueChanged events we could
+ /// hook for reactive updates, but polling is simpler at lobby scale
+ /// (max 9 players × a handful of fields each) and avoids tracking
+ /// subscription lifecycles across spawn/despawn.
+ ///
+ /// Race picker placeholder. The enum
+ /// currently only has None. The picker shows a single "Test Race"
+ /// button that calls SubmitRaceRpc(RaceId.None) — temporary, so
+ /// ready-up flow can be exercised end-to-end before Phase 1.8 lands real
+ /// races. Replace with the actual race-selection UI when
+ /// assets exist.
+ ///
+ [RequireComponent(typeof(UIDocument))]
+ public class LobbyController : MonoBehaviour
+ {
+ // ----- Cached UI elements -----------------------------------------
+
+ private VisualElement playerListContainer;
+ private Button startMatchButton;
+ private Button leaveButton;
+ private Label statusLabel;
+
+ // ----- Lifecycle --------------------------------------------------
+
+ private void Start()
+ {
+ var doc = GetComponent();
+ var root = doc?.rootVisualElement;
+ if (root == null)
+ {
+ Debug.LogError("[LobbyController] rootVisualElement is null. " +
+ "Check the UIDocument's Panel Settings.");
+ return;
+ }
+
+ BuildUI(root);
+ }
+
+ private void Update()
+ {
+ if (playerListContainer == null) return;
+ RefreshPlayerList();
+ RefreshStartButton();
+ }
+
+ // ----- UI construction --------------------------------------------
+
+ private void BuildUI(VisualElement root)
+ {
+ root.style.flexDirection = FlexDirection.Column;
+ root.style.alignItems = Align.Center;
+ root.style.justifyContent = Justify.FlexStart;
+ root.style.backgroundColor = new Color(0.05f, 0.05f, 0.08f, 1f);
+ root.style.width = Length.Percent(100);
+ root.style.height = Length.Percent(100);
+ root.style.paddingTop = 40;
+
+ // Title.
+ var title = new Label("Lobby");
+ title.style.fontSize = 36;
+ title.style.color = Color.white;
+ title.style.unityFontStyleAndWeight = FontStyle.Bold;
+ title.style.marginBottom = 24;
+ root.Add(title);
+
+ // Player list container (rebuilt every Update from AllPlayers).
+ playerListContainer = new VisualElement();
+ playerListContainer.style.flexDirection = FlexDirection.Column;
+ playerListContainer.style.minWidth = 520;
+ playerListContainer.style.paddingTop = 12;
+ playerListContainer.style.paddingBottom = 12;
+ playerListContainer.style.paddingLeft = 16;
+ playerListContainer.style.paddingRight = 16;
+ playerListContainer.style.backgroundColor = new Color(0f, 0f, 0f, 0.4f);
+ playerListContainer.style.marginBottom = 24;
+ root.Add(playerListContainer);
+
+ // Action row: Start Match (host) + Leave (everyone).
+ var actionRow = new VisualElement();
+ actionRow.style.flexDirection = FlexDirection.Row;
+ actionRow.style.marginTop = 8;
+ root.Add(actionRow);
+
+ startMatchButton = new Button(OnStartMatchClicked) { text = "Start Match" };
+ startMatchButton.style.minWidth = 200;
+ startMatchButton.style.height = 44;
+ startMatchButton.style.fontSize = 18;
+ startMatchButton.style.marginRight = 16;
+ startMatchButton.SetEnabled(false);
+ actionRow.Add(startMatchButton);
+
+ leaveButton = new Button(OnLeaveClicked) { text = "Leave Lobby" };
+ leaveButton.style.minWidth = 200;
+ leaveButton.style.height = 44;
+ leaveButton.style.fontSize = 18;
+ actionRow.Add(leaveButton);
+
+ statusLabel = new Label(string.Empty);
+ statusLabel.style.color = new Color(1f, 0.6f, 0.3f);
+ statusLabel.style.marginTop = 16;
+ statusLabel.style.minHeight = 20;
+ root.Add(statusLabel);
+ }
+
+ // ----- Per-frame refresh ------------------------------------------
+
+ private void RefreshPlayerList()
+ {
+ playerListContainer.Clear();
+
+ ulong localId = NetworkManager.Singleton != null
+ ? NetworkManager.Singleton.LocalClientId
+ : ulong.MaxValue;
+
+ // Sort by slot for stable ordering. AllPlayers is keyed by clientId
+ // which may not be slot-ordered.
+ var players = PlayerMatchState.AllPlayers.OrderBy(p => (int)p.Slot).ToList();
+
+ foreach (var pms in players)
+ {
+ bool isLocal = pms.OwnerClientId == localId;
+ playerListContainer.Add(BuildPlayerRow(pms, isLocal));
+ }
+
+ if (players.Count == 0)
+ playerListContainer.Add(new Label("(no players connected)") { style = { color = Color.gray } });
+ }
+
+ private VisualElement BuildPlayerRow(PlayerMatchState pms, bool isLocal)
+ {
+ var row = new VisualElement();
+ row.style.flexDirection = FlexDirection.Row;
+ row.style.alignItems = Align.Center;
+ row.style.paddingTop = 6;
+ row.style.paddingBottom = 6;
+ row.style.paddingLeft = 8;
+ row.style.paddingRight = 8;
+ row.style.marginBottom = 4;
+ row.style.backgroundColor = isLocal
+ ? new Color(0.18f, 0.22f, 0.30f)
+ : new Color(0.10f, 0.10f, 0.12f);
+
+ // Slot label.
+ var slotLabel = new Label($"P{(int)pms.Slot}");
+ slotLabel.style.minWidth = 40;
+ slotLabel.style.color = Color.white;
+ slotLabel.style.unityFontStyleAndWeight = FontStyle.Bold;
+ row.Add(slotLabel);
+
+ // Display name.
+ var nameLabel = new Label(string.IsNullOrEmpty(pms.DisplayName)
+ ? $"Player {(int)pms.Slot}"
+ : pms.DisplayName);
+ nameLabel.style.minWidth = 140;
+ nameLabel.style.color = Color.white;
+ row.Add(nameLabel);
+
+ // Race indicator.
+ var raceLabel = new Label($"Race: {pms.RaceSelection}");
+ raceLabel.style.minWidth = 160;
+ raceLabel.style.color = pms.RaceSelection == RaceId.None
+ ? new Color(0.7f, 0.5f, 0.5f)
+ : new Color(0.85f, 0.85f, 0.85f);
+ row.Add(raceLabel);
+
+ // Ready indicator.
+ var readyLabel = new Label(pms.IsReady ? "READY ✓" : "not ready");
+ readyLabel.style.minWidth = 100;
+ readyLabel.style.color = pms.IsReady
+ ? new Color(0.3f, 0.85f, 0.3f)
+ : new Color(0.6f, 0.6f, 0.6f);
+ readyLabel.style.unityFontStyleAndWeight = FontStyle.Bold;
+ row.Add(readyLabel);
+
+ // Local-only controls.
+ if (isLocal)
+ {
+ // PLACEHOLDER race picker — the RaceId enum only has None right
+ // now (Phase 1.8 will fill it). For now the single button submits
+ // RaceId.None, which keeps the ready-up gate effectively a no-op
+ // (the server requires RaceSelection != None to allow ready). To
+ // exercise the flow end-to-end before races exist, comment out
+ // the gate in PlayerMatchState.SubmitReadyRpc.
+ //
+ // TODO (Phase 1.8): replace with a dropdown of RaceDefinition
+ // assets discovered at runtime, each option calling
+ // pms.SubmitRaceRpc(definition.Id).
+ var pickRaceBtn = new Button(() => pms.SubmitRaceRpc(RaceId.None))
+ {
+ text = "Pick Race (stub)"
+ };
+ pickRaceBtn.style.minWidth = 130;
+ pickRaceBtn.style.height = 28;
+ pickRaceBtn.style.marginLeft = 12;
+ row.Add(pickRaceBtn);
+
+ var readyBtn = new Button(() => pms.SubmitReadyRpc(!pms.IsReady))
+ {
+ text = pms.IsReady ? "Unready" : "Ready"
+ };
+ readyBtn.style.minWidth = 100;
+ readyBtn.style.height = 28;
+ readyBtn.style.marginLeft = 8;
+ row.Add(readyBtn);
+ }
+
+ return row;
+ }
+
+ private void RefreshStartButton()
+ {
+ var nm = NetworkManager.Singleton;
+ bool isHost = nm != null && nm.IsHost;
+
+ startMatchButton.style.display = isHost ? DisplayStyle.Flex : DisplayStyle.None;
+ if (!isHost) return;
+
+ bool canStart = LobbyService.AreAllPlayersReady(out string reason);
+ startMatchButton.SetEnabled(canStart);
+ statusLabel.text = canStart ? string.Empty : reason;
+ }
+
+ // ----- Button handlers --------------------------------------------
+
+ private void OnStartMatchClicked()
+ {
+ var svc = LobbyService.Instance;
+ if (svc == null)
+ {
+ Debug.LogError("[LobbyController] LobbyService.Instance is null. " +
+ "Scene setup is incomplete.");
+ return;
+ }
+ svc.RequestStartMatchRpc();
+ }
+
+ private void OnLeaveClicked()
+ {
+ // Disconnect drops our connection. SessionFlow will route the
+ // local peer back to the MainMenu scene when the disconnect
+ // callback fires.
+ NetworkBootstrap.Disconnect();
+ }
+ }
+}
diff --git a/Assets/_Project/Scripts/UI/LobbyController.cs.meta b/Assets/_Project/Scripts/UI/LobbyController.cs.meta
new file mode 100644
index 0000000..50173be
--- /dev/null
+++ b/Assets/_Project/Scripts/UI/LobbyController.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 8c40427d598ba944c82f3790429c5532
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/UI/MainMenuController.cs b/Assets/_Project/Scripts/UI/MainMenuController.cs
new file mode 100644
index 0000000..b72ab09
--- /dev/null
+++ b/Assets/_Project/Scripts/UI/MainMenuController.cs
@@ -0,0 +1,233 @@
+// Assets/_Project/Scripts/UI/MainMenuController.cs
+using Unity.Netcode;
+using UnityEngine;
+using UnityEngine.UIElements;
+using TD.Net;
+
+namespace TD.UI
+{
+ ///
+ /// Drives the main menu UI. Requires a on the same
+ /// GameObject. Builds the UI programmatically — no UXML needed for v1.
+ ///
+ ///
+ /// Buttons.
+ ///
+ /// - Host — calls on
+ /// the default port, then NGO scene-loads the Lobby. Clients that
+ /// join later get pulled into whatever scene the server is in.
+ /// - Join — reveals IP + port fields, then calls
+ /// . The server will pull
+ /// the client into the current networked scene (Lobby or Match)
+ /// once the connection completes.
+ /// - Quit — Application.Quit (no-op in the editor).
+ ///
+ ///
+ /// Future Steam integration. A lobby-browser panel will be
+ /// added here. The Host button will create a Steam lobby instead of a
+ /// direct-IP host; Join becomes "browse public lobbies" + "accept friend
+ /// invite". See Project_Roadmap.md §1.7-Future Steam Lobby Migration.
+ ///
+ [RequireComponent(typeof(UIDocument))]
+ public class MainMenuController : MonoBehaviour
+ {
+ // ----- Inspector --------------------------------------------------
+
+ [Tooltip("Default port shown in the Join field and used by the Host button.")]
+ [SerializeField] private ushort defaultPort = NetworkBootstrap.DefaultPort;
+
+ [Tooltip("Default address pre-filled in the Join field for solo testing.")]
+ [SerializeField] private string defaultJoinAddress = NetworkBootstrap.DefaultConnectAddress;
+
+ // ----- Cached UI elements -----------------------------------------
+
+ private Button hostButton;
+ private Button joinButton;
+ private Button quitButton;
+ private VisualElement joinPanel;
+ private TextField joinAddressField;
+ private TextField joinPortField;
+ private Button joinConfirmButton;
+ private Button joinCancelButton;
+ private Label statusLabel;
+
+ // ----- Lifecycle --------------------------------------------------
+
+ private void Start()
+ {
+ // UIDocument creates its panel in OnEnable, which runs after Awake.
+ // Start is the safe time to access rootVisualElement.
+ var doc = GetComponent();
+ var root = doc?.rootVisualElement;
+ if (root == null)
+ {
+ Debug.LogError("[MainMenuController] rootVisualElement is null. " +
+ "Check the UIDocument's Panel Settings.");
+ return;
+ }
+
+ BuildUI(root);
+ }
+
+ // ----- UI construction --------------------------------------------
+
+ private void BuildUI(VisualElement root)
+ {
+ // Centered vertical stack on a dark background.
+ root.style.flexDirection = FlexDirection.Column;
+ root.style.justifyContent = Justify.Center;
+ root.style.alignItems = Align.Center;
+ root.style.backgroundColor = new Color(0.05f, 0.05f, 0.08f, 1f);
+ // The UIDocument root needs explicit width/height to fill the screen.
+ root.style.width = Length.Percent(100);
+ root.style.height = Length.Percent(100);
+
+ // Title.
+ var title = new Label("Unity Tower Defense");
+ title.style.fontSize = 48;
+ title.style.color = Color.white;
+ title.style.unityFontStyleAndWeight = FontStyle.Bold;
+ title.style.marginBottom = 60;
+ root.Add(title);
+
+ // Primary button column.
+ var buttonColumn = new VisualElement();
+ buttonColumn.style.flexDirection = FlexDirection.Column;
+ buttonColumn.style.alignItems = Align.Center;
+ root.Add(buttonColumn);
+
+ hostButton = MakeMenuButton("Host Game", OnHostClicked);
+ joinButton = MakeMenuButton("Join Game", OnJoinClicked);
+ quitButton = MakeMenuButton("Quit", OnQuitClicked);
+ buttonColumn.Add(hostButton);
+ buttonColumn.Add(joinButton);
+ buttonColumn.Add(quitButton);
+
+ // Join sub-panel — hidden until Join is clicked. Holds the IP+port
+ // fields and the Connect / Cancel buttons.
+ joinPanel = new VisualElement();
+ joinPanel.style.flexDirection = FlexDirection.Column;
+ joinPanel.style.alignItems = Align.Center;
+ joinPanel.style.marginTop = 24;
+ joinPanel.style.paddingTop = 16;
+ joinPanel.style.paddingBottom = 16;
+ joinPanel.style.paddingLeft = 24;
+ joinPanel.style.paddingRight = 24;
+ joinPanel.style.backgroundColor = new Color(0f, 0f, 0f, 0.4f);
+ joinPanel.style.display = DisplayStyle.None;
+ root.Add(joinPanel);
+
+ joinAddressField = new TextField("Host address");
+ joinAddressField.value = defaultJoinAddress;
+ joinAddressField.style.width = 280;
+ joinAddressField.style.color = Color.white;
+ joinPanel.Add(joinAddressField);
+
+ joinPortField = new TextField("Port");
+ joinPortField.value = defaultPort.ToString();
+ joinPortField.style.width = 280;
+ joinPortField.style.marginTop = 8;
+ joinPortField.style.color = Color.white;
+ joinPanel.Add(joinPortField);
+
+ var joinButtons = new VisualElement();
+ joinButtons.style.flexDirection = FlexDirection.Row;
+ joinButtons.style.marginTop = 12;
+ joinPanel.Add(joinButtons);
+
+ joinConfirmButton = new Button(OnJoinConfirmClicked) { text = "Connect" };
+ joinConfirmButton.style.minWidth = 120;
+ joinConfirmButton.style.height = 32;
+ joinConfirmButton.style.marginRight = 8;
+ joinButtons.Add(joinConfirmButton);
+
+ joinCancelButton = new Button(OnJoinCancelClicked) { text = "Cancel" };
+ joinCancelButton.style.minWidth = 120;
+ joinCancelButton.style.height = 32;
+ joinButtons.Add(joinCancelButton);
+
+ // Status line at the bottom for connection feedback.
+ statusLabel = new Label(string.Empty);
+ statusLabel.style.color = new Color(1f, 0.6f, 0.3f);
+ statusLabel.style.marginTop = 32;
+ statusLabel.style.minHeight = 20;
+ root.Add(statusLabel);
+ }
+
+ private static Button MakeMenuButton(string text, System.Action onClick)
+ {
+ var btn = new Button(() => onClick?.Invoke()) { text = text };
+ btn.style.minWidth = 240;
+ btn.style.height = 48;
+ btn.style.fontSize = 20;
+ btn.style.marginBottom = 8;
+ return btn;
+ }
+
+ // ----- Button handlers --------------------------------------------
+
+ private void OnHostClicked()
+ {
+ statusLabel.text = "Starting host…";
+ if (!NetworkBootstrap.StartHost(defaultPort))
+ {
+ statusLabel.text = "Failed to start host. Check the console.";
+ return;
+ }
+
+ // After StartHost the local peer is the server + client. Trigger
+ // the networked scene load to take everyone to the Lobby. NGO
+ // replicates this to any future-joining clients automatically.
+ NetworkBootstrap.LoadSceneAsHost(SceneNames.Lobby);
+ }
+
+ private void OnJoinClicked()
+ {
+ // Toggle the join sub-panel.
+ joinPanel.style.display = joinPanel.style.display == DisplayStyle.None
+ ? DisplayStyle.Flex
+ : DisplayStyle.None;
+ }
+
+ private void OnJoinConfirmClicked()
+ {
+ string address = string.IsNullOrWhiteSpace(joinAddressField.value)
+ ? defaultJoinAddress
+ : joinAddressField.value.Trim();
+
+ ushort port = defaultPort;
+ if (!string.IsNullOrWhiteSpace(joinPortField.value)
+ && ushort.TryParse(joinPortField.value.Trim(), out var parsed))
+ {
+ port = parsed;
+ }
+
+ statusLabel.text = $"Connecting to {address}:{port}…";
+ if (!NetworkBootstrap.StartClient(address, port))
+ {
+ statusLabel.text = "Failed to start client. Check the console.";
+ return;
+ }
+
+ // The server's NGO SceneManager will pull this client into whatever
+ // scene the server is in (Lobby or Match) once the connection
+ // completes. SessionFlow handles disconnect recovery.
+ joinPanel.style.display = DisplayStyle.None;
+ }
+
+ private void OnJoinCancelClicked()
+ {
+ joinPanel.style.display = DisplayStyle.None;
+ statusLabel.text = string.Empty;
+ }
+
+ private void OnQuitClicked()
+ {
+#if UNITY_EDITOR
+ UnityEditor.EditorApplication.isPlaying = false;
+#else
+ Application.Quit();
+#endif
+ }
+ }
+}
diff --git a/Assets/_Project/Scripts/UI/MainMenuController.cs.meta b/Assets/_Project/Scripts/UI/MainMenuController.cs.meta
new file mode 100644
index 0000000..b407fe3
--- /dev/null
+++ b/Assets/_Project/Scripts/UI/MainMenuController.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 01199add5a12f4a4bb9a94d1e44fbb4d
\ No newline at end of file
diff --git a/Project_Roadmap.md b/Project_Roadmap.md
index cdb4901..52afb7f 100644
--- a/Project_Roadmap.md
+++ b/Project_Roadmap.md
@@ -75,7 +75,45 @@ The most architecturally significant pending system. This blocks the Phase 2 tow
- 4-connected, no diagonals (matches existing maze validation)
- Re-pathing on tower placement/removal events. Fire on: `TowerInstance` spawn/despawn, construction-start, construction-finish, shelved-tower despawn.
-### 1.7 Terrain architecture
+### 1.7 Lobby & Connection (CURRENT)
+
+Replaces the current bare "Start Host" button with a proper main menu → lobby → match flow.
+Lobby is its own scene; players gather, pick races, ready up, and the host starts the match.
+
+**v1 scope (Direct IP, Option A host-leaves-closes-lobby):**
+
+- `MainMenu` scene — Host / Join (with IP+port field) / Quit
+- `Lobby` scene — player list, per-player race picker, ready toggle, host-only Start button, Leave button
+- `LobbyService` `NetworkBehaviour` scene singleton — Start Match RPC, Return to Lobby RPC
+- `NetworkBootstrap` static — wraps `UnityTransport` host/join/disconnect. Designed for the Steam swap-out below: same call sites, different transport.
+- `PlayerMatchState` extended with `IsReady` NetworkVariable and `SetReadyRpc` / `SetRaceRpc` for client → server submissions
+- Match-end overlay extended: **Retry** (back to lobby) AND **Return to Main Menu** (this player only)
+- Host leaves → server raises a "lobby closed" RPC; all clients shutdown + return to main menu. Same applies if the host clicks "Return to Main Menu" after a match — session ends for everyone, each player returns independently to their own main menu.
+
+### 1.7-Future Steam Lobby Migration (Option C — DEFERRED)
+
+When the Steam SDK is integrated (post-1.7, before 1.8 or in parallel), the Direct IP backend is replaced with a Steam-backed lobby provider. **The lobby UI and gameplay code do not change.** The migration touches only `NetworkBootstrap` and adds a `LobbyProvider` abstraction behind it.
+
+**What changes:**
+
+- Add `Facepunch.Steamworks` (recommended) or `Steamworks.NET` to the project
+- Use Steam app ID **480 (Spacewar)** during development; switch to the actual app ID once Steam page is provisioned
+- Replace `UnityTransport` with `SteamNetworkingSocketsTransport` (community-maintained NGO transport)
+- Refactor `NetworkBootstrap` from static helpers into an `IConnectionProvider` interface with two implementations:
+ - `DirectIpConnectionProvider` (existing behavior, kept for LAN testing and DRM-free distribution)
+ - `SteamConnectionProvider` (Steam lobby create/join, friend invite via Steam overlay, Steam P2P sockets)
+- Add lobby browser UI to `MainMenu` scene (currently just Host/Join buttons)
+- Friend-invite flow: handled by Steam overlay (Shift+Tab → invite friend); join request lands in the existing Lobby scene
+- Host-leaves behavior upgrades from Option A → Option C: Steam lobbies persist independently of the game host, so a new host can be elected from remaining members rather than tearing the lobby down. **This is the deferred behavioral upgrade flagged for migration time.**
+
+**Notes for the migration:**
+
+- Lobby code already factored to read player state from `PlayerMatchState` (carries across scene loads) and lobby-wide state from `LobbyService` — no UI rewrite needed
+- The `IConnectionProvider` abstraction is the single seam between gameplay and transport; everything else stays put
+- Both providers should coexist in the codebase so DRM-free builds (itch.io, direct distribution) can still ship without Steam
+- Steam Direct fee ($100 USD) is required to publish on Steam but NOT for development — Spacewar app ID 480 is free to use
+
+### 1.7.5 Terrain architecture
Decision deferred per existing context document; Builder code is already terrain-agnostic. Recommend deciding after the character pipeline mini-session and before Phase 2 begins (the visual prototype will look very different on Unity Terrain vs mesh-based terrain).
@@ -100,6 +138,59 @@ Flagged for revisit; not blocking anything.
- Center-on-builder hotkey (e.g., Space)
- Initial camera position taking race or match phase into account
+### 1.10 Tower Customization & Meta-Progression (DEFERRED — DESIGN CAPTURED)
+
+Long-term cross-match progression loop. Recorded here so Phase 1.8 race-system design and Phase 2 visual prototype work can anticipate the customization data model when they're scheduled. Not blocking Phase 1 exit criteria — a single match is fully playable without it.
+
+**Core concept**
+
+- Every player starts each match with the same **base tower set** — a small fixed roster (universal or per-race; see open question below).
+- **End of match awards a customization reward**, drawn from a Warhammer-themed pool. Win and loss draw from **different pools** — winning unlocks higher-tier / rarer rewards, losing still progresses at a slower rate so no match is truly wasted.
+- Reward types:
+ - **Paint color** (e.g. Macragge Blue, Mephiston Red, Caliban Green) — applied to tower visuals, can carry gameplay effects (see below)
+ - **Sticker decal** — chapter badges, regimental icons, faction sigils
+ - Other Warhammer-aligned customizations: transfers, weathering, base styles, freehand-style elements
+- Customizations are **persistent across matches** via a player profile / save system.
+- Applied via a **customization menu** (does not exist yet) reachable from the main menu, showing owned customizations and per-tower application slots.
+
+**Gameplay enhancements via customization**
+
+Cosmetics double as upgrades. The pattern mirrors the real-world Warhammer hobby loop (collect → paint → customize) translated into mechanical progression:
+
+- **Stat modifiers** — extended range, increased damage, faster fire rate, larger splash radius
+- **Damage-type changes** — applying a particular paint scheme grants Fire / Cold / Poison damage to an otherwise basic tower
+- **Special effects** — chain hits, slow effects, DoT — driven by specific decal or paint combinations
+- **Layering / set bonuses** — applying multiple customizations from the same "faction" might unlock additional effects (e.g. full Salamanders paint kit + chapter decal grants a flamer-style damage profile)
+
+**Required systems (none exist yet)**
+
+- **Player profile / persistence** — local save for now, account-bound later. Tracks owned customizations + per-tower application state.
+- **`CustomizationDefinition` ScriptableObject** — stat-delta fields, visual asset references (decal texture, color values), rarity tier, reward-pool tags.
+- **Tower stat modifier stack** — extend `TowerDefinition` (currently flat fields) to accept a layered modifier stack from applied customizations. Affects targeting, damage, range, fire rate at runtime.
+- **End-of-match reward roll** — server-authoritative draw from win/loss pool, replicated to the relevant player. Anti-cheat: server owns the roll, not the client.
+- **Customization menu UI** — main-menu-accessible. Browse owned, browse locked, apply / remove per tower in the base set.
+- **Networked applied-customization state during a match** — towers must visually reflect each owner's applied customizations on every peer, AND stat modifiers must be authoritative on the server.
+
+**Open question — interaction with the Phase 1.8 race system**
+
+Phase 1.8 currently assumes **race-driven tower rosters** (each race has its own distinct set of towers). This new direction suggests a flatter, customization-driven model. The two systems need reconciliation before either ships:
+
+- **Option A:** Race remains the primary tower-set distinction. Customizations layer on top of race-specific towers. Each race owns its own customization pool.
+- **Option B:** Race becomes one customization category among many. Tower set is universal across players; "race" identity emerges from the player's chosen paint scheme / decal kit.
+- **Option C:** Hybrid — small universal base set + larger race-locked roster, both customizable. Customizations affect both.
+
+Decision deferred until both 1.8 and 1.10 are actively scheduled. The choice has significant implications for content scope: Option A means N races × M customizations each; Option B means one tower set × (M customizations × N "factions"). Option B is dramatically less art work per tower.
+
+**Why this is deferred**
+
+- Not required for Phase 1 exit criteria (a single match plays fine without cross-match progression).
+- Phase 2 visual prototype doesn't depend on this — the painted-miniature aesthetic provides the visual scaffolding customization will eventually exploit, but the prototype tower's paint scheme can be hard-coded for the demo.
+- The data model interaction with Phase 1.8 needs to be resolved first (see open question above).
+
+**When to schedule**
+
+Earliest reasonable slot: after Phase 1 exit criteria are met and Phase 1.8 race-system design has the customization-interaction question resolved. Could run in parallel with Phase 2 (no overlap with visual prototype scope) or after Phase 2 if visual direction needs to prove out first.
+
### Phase 1 exit criteria
A complete match is playable end-to-end: race pick → builder spawn → tower placement and construction → wave spawning → enemies path through the maze → towers shoot enemies → enemies die or leak → match concludes. Visuals are placeholder. All systems in this section are functional.