diff --git a/Assets/Scripts/MatchManager.cs b/Assets/Scripts/MatchManager.cs
index 4865028..12f41bd 100644
--- a/Assets/Scripts/MatchManager.cs
+++ b/Assets/Scripts/MatchManager.cs
@@ -1,5 +1,6 @@
using System.Collections;
using System.Collections.Generic;
+using Unity.Collections;
using Unity.Netcode;
using UnityEngine;
using UnityEngine.SceneManagement;
@@ -36,9 +37,17 @@ using UnityEngine.InputSystem;
/// (strictly serialized - an NGO constraint, only one scene op in flight); then every player object is
/// despawned and respawned as its role's prefab variant.
///
+/// Match-control AUTHORITY: the client allowed to start (walk into the trigger) and end (Esc) the match.
+/// A host has its own player, so the host is the authority. A DEDICATED server (StartServer, no local
+/// player) hands authority to the FIRST client that connects, so a server nobody plays on is still
+/// controllable. The id is server-local; the designated remote client is told via a named message so it
+/// reads Esc locally and asks the server (which validates the sender) to end. If the authority leaves,
+/// it is reassigned to another connected client.
+///
/// End / join / teardown:
-/// - The host presses Esc -> : Test Map is swapped back to Lobby and everyone is
-/// respawned as the neutral Player. Server-gated, so only the host can end the match.
+/// - The match authority presses Esc -> : Test Map is swapped back to Lobby and
+/// everyone is respawned as the neutral Player. The host/server ends directly; a remote authority
+/// client requests it over a named message that only the authority's id is honored for.
/// - A client that joins MID-MATCH spawns as a SPECTATOR: a dedicated EMPTY prefab (no body/collider)
/// bound to free-cam-only, so it flies around the running map and watches but does not play. It
/// respawns as a neutral lobby Player when the match ends.
@@ -104,6 +113,19 @@ public class MatchManager : MonoBehaviour
// previous one's painting; a normal respawn keeps Main loaded, so the parked copy survives it.
private byte[] _carriedPaint;
+ // Match-control authority. Server-local: which client may start/end the match. Host -> the host
+ // itself (set at server start); dedicated server -> the first client to connect (set on connect).
+ private bool _hasAuthorityClient;
+ private ulong _authorityClientId;
+ // Client-side mirror, set by the grant message below: this machine's local client is the authority,
+ // so it reads Esc and asks the server to end the match.
+ private bool _isLocalAuthority;
+
+ // Named messages (CustomMessagingManager). One bridges server->authority-client ("you have authority"),
+ // the other client->server ("please end the match"); the server only honors it from the authority id.
+ private const string MsgAuthorityGranted = "Match_AuthorityGranted";
+ private const string MsgRequestEndMatch = "Match_RequestEndMatch";
+
private void Awake()
{
if (Instance && Instance != this) { Destroy(this); return; }
@@ -140,7 +162,13 @@ public class MatchManager : MonoBehaviour
_net.OnServerStopped -= OnNetStopped;
_net.OnClientStopped -= OnNetStopped;
_net.OnClientConnectedCallback -= OnClientConnected;
+ _net.OnClientDisconnectCallback -= OnClientDisconnected;
if (_net.SceneManager != null) _net.SceneManager.OnSceneEvent -= OnSceneEvent;
+ if (_net.CustomMessagingManager != null)
+ {
+ _net.CustomMessagingManager.UnregisterNamedMessageHandler(MsgAuthorityGranted);
+ _net.CustomMessagingManager.UnregisterNamedMessageHandler(MsgRequestEndMatch);
+ }
}
if (Instance == this) Instance = null;
}
@@ -158,9 +186,19 @@ public class MatchManager : MonoBehaviour
Phase = MatchPhase.Lobby;
_sceneReady = false;
+ // Match-control authority. A host has its own player, so it is the authority right away. A
+ // dedicated server has no local player; the first client to connect claims it (OnClientConnected).
+ _hasAuthorityClient = false;
+ if (_net.IsHost) { _authorityClientId = _net.LocalClientId; _hasAuthorityClient = true; }
+
+ if (_net.CustomMessagingManager != null)
+ _net.CustomMessagingManager.RegisterNamedMessageHandler(MsgRequestEndMatch, OnRequestEndMatchMessage);
+
// Spawn late joiners on connect (server-side callback). Re-hook defensively in case of re-host.
_net.OnClientConnectedCallback -= OnClientConnected;
_net.OnClientConnectedCallback += OnClientConnected;
+ _net.OnClientDisconnectCallback -= OnClientDisconnected;
+ _net.OnClientDisconnectCallback += OnClientDisconnected;
StartCoroutine(LoadLobbyRoutine());
}
@@ -168,7 +206,13 @@ public class MatchManager : MonoBehaviour
private void OnClientStarted()
{
_cleanedUp = false;
+ _isLocalAuthority = false;
HookSceneManager();
+
+ // Listen for the server granting this machine match authority (dedicated-server case). Registered
+ // here (client start) so the handler exists before the server sends the grant on connect.
+ if (_net.CustomMessagingManager != null)
+ _net.CustomMessagingManager.RegisterNamedMessageHandler(MsgAuthorityGranted, OnAuthorityGrantedMessage);
}
// Fired on shutdown / disconnect for both the server and the client (a host gets both, hence the
@@ -182,26 +226,50 @@ public class MatchManager : MonoBehaviour
StopAllCoroutines();
_hooked = false;
_sceneReady = false;
+ _hasAuthorityClient = false;
+ _isLocalAuthority = false;
Phase = MatchPhase.Lobby;
if (_net)
{
_net.OnClientConnectedCallback -= OnClientConnected;
+ _net.OnClientDisconnectCallback -= OnClientDisconnected;
if (_net.SceneManager != null) _net.SceneManager.OnSceneEvent -= OnSceneEvent;
+ if (_net.CustomMessagingManager != null)
+ {
+ _net.CustomMessagingManager.UnregisterNamedMessageHandler(MsgAuthorityGranted);
+ _net.CustomMessagingManager.UnregisterNamedMessageHandler(MsgRequestEndMatch);
+ }
}
ReturnToMainOnly();
}
- // Host-only: Esc ends the running match and sends everyone back to the lobby. Read locally here
- // (Input System reads the device directly, independent of focus/other readers); guarded to the server
- // so pure clients pressing Esc do nothing.
+ // Esc ends the running match (sends everyone back to the lobby) - but only for the match AUTHORITY.
+ // The host/server reads it locally and ends directly. A remote authority client (dedicated server)
+ // can't end the match itself (the flow is server-authoritative), so it asks the server over a named
+ // message; the server validates the sender is the authority and that a match is running.
private void Update()
{
- if (_net == null || !_net.IsServer || Phase != MatchPhase.InMatch) return;
+ if (_net == null) return;
+
+ if (_net.IsServer)
+ {
+ if (Phase == MatchPhase.InMatch && IsMatchAuthority(_net.LocalClientId) && EscapePressed())
+ EndMatch();
+ return;
+ }
+
+ // Pure client: Phase is server-local, so we don't gate on it here - the server checks it on receipt.
+ if (_isLocalAuthority && EscapePressed())
+ RequestEndMatchFromServer();
+ }
+
+ private static bool EscapePressed()
+ {
#if ENABLE_INPUT_SYSTEM
- if (Keyboard.current != null && Keyboard.current.escapeKey.wasPressedThisFrame) EndMatch();
+ return Keyboard.current != null && Keyboard.current.escapeKey.wasPressedThisFrame;
#else
- if (Input.GetKeyDown(KeyCode.Escape)) EndMatch();
+ return Input.GetKeyDown(KeyCode.Escape);
#endif
}
@@ -238,6 +306,18 @@ public class MatchManager : MonoBehaviour
private void OnClientConnected(ulong clientId)
{
if (_net == null || !_net.IsServer) return;
+
+ // Dedicated-server case: the first client to connect becomes the match authority (a host already
+ // claimed it at server start, so this only fires for a server with no local player). Tell the
+ // client over a named message so it reads Esc locally; the trigger picks it up server-side.
+ if (!_hasAuthorityClient)
+ {
+ _authorityClientId = clientId;
+ _hasAuthorityClient = true;
+ SendAuthorityGrant(clientId);
+ Debug.Log($"[MatchManager] Client {clientId} is now the match authority.", this);
+ }
+
// During a Lobby<->Test Map swap the active scene briefly falls back to Main (no floor); defer
// and let the respawn loop spawn this joiner once the target scene is loaded.
if (!_sceneReady || Phase == MatchPhase.Transitioning) return;
@@ -248,6 +328,27 @@ public class MatchManager : MonoBehaviour
SpawnFor(clientId, _playerPrefab, lobbySpawnCenter);
}
+ // Server-side: if the match authority leaves, hand authority to another connected client (lowest
+ // remaining id) so a dedicated-server match stays controllable. No-op in the host case (the host
+ // holds authority and only "leaves" on shutdown, which teardown handles).
+ private void OnClientDisconnected(ulong clientId)
+ {
+ if (_net == null || !_net.IsServer) return;
+ if (!_hasAuthorityClient || clientId != _authorityClientId) return;
+ if (_net.IsHost && clientId == _net.LocalClientId) return; // host shutting down; teardown handles it
+
+ _hasAuthorityClient = false;
+ foreach (ulong id in _net.ConnectedClientsIds)
+ {
+ if (id == clientId) continue; // the leaving client may still be listed momentarily
+ _authorityClientId = id;
+ _hasAuthorityClient = true;
+ SendAuthorityGrant(id);
+ Debug.Log($"[MatchManager] Match authority reassigned to client {id}.", this);
+ break;
+ }
+ }
+
// Spawns the given prefab as a client's player object into the currently active gameplay scene. No-op
// if the client already owns a player object (double-spawn guard) or has since disconnected.
private void SpawnFor(ulong clientId, GameObject prefab, Vector3 at)
@@ -422,4 +523,47 @@ public class MatchManager : MonoBehaviour
_carriedPaint = null;
return d;
}
+
+ /// True if the given client currently holds match-control authority (may start and end the match).
+ public bool IsMatchAuthority(ulong clientId) => _hasAuthorityClient && clientId == _authorityClientId;
+
+ // Server -> client: tell the designated client it is the match authority (so it reads Esc locally).
+ private void SendAuthorityGrant(ulong clientId)
+ {
+ var cmm = _net != null ? _net.CustomMessagingManager : null;
+ if (cmm == null) return;
+ using (var writer = new FastBufferWriter(sizeof(byte), Allocator.Temp))
+ {
+ writer.WriteByteSafe(1); // payload unused; a named message still needs a buffer
+ cmm.SendNamedMessage(MsgAuthorityGranted, clientId, writer);
+ }
+ }
+
+ // Client handler: this machine has been granted match authority.
+ private void OnAuthorityGrantedMessage(ulong senderClientId, FastBufferReader reader)
+ {
+ _isLocalAuthority = true;
+ Debug.Log("[MatchManager] This client is the match authority: walk into the Start Match trigger to begin, Esc to end.", this);
+ }
+
+ // Client -> server: ask the server to end the match. Only honored from the authority client (and only
+ // while a match is running - EndMatch guards the phase).
+ private void RequestEndMatchFromServer()
+ {
+ var cmm = _net != null ? _net.CustomMessagingManager : null;
+ if (cmm == null) return;
+ using (var writer = new FastBufferWriter(sizeof(byte), Allocator.Temp))
+ {
+ writer.WriteByteSafe(1);
+ cmm.SendNamedMessage(MsgRequestEndMatch, NetworkManager.ServerClientId, writer);
+ }
+ }
+
+ // Server handler: an authority client asked to end the match.
+ private void OnRequestEndMatchMessage(ulong senderClientId, FastBufferReader reader)
+ {
+ if (_net == null || !_net.IsServer) return;
+ if (!IsMatchAuthority(senderClientId)) return;
+ EndMatch(); // guards Phase == InMatch itself
+ }
}
diff --git a/Assets/Scripts/StartMatchTrigger.cs b/Assets/Scripts/StartMatchTrigger.cs
index 25b7a38..22e91c3 100644
--- a/Assets/Scripts/StartMatchTrigger.cs
+++ b/Assets/Scripts/StartMatchTrigger.cs
@@ -2,25 +2,75 @@ using Unity.Netcode;
using UnityEngine;
///
-/// Sits on the Lobby's "Start Match" trigger volume (a BoxCollider with isTrigger on). When the HOST's
-/// own player walks in, it asks the to start the match. Server-gated: pure
-/// clients fire nothing (IsServer is false there), and only the host-owned player object counts, so a
-/// remote avatar passing through never starts the match. The trigger lives in the additively-loaded
-/// Lobby and is destroyed when the lobby unloads, so it cannot start a second match.
+/// Sits on the Lobby's "Start Match" trigger volume (a BoxCollider with isTrigger on). When the match
+/// AUTHORITY's avatar stands inside it, it asks the to start the match.
+///
+/// Detection POLLS a server-side physics overlap every frame instead of relying on OnTriggerEnter.
+/// Why: the authority can be a REMOTE client (a dedicated server with no host player hands authority to
+/// the first client). On the server that client's avatar is a NetworkTransform-driven replica - its
+/// CharacterController is disabled () and it carries no Rigidbody, so it
+/// is moved by direct transform writes. Unity only sends OnTriggerEnter when one of the two colliders has
+/// a Rigidbody (or is a CharacterController moving via Move()); a transform-moved non-Rigidbody collider
+/// against a static trigger fires nothing. That is exactly why a host's own (Move()-driven) avatar tripped
+/// the trigger but a remote authority never did. An explicit overlap query finds the avatar's (baked)
+/// collider regardless of HOW it is moved, so start works for a host and a dedicated server alike.
+///
+/// Server-gated: pure clients query nothing. Only the authority avatar starts the match; a passing
+/// non-authority avatar is ignored. Runs only while in the lobby phase and stops once it has started a
+/// match. The trigger lives in the additively-loaded Lobby and is destroyed when the lobby unloads, so a
+/// re-match (back in the lobby) gets a fresh instance with its guard reset.
///
[RequireComponent(typeof(Collider))]
[DisallowMultipleComponent]
public class StartMatchTrigger : MonoBehaviour
{
- private void OnTriggerEnter(Collider other)
+ private Collider _collider;
+ private readonly Collider[] _overlap = new Collider[16];
+ private bool _started;
+
+ private void Awake() => _collider = GetComponent();
+
+ private void Update()
{
+ if (_started) return;
+
var nm = NetworkManager.Singleton;
- if (!nm || !nm.IsServer) return; // only the server/host starts the match
+ if (!nm || !nm.IsServer) return; // detection + start are server-side
+ var match = MatchManager.Instance;
+ if (!match || match.Phase != MatchManager.MatchPhase.Lobby) return;
- var netObj = other.GetComponentInParent();
- if (!netObj || !netObj.IsPlayerObject) return; // must be a player avatar
- if (netObj.OwnerClientId != nm.LocalClientId) return; // ...and specifically the host's own
+ int count = OverlapTrigger();
+ for (int i = 0; i < count; i++)
+ {
+ var netObj = _overlap[i].GetComponentInParent();
+ if (!netObj || !netObj.IsPlayerObject) continue; // must be a player avatar
+ if (!match.IsMatchAuthority(netObj.OwnerClientId)) continue; // ...the authority's
- if (MatchManager.Instance) MatchManager.Instance.StartMatch();
+ _started = true;
+ match.StartMatch();
+ return;
+ }
+ }
+
+ // Overlaps the trigger's own volume. Uses the oriented box for a BoxCollider (the authored shape);
+ // falls back to the collider's world AABB for any other collider type. Triggers are ignored so the
+ // query skips this volume itself and reports only solid avatar colliders (e.g. the baked mesh).
+ private int OverlapTrigger()
+ {
+ if (_collider is BoxCollider box)
+ {
+ Vector3 center = transform.TransformPoint(box.center);
+ Vector3 scale = transform.lossyScale;
+ Vector3 half = new Vector3(
+ Mathf.Abs(box.size.x * scale.x),
+ Mathf.Abs(box.size.y * scale.y),
+ Mathf.Abs(box.size.z * scale.z)) * 0.5f;
+ return Physics.OverlapBoxNonAlloc(
+ center, half, _overlap, transform.rotation, ~0, QueryTriggerInteraction.Ignore);
+ }
+
+ Bounds b = _collider.bounds;
+ return Physics.OverlapBoxNonAlloc(
+ b.center, b.extents, _overlap, Quaternion.identity, ~0, QueryTriggerInteraction.Ignore);
}
}