diff --git a/Assets/Scripts/GameMode/GameModeManager.cs b/Assets/Scripts/GameMode/GameModeManager.cs
index 5143ab3..e225ed3 100644
--- a/Assets/Scripts/GameMode/GameModeManager.cs
+++ b/Assets/Scripts/GameMode/GameModeManager.cs
@@ -61,6 +61,7 @@ public class GameModeManager : MonoBehaviour
private int _activeIndex = -1;
private bool _paused;
private int _resumeIndex = -1;
+ private bool _loadFrozen; // true while the pre-match Loading barrier is holding the local player still
/// The currently active mode (null before startup).
public GameMode ActiveMode =>
@@ -322,6 +323,23 @@ public class GameModeManager : MonoBehaviour
// switch key and Tick so Tab can't bootstrap a mode and no camera rig is driven until a bind.
if (_activeIndex < 0) return;
+ // Pre-match loading barrier: hold the local player frozen (no movement, painting or mode switching)
+ // until every client has finished loading and the Hide phase begins. Re-asserted each frame so nothing
+ // sneaks input in; on release the active mode is re-entered to restore its intended control state
+ // without a camera jump (exactly like SetPaused's resume - the mode was never Exited).
+ if (MovementGated())
+ {
+ if (player) player.ControlEnabled = false;
+ if (painter) painter.PaintingEnabled = false;
+ _loadFrozen = true;
+ return;
+ }
+ if (_loadFrozen)
+ {
+ _loadFrozen = false;
+ ActiveMode?.Enter();
+ }
+
// Resolution (the reveal window) is watch-only: force out of Paint and keep the switch key from
// re-entering. No restore needed - the end-of-match respawn rebuilds the cycle at index 0.
bool suppressPaint = PaintSuppressed();
@@ -355,6 +373,15 @@ public class GameModeManager : MonoBehaviour
match.LocalSubPhase == MatchManager.MatchSubPhase.Resolution;
}
+ /// True while the local match is in its pre-Hide LOADING barrier, during which the local player is
+ /// frozen (no movement, painting or mode switching) until every client has finished loading.
+ private bool MovementGated()
+ {
+ var match = MatchManager.Instance;
+ return match && match.MatchActive &&
+ match.LocalSubPhase == MatchManager.MatchSubPhase.Loading;
+ }
+
/// First non-Paint mode index at or after (wrapping once through the
/// cycle); -1 if the set has no non-paint mode (a degenerate paint-only configuration).
private int FirstNonPaintFrom(int start)
diff --git a/Assets/Scripts/Network/MatchManager.cs b/Assets/Scripts/Network/MatchManager.cs
index bfeef09..e7e3eb4 100644
--- a/Assets/Scripts/Network/MatchManager.cs
+++ b/Assets/Scripts/Network/MatchManager.cs
@@ -37,9 +37,11 @@ public class MatchManager : MonoBehaviour
/// The in-match sub-phase, replicated to every client over a named message (not via NGO
/// NetworkVariables - this is a MonoBehaviour by design, see class summary). None = no match running.
- /// Hide -> Seek -> Resolution, then back to the lobby. Drives per-client visibility, hunter shooting
- /// and the match HUD countdown.
- public enum MatchSubPhase { None, Hide, Seek, Resolution }
+ /// Loading -> Hide -> Seek -> Resolution, then back to the lobby. Drives per-client visibility, hunter
+ /// shooting and the match HUD countdown. Loading is the pre-match barrier: everyone is frozen (no movement,
+ /// no countdown) until every client has spawned all match avatars AND applied their paintings, so nobody
+ /// moves or sees a half-loaded world before Hide begins.
+ public enum MatchSubPhase { None, Loading, Hide, Seek, Resolution }
/// How the running match behaves. Chosen server-side () and replicated
/// to every client alongside the sub-phase (so visibility is right from the first Hide frame and late
@@ -106,6 +108,13 @@ public class MatchManager : MonoBehaviour
[Min(0f)]
[SerializeField] private float resolveDuration = 8f;
+ [Tooltip("Max seconds the pre-match LOADING barrier waits for EVERY client to finish loading (all match " +
+ "avatars spawned AND their paintings applied on every machine) before the Hide phase starts and " +
+ "players may move. Until then everyone is frozen and the countdown is held. A safety cap only: if " +
+ "a client hangs, the match starts anyway once this elapses. 0 = wait indefinitely (not recommended).")]
+ [Min(0f)]
+ [SerializeField] private float matchLoadTimeout = 20f;
+
[Header("Hunter shooting")]
[Tooltip("Max distance (metres) of the hunter's camera-centre shoot raycast.")]
[Min(0f)]
@@ -228,6 +237,12 @@ public class MatchManager : MonoBehaviour
// Server -> HUNTER clients only, on each Seek radar sweep: the still-un-found hiders' NetworkObjectIds plus
// the blip lifetime. Each hunter resolves the ids locally and drops a Radar blip pointing at the hider.
private const string MsgRadarPing = "Match_RadarPing";
+ // Pre-match LOADING barrier. Server -> all clients, once the match avatars are respawned: the target avatar
+ // NetworkObjectIds + a generation tag; each client waits until it has spawned & paint-applied all of them.
+ // Client -> server: the "I finished loading" reply (echoes the generation so a stale ack from a previous
+ // barrier round is dropped). The server holds the Hide phase until every expected client has replied.
+ private const string MsgBeginLoad = "Match_BeginLoad";
+ private const string MsgReady = "Match_Ready";
// Server-only sub-phase machine. _pendingHiders holds the hiders not yet found; seek ends the instant it
// empties. _phaseRoutine is the running Hide->Seek->Resolution coroutine (stopped if the match is ended
@@ -244,6 +259,21 @@ public class MatchManager : MonoBehaviour
private readonly List _radarPingIds = new List(); // reused scratch: hider NetworkObjectIds for a sweep
private readonly List _radarRecipients = new List(); // reused scratch: hunter client recipients for a sweep
+ // Server-only pre-match loading barrier. _matchAvatarIds = the freshly respawned avatars every client must
+ // have spawned & paint-applied; _expectedReady = the clients present when the barrier opened (a mid-load
+ // joiner isn't required, so it can't stall the start); _readyClients = those that have reported ready.
+ // _loadRecipients is reused scratch for the broadcast. _loadGeneration tags the round so a late ack from a
+ // previous barrier is ignored.
+ private readonly List _matchAvatarIds = new List();
+ private readonly List _loadRecipients = new List();
+ private readonly HashSet _expectedReady = new HashSet();
+ private readonly HashSet _readyClients = new HashSet();
+ private int _loadGeneration;
+ // Client-side barrier state: the target avatar ids to wait for, the generation to echo back, and the running poll.
+ private readonly List _loadTargetIds = new List();
+ private int _loadGenerationClient;
+ private Coroutine _clientLoadPoll;
+
private void Awake()
{
if (Instance && Instance != this) { Destroy(this); return; }
@@ -298,6 +328,8 @@ public class MatchManager : MonoBehaviour
_net.CustomMessagingManager.UnregisterNamedMessageHandler(MsgRadarPing);
_net.CustomMessagingManager.UnregisterNamedMessageHandler(MsgReportHit);
_net.CustomMessagingManager.UnregisterNamedMessageHandler(MsgIdentity);
+ _net.CustomMessagingManager.UnregisterNamedMessageHandler(MsgBeginLoad);
+ _net.CustomMessagingManager.UnregisterNamedMessageHandler(MsgReady);
}
}
@@ -323,6 +355,7 @@ public class MatchManager : MonoBehaviour
_net.CustomMessagingManager.RegisterNamedMessageHandler(MsgRequestEndMatch, OnRequestEndMatchMessage);
_net.CustomMessagingManager.RegisterNamedMessageHandler(MsgReportHit, OnReportHitMessage);
_net.CustomMessagingManager.RegisterNamedMessageHandler(MsgIdentity, OnIdentityMessage);
+ _net.CustomMessagingManager.RegisterNamedMessageHandler(MsgReady, OnReadyMessage);
}
_subPhase = MatchSubPhase.None;
@@ -350,6 +383,7 @@ public class MatchManager : MonoBehaviour
_net.CustomMessagingManager.RegisterNamedMessageHandler(MsgAuthorityGranted, OnAuthorityGrantedMessage);
_net.CustomMessagingManager.RegisterNamedMessageHandler(MsgPhase, OnPhaseMessage);
_net.CustomMessagingManager.RegisterNamedMessageHandler(MsgRadarPing, OnRadarPingMessage);
+ _net.CustomMessagingManager.RegisterNamedMessageHandler(MsgBeginLoad, OnBeginLoadMessage);
}
// Report our persistent id to the server the moment our own connection completes (host records its own
@@ -376,6 +410,12 @@ public class MatchManager : MonoBehaviour
ApplyPhaseLocal(MatchSubPhase.None, 0f, _localMode);
_pendingHiders.Clear();
_persistentIds.Clear();
+ // Drop any in-flight loading-barrier state (StopAllCoroutines above already killed the polls).
+ _expectedReady.Clear();
+ _readyClients.Clear();
+ _matchAvatarIds.Clear();
+ _loadTargetIds.Clear();
+ _clientLoadPoll = null;
if (_net) UnhookSessionCallbacks();
ReturnToMainOnly();
@@ -447,6 +487,10 @@ public class MatchManager : MonoBehaviour
// A hider that leaves mid-match can never be found - drop it so "all hiders found" can still trigger.
_pendingHiders.Remove(clientId);
_revealingHiders.Remove(clientId); // and out of the in-flight flash set (its resolve coroutine no-ops)
+ // Drop it from the loading barrier too: an expected client that leaves mid-load must stop gating the
+ // start (AllClientsReady only requires still-connected expected clients).
+ _expectedReady.Remove(clientId);
+ _readyClients.Remove(clientId);
// Free the ephemeral clientId -> identity mapping (the clientId will be reused by a later joiner).
_persistentIds.Remove(clientId);
@@ -581,6 +625,12 @@ public class MatchManager : MonoBehaviour
// Resolution are fixed-length; Seek bails the instant the last hider is found (or its timer runs out).
private IEnumerator RunMatchPhases()
{
+ // Pre-match readiness barrier: hold everyone in the Loading sub-phase (frozen, no countdown) until every
+ // client has spawned all match avatars AND applied their paintings, so nobody moves or sees a half-loaded
+ // world before Hide. Bounded by matchLoadTimeout so a hung client can't stall the match forever. Lives in
+ // _phaseRoutine (yielded inline), so an early Back-to-Lobby that stops that routine stops the barrier too.
+ yield return RunLoadingBarrier();
+
SetPhaseServer(MatchSubPhase.Hide, hideDuration);
yield return WaitPhase(hideDuration, null);
@@ -632,6 +682,156 @@ public class MatchManager : MonoBehaviour
}
}
+ // --- Pre-match loading barrier ---------------------------------------------------------------------
+ // Server-only. Opens the barrier (snapshot the just-respawned avatars + the clients that must load them,
+ // switch everyone to Loading, tell each client which avatars to wait for), then spins until every still-
+ // connected expected client has reported ready - or matchLoadTimeout elapses.
+ private IEnumerator RunLoadingBarrier()
+ {
+ BeginLoadingBarrierServer();
+
+ float t = matchLoadTimeout;
+ while (!AllClientsReady())
+ {
+ // The host is a client too and never messages itself: fold its own local readiness in directly.
+ if (LocalClientReady(_matchAvatarIds)) _readyClients.Add(_net.LocalClientId);
+
+ if (matchLoadTimeout > 0f)
+ {
+ t -= Time.deltaTime;
+ if (t <= 0f)
+ {
+ Debug.LogWarning("[MatchManager] Load barrier timed out - starting the match anyway.", this);
+ break;
+ }
+ }
+ yield return null;
+ }
+ }
+
+ // Server-only. Captures the barrier target (the avatars RespawnAll just created + the clients that must load
+ // them), bumps the generation, switches everyone to Loading (duration 0 = no countdown) and broadcasts the
+ // target avatar ids so each client can start waiting.
+ private void BeginLoadingBarrierServer()
+ {
+ _matchAvatarIds.Clear();
+ _expectedReady.Clear();
+ _readyClients.Clear();
+ foreach (ulong id in _net.ConnectedClientsIds)
+ {
+ _expectedReady.Add(id);
+ if (_net.ConnectedClients.TryGetValue(id, out var c) && c.PlayerObject)
+ _matchAvatarIds.Add(c.PlayerObject.NetworkObjectId);
+ }
+ _loadGeneration++;
+
+ SetPhaseServer(MatchSubPhase.Loading, 0f);
+ BroadcastBeginLoad();
+ }
+
+ // True once every expected client that is STILL connected has reported ready. A client that disconnected
+ // mid-load drops out of the requirement; a mid-load joiner was never in _expectedReady, so it can't stall it.
+ private bool AllClientsReady()
+ {
+ foreach (ulong id in _expectedReady)
+ if (_net.ConnectedClients.ContainsKey(id) && !_readyClients.Contains(id))
+ return false;
+ return true;
+ }
+
+ // Any machine: true once all target avatars are spawned locally AND each one's painting has been applied
+ // (PlayerPaintSync.PaintRevealReady). The local owner's own avatar is always ready; remotes gate on the
+ // applied paint cutoff - exactly the condition the per-client reveal uses to un-hide the body.
+ private bool LocalClientReady(List ids)
+ {
+ var sm = _net != null ? _net.SpawnManager : null;
+ if (sm == null) return false;
+ for (int i = 0; i < ids.Count; i++)
+ {
+ if (!sm.SpawnedObjects.TryGetValue(ids[i], out var no) || !no) return false;
+ var sync = no.GetComponentInChildren(true);
+ if (sync && !sync.PaintRevealReady) return false;
+ }
+ return true;
+ }
+
+ // Server -> every other client: the target avatar ids + generation for this barrier round.
+ private void BroadcastBeginLoad()
+ {
+ var cmm = _net != null ? _net.CustomMessagingManager : null;
+ if (cmm == null) return;
+
+ _loadRecipients.Clear();
+ foreach (ulong id in _net.ConnectedClientsIds)
+ if (id != _net.LocalClientId) _loadRecipients.Add(id);
+ if (_loadRecipients.Count == 0) return;
+
+ int size = sizeof(int) + sizeof(int) + _matchAvatarIds.Count * sizeof(ulong);
+ using (var writer = new FastBufferWriter(size, Allocator.Temp))
+ {
+ writer.WriteValueSafe(_loadGeneration);
+ writer.WriteValueSafe(_matchAvatarIds.Count);
+ for (int i = 0; i < _matchAvatarIds.Count; i++)
+ writer.WriteValueSafe(_matchAvatarIds[i]);
+ cmm.SendNamedMessage(MsgBeginLoad, _loadRecipients, writer);
+ }
+ }
+
+ // Client handler: the server opened the loading barrier. Store the target avatars + generation and start
+ // polling; when everything is spawned & painted, reply Ready. Re-started if a fresh barrier message arrives.
+ private void OnBeginLoadMessage(ulong senderClientId, FastBufferReader reader)
+ {
+ reader.ReadValueSafe(out int generation);
+ reader.ReadValueSafe(out int count);
+ _loadTargetIds.Clear();
+ for (int i = 0; i < count; i++)
+ {
+ reader.ReadValueSafe(out ulong id);
+ _loadTargetIds.Add(id);
+ }
+ _loadGenerationClient = generation;
+
+ if (_clientLoadPoll != null) StopCoroutine(_clientLoadPoll);
+ _clientLoadPoll = StartCoroutine(ClientLoadingPoll());
+ }
+
+ // Client-only. Polls until this machine has spawned & paint-applied every target avatar, then reports ready.
+ // Bails without a reply if the phase leaves Loading first (match ended, or the server already moved to Hide).
+ private IEnumerator ClientLoadingPoll()
+ {
+ while (_subPhase == MatchSubPhase.Loading)
+ {
+ if (LocalClientReady(_loadTargetIds))
+ {
+ SendReadyToServer(_loadGenerationClient);
+ break;
+ }
+ yield return null;
+ }
+ _clientLoadPoll = null;
+ }
+
+ // Client -> server: this machine finished loading (echoes the generation so a stale reply is ignored).
+ private void SendReadyToServer(int generation)
+ {
+ var cmm = _net != null ? _net.CustomMessagingManager : null;
+ if (cmm == null) return;
+ using (var writer = new FastBufferWriter(sizeof(int), Allocator.Temp))
+ {
+ writer.WriteValueSafe(generation);
+ cmm.SendNamedMessage(MsgReady, NetworkManager.ServerClientId, writer);
+ }
+ }
+
+ // Server handler: a client reported it finished loading. Counted only if it is for the current barrier round.
+ private void OnReadyMessage(ulong senderClientId, FastBufferReader reader)
+ {
+ reader.ReadValueSafe(out int generation);
+ if (_net == null || !_net.IsServer) return;
+ if (generation != _loadGeneration) return; // stale ack from a previous barrier round
+ _readyClients.Add(senderClientId);
+ }
+
// Server-only. Applies the sub-phase to the server's own mirror and broadcasts it to every other client.
private void SetPhaseServer(MatchSubPhase phase, float duration)
{
diff --git a/Assets/Scripts/Network/NetworkPlayerSetup.cs b/Assets/Scripts/Network/NetworkPlayerSetup.cs
index 8459b43..fa4d8db 100644
--- a/Assets/Scripts/Network/NetworkPlayerSetup.cs
+++ b/Assets/Scripts/Network/NetworkPlayerSetup.cs
@@ -326,6 +326,9 @@ public class NetworkPlayerSetup : NetworkBehaviour
if (role != PlayerRole.Hider) return true; // hunters are always visible
var phase = match.LocalSubPhase;
+ // The pre-match loading window conceals hiders exactly like Hide, so a fast-loading client can't reveal
+ // a hider (once its paint applies) before the match proper even begins.
+ if (phase == MatchManager.MatchSubPhase.Loading) phase = MatchManager.MatchSubPhase.Hide;
if (phase == MatchManager.MatchSubPhase.Resolution) return true; // all revealed at the end
if (match.LocalMatchMode == MatchManager.MatchMode.Conversion)
diff --git a/Assets/Scripts/Network/UI/MatchHud.cs b/Assets/Scripts/Network/UI/MatchHud.cs
index c039d71..9fd068f 100644
--- a/Assets/Scripts/Network/UI/MatchHud.cs
+++ b/Assets/Scripts/Network/UI/MatchHud.cs
@@ -104,6 +104,7 @@ public class MatchHud : MonoBehaviour
{
switch (phase)
{
+ case MatchManager.MatchSubPhase.Loading: return "Loading";
case MatchManager.MatchSubPhase.Hide: return "Hide";
case MatchManager.MatchSubPhase.Seek: return "Seek";
case MatchManager.MatchSubPhase.Resolution: return "Resolution";