diff --git a/SSMP/Game/Server/ModServerManager.cs b/SSMP/Game/Server/ModServerManager.cs
index cc1c244..63c9c42 100644
--- a/SSMP/Game/Server/ModServerManager.cs
+++ b/SSMP/Game/Server/ModServerManager.cs
@@ -1,4 +1,5 @@
using System;
+using System.Linq;
using SSMP.Game.Command.Server;
using SSMP.Game.Settings;
using SSMP.Networking.Packet;
@@ -58,9 +59,12 @@ public override void Initialize() {
AddonManager.LoadAddons();
// Register handlers for UI events
- _uiManager.RequestServerStartHostEvent += (_, port, _, transportType, _) =>
+ _uiManager.RequestServerStartHostEvent += (_, port, _, transportType, _) =>
OnRequestServerStartHost(port, _modSettings.FullSynchronisation, transportType);
_uiManager.RequestServerStopHostEvent += Stop;
+ PlayerConnectEvent += _ => UpdateMatchmakingRemotePlayerCount();
+ PlayerDisconnectEvent += _ => UpdateMatchmakingRemotePlayerCount();
+ ServerShutdownEvent += () => _uiManager.ConnectInterface.MmsClient.SetConnectedPlayers(0);
// Register application quit handler
// ModHooks.ApplicationQuitHook += Stop;
@@ -97,6 +101,7 @@ private void OnRequestServerStartHost(int port, bool fullSynchronisation, Transp
};
Start(port, fullSynchronisation, transportServer);
+ UpdateMatchmakingRemotePlayerCount();
}
///
@@ -119,4 +124,15 @@ protected override void DeregisterCommands() {
CommandManager.DeregisterCommand(_settingsCommand);
}
+
+ ///
+ /// Pushes the current remote-player count to MMS heartbeat state.
+ ///
+ private void UpdateMatchmakingRemotePlayerCount() {
+ var hostAuthKey = _modSettings.AuthKey;
+ var remotePlayerCount = hostAuthKey == null
+ ? 0
+ : Players.Count(player => player.AuthKey != hostAuthKey);
+ _uiManager.ConnectInterface.MmsClient.SetConnectedPlayers(remotePlayerCount);
+ }
}
diff --git a/SSMP/Networking/Matchmaking/Host/MmsHostSessionService.cs b/SSMP/Networking/Matchmaking/Host/MmsHostSessionService.cs
new file mode 100644
index 0000000..45b2410
--- /dev/null
+++ b/SSMP/Networking/Matchmaking/Host/MmsHostSessionService.cs
@@ -0,0 +1,508 @@
+using System;
+using System.Net;
+using System.Threading;
+using System.Threading.Tasks;
+using SSMP.Logging;
+using SSMP.Networking.Matchmaking.Join;
+using SSMP.Networking.Matchmaking.Parsing;
+using SSMP.Networking.Matchmaking.Protocol;
+using SSMP.Networking.Matchmaking.Transport;
+using SSMP.Networking.Matchmaking.Utilities;
+
+namespace SSMP.Networking.Matchmaking.Host;
+
+/// Host lobby lifecycle: creation, heartbeat, UDP discovery, and teardown.
+internal sealed class MmsHostSessionService : IDisposable {
+ /// The base HTTP URL of the MMS server (e.g. https://mms.example.com).
+ private readonly string _baseUrl;
+
+ /// NAT discovery hostname; null if disabled.
+ private readonly string? _discoveryHost;
+
+ /// Synchronization lock for thread-safe access to session state (tokens, lobby IDs).
+ private readonly object _sessionLock = new();
+
+ /// Prevents concurrent lobby creation.
+ private int _creationLock;
+
+ /// Whether this service instance has been disposed.
+ private volatile bool _disposed;
+
+ /// WebSocket handler that receives real-time MMS server events.
+ private readonly MmsWebSocketHandler _webSocket;
+
+ /// MMS session bearer token; null if no active lobby.
+ private volatile string? _hostToken;
+
+ ///
+ /// The MMS lobby ID of the currently active session, or null when no
+ /// lobby is active.
+ ///
+ private string? _currentLobbyId;
+
+ /// MMS lobby keep-alive timer.
+ private Timer? _heartbeatTimer;
+
+ ///
+ /// Cancellation source to suppress in-flight heartbeat continuations after the lobby is closed.
+ ///
+ private CancellationTokenSource? _heartbeatCts;
+
+ /// The number of players currently connected to this host's session.
+ private int _connectedPlayers;
+
+ /// Count of consecutive heartbeat send failures observed by the timer callback.
+ private int _heartbeatFailureCount;
+
+ ///
+ /// Cancellation source that controls the background UDP discovery refresh task.
+ /// null when no refresh is running.
+ ///
+ private CancellationTokenSource? _hostDiscoveryRefreshCts;
+
+ ///
+ /// Initializes a new .
+ ///
+ /// Base HTTP URL of the MMS server.
+ ///
+ /// Hostname of the MMS UDP discovery endpoint, or null to disable
+ /// NAT hole-punch discovery.
+ ///
+ /// WebSocket handler for real-time MMS events.
+ public MmsHostSessionService(
+ string baseUrl,
+ string? discoveryHost,
+ MmsWebSocketHandler webSocket
+ ) {
+ _baseUrl = baseUrl;
+ _discoveryHost = discoveryHost;
+ _webSocket = webSocket;
+ }
+
+ ///
+ /// Raised when MMS requests a host-mapping refresh.
+ /// Provides the join ID, host discovery token, and a server correlation timestamp.
+ /// Forwarded directly from .
+ ///
+ public event Action? RefreshHostMappingRequested {
+ add => _webSocket.RefreshHostMappingRequested += value;
+ remove => _webSocket.RefreshHostMappingRequested -= value;
+ }
+
+ ///
+ /// Raised when MMS confirms that a host mapping has been received and recorded.
+ /// Forwarded directly from .
+ ///
+ public event Action? HostMappingReceived {
+ add => _webSocket.HostMappingReceived += value;
+ remove => _webSocket.HostMappingReceived -= value;
+ }
+
+ ///
+ /// Raised when MMS instructs this host to begin NAT hole-punching toward a client.
+ /// Provides the join ID, client IP, client port, host port, and a startTimeMs correlation timestamp.
+ /// Forwarded directly from .
+ ///
+ public event Action? StartPunchRequested {
+ add => _webSocket.StartPunchRequested += value;
+ remove => _webSocket.StartPunchRequested -= value;
+ }
+
+ /// Updates player count; triggers immediate heartbeat if changed.
+ public void SetConnectedPlayers(int count) {
+ if (_disposed) throw new ObjectDisposedException(nameof(MmsHostSessionService));
+
+ if (count < 0)
+ throw new ArgumentOutOfRangeException(nameof(count), count, "Connected player count cannot be negative.");
+
+ var previous = Interlocked.Exchange(ref _connectedPlayers, count);
+ if (previous == count) return;
+
+ if (_hostToken != null) SendHeartbeat(state: null);
+ }
+
+ /// Creates lobby on MMS and activates session.
+ public async
+ Task<((string? lobbyCode, string? lobbyName, string? hostDiscoveryToken) result, MatchmakingError error)>
+ CreateLobbyAsync(
+ int hostPort,
+ bool isPublic,
+ string gameVersion,
+ PublicLobbyType lobbyType
+ ) {
+ if (_disposed) throw new ObjectDisposedException(nameof(MmsHostSessionService));
+
+ if (Interlocked.CompareExchange(ref _creationLock, 1, 0) != 0)
+ return ((null, null, null), MatchmakingError.NetworkFailure);
+
+ try {
+ lock (_sessionLock) {
+ if (_disposed) throw new ObjectDisposedException(nameof(MmsHostSessionService));
+ if (_hostToken != null) return ((null, null, null), MatchmakingError.NetworkFailure);
+ }
+
+ using var lease = MmsJsonParser.FormatCreateLobbyJson(
+ hostPort, isPublic, gameVersion, lobbyType, MmsUtilities.GetLocalIpAddress()
+ );
+
+ var response = await MmsHttpClient.PostJsonAsync(
+ $"{_baseUrl}{MmsRoutes.Lobby}",
+ new string(lease.Span)
+ );
+
+ if (!response.Success || response.Body == null)
+ return ((null, null, null), response.Error);
+
+ return TryActivateLobby(
+ response.Body,
+ "CreateLobby",
+ out var lobbyName,
+ out var lobbyCode,
+ out var hostDiscoveryToken
+ )
+ ? ((lobbyCode, lobbyName, hostDiscoveryToken), MatchmakingError.None)
+ : ((null, null, null), MatchmakingError.NetworkFailure);
+ } finally {
+ Interlocked.Exchange(ref _creationLock, 0);
+ }
+ }
+
+ ///
+ /// Registers an existing Steam lobby with MMS, creating a corresponding MMS lobby entry.
+ ///
+ /// Steam lobby identifier to associate.
+ /// Whether the lobby should appear in public MMS listings.
+ /// Game version string for matchmaking compatibility.
+ ///
+ /// Returns the MMS lobby code on success; otherwise returns null along with a MatchmakingError describing the failure.
+ ///
+ public async Task<(string? lobbyCode, MatchmakingError error)> RegisterSteamLobbyAsync(
+ string steamLobbyId,
+ bool isPublic,
+ string gameVersion
+ ) {
+ if (_disposed) throw new ObjectDisposedException(nameof(MmsHostSessionService));
+
+ if (Interlocked.CompareExchange(ref _creationLock, 1, 0) != 0)
+ return (null, MatchmakingError.NetworkFailure);
+
+ try {
+ lock (_sessionLock) {
+ if (_disposed) throw new ObjectDisposedException(nameof(MmsHostSessionService));
+ if (_hostToken != null) return (null, MatchmakingError.NetworkFailure);
+ }
+
+ var response = await MmsHttpClient.PostJsonAsync(
+ $"{_baseUrl}{MmsRoutes.Lobby}",
+ BuildSteamLobbyJson(steamLobbyId, isPublic, gameVersion)
+ );
+ if (!response.Success || response.Body == null)
+ return (null, response.Error);
+
+ return !TryActivateLobby(response.Body, "RegisterSteamLobby", out _, out var lobbyCode, out _)
+ ? (null, MatchmakingError.NetworkFailure)
+ : (lobbyCode, MatchmakingError.None);
+ } finally {
+ Interlocked.Exchange(ref _creationLock, 0);
+ }
+ }
+
+ /// Stops session: heartbeat, discovery, and socket. Deletes lobby on MMS.
+ public void CloseLobby() {
+ (string token, string? lobbyId)? snapshot;
+ lock (_sessionLock) {
+ if (_hostToken == null) return;
+ snapshot = SnapshotAndClearSessionUnsafe();
+ StopHeartbeat();
+ }
+
+ StopHostDiscoveryRefresh();
+ _webSocket.Stop();
+
+ var (tokenSnapshot, lobbyIdSnapshot) = snapshot.Value;
+ _ = SafeDeleteLobbyAsync(tokenSnapshot, lobbyIdSnapshot);
+ }
+
+ ///
+ /// Starts the WebSocket connection that receives pending-client and punch events
+ /// from MMS. Requires an active lobby; logs an error and returns if no host token is available.
+ ///
+ public void StartWebSocketConnection() {
+ if (_disposed) throw new ObjectDisposedException(nameof(MmsHostSessionService));
+
+ if (_hostToken == null) {
+ Logger.Error("MmsHostSessionService: cannot start WebSocket without a host token.");
+ return;
+ }
+
+ _webSocket.Start(_hostToken);
+ }
+
+ /// Starts periodic background UDP discovery for external IP learning.
+ /// Session token sent inside each UDP packet.
+ ///
+ /// Callback that writes raw bytes through the caller's UDP socket to the given endpoint.
+ ///
+ public void StartHostDiscoveryRefresh(string hostDiscoveryToken, Action sendRawAction) {
+ if (_disposed) throw new ObjectDisposedException(nameof(MmsHostSessionService));
+
+ if (_discoveryHost == null) return;
+
+ var cts = new CancellationTokenSource(TimeSpan.FromSeconds(MmsProtocol.DiscoveryDurationSeconds));
+
+ var oldCts = Interlocked.Exchange(ref _hostDiscoveryRefreshCts, cts);
+ if (oldCts != null) {
+ try {
+ oldCts.Cancel();
+ } catch (ObjectDisposedException) {
+ /*ignored*/
+ }
+
+ oldCts.Dispose();
+ }
+
+ MmsUtilities.RunBackground(
+ RunHostDiscoveryRefreshAsync(hostDiscoveryToken, sendRawAction, cts),
+ nameof(MmsHostSessionService),
+ "host UDP discovery"
+ );
+ }
+
+ ///
+ /// Cancels the active UDP discovery refresh task, if any.
+ /// Safe to call when no refresh is running.
+ ///
+ public void StopHostDiscoveryRefresh() {
+ var cts = Interlocked.Exchange(ref _hostDiscoveryRefreshCts, null);
+ if (cts == null)
+ return;
+ try {
+ cts.Cancel();
+ } catch (ObjectDisposedException) {
+ /*ignored*/
+ }
+
+ cts.Dispose();
+ }
+
+ ///
+ /// Builds the JSON request body for a Steam lobby registration.
+ ///
+ /// Steam lobby ID sent as ConnectionData.
+ /// Public-visibility flag.
+ /// Game version string for compatibility filtering.
+ /// A JSON string ready to POST to the MMS lobby endpoint.
+ private static string BuildSteamLobbyJson(string steamLobbyId, bool isPublic, string gameVersion) =>
+ $"{{\"{MmsFields.ConnectionDataRequest}\":\"{MmsUtilities.EscapeJsonString(steamLobbyId)}\"," +
+ $"\"{MmsFields.IsPublicRequest}\":{MmsUtilities.BoolToJson(isPublic)}," +
+ $"\"{MmsFields.GameVersionRequest}\":\"{MmsUtilities.EscapeJsonString(gameVersion)}\"," +
+ $"\"{MmsFields.LobbyTypeRequest}\":\"steam\"}}";
+
+
+ ///
+ /// Captures the current session token and lobby ID, then clears both fields.
+ /// Called during to ensure the delete request uses
+ /// the correct values even if state is mutated concurrently.
+ /// IMPORTANT: Must be called while holding _sessionLock, and with _hostToken non-null.
+ ///
+ ///
+ /// A tuple of (hostToken, lobbyId) holding the values that were active
+ /// at the moment of the snapshot.
+ ///
+ private (string token, string? lobbyId) SnapshotAndClearSessionUnsafe() {
+ System.Diagnostics.Debug.Assert(Monitor.IsEntered(_sessionLock));
+ var snapshot = (_hostToken!, _currentLobbyId);
+ _hostToken = null;
+ _currentLobbyId = null;
+ return snapshot;
+ }
+
+ /// Validates and records lobby activation. Deletes if disposed mid-flight.
+ /// Raw JSON response body from MMS.
+ /// Human-readable operation name used in log messages.
+ /// Receives the lobby display name, or null on failure.
+ /// Receives the short lobby join code, or null on failure.
+ /// Receives the UDP discovery token, or null on failure.
+ /// true if parsing and activation succeeded; false otherwise.
+ private bool TryActivateLobby(
+ string response,
+ string operation,
+ out string? lobbyName,
+ out string? lobbyCode,
+ out string? hostDiscoveryToken
+ ) {
+ if (!MmsResponseParser.TryParseLobbyActivation(
+ response,
+ out var lobbyId,
+ out var hostToken,
+ out lobbyName,
+ out lobbyCode,
+ out hostDiscoveryToken
+ )) {
+ Logger.Error($"MmsHostSessionService: Invalid {operation} response (length={response.Length}).");
+ return false;
+ }
+
+ lock (_sessionLock) {
+ if (_disposed) {
+ _ = SafeDeleteLobbyAsync(hostToken!, lobbyId);
+ return false;
+ }
+
+ _hostToken = hostToken;
+ _currentLobbyId = lobbyId;
+ _heartbeatFailureCount = 0;
+ StartHeartbeat();
+ }
+
+ Logger.Info($"MmsHostSessionService: {operation} succeeded for lobby {lobbyCode}.");
+ return true;
+ }
+
+ ///
+ /// Stops any existing heartbeat timer and starts a new one that fires
+ /// every .
+ /// IMPORTANT: Caller must hold _sessionLock.
+ ///
+ private void StartHeartbeat() {
+ StopHeartbeat();
+ _heartbeatCts = new CancellationTokenSource();
+ _heartbeatTimer = new Timer(
+ SendHeartbeat, null, MmsProtocol.HeartbeatIntervalMs, MmsProtocol.HeartbeatIntervalMs
+ );
+ }
+
+ ///
+ /// Disposes the heartbeat timer. Safe to call when no timer is active.
+ /// IMPORTANT: Caller must hold _sessionLock.
+ ///
+ private void StopHeartbeat() {
+ _heartbeatTimer?.Dispose();
+ _heartbeatTimer = null;
+ if (_heartbeatCts == null)
+ return;
+ try {
+ _heartbeatCts.Cancel();
+ } catch (ObjectDisposedException) {
+ /*ignored*/
+ }
+
+ _heartbeatCts.Dispose();
+ _heartbeatCts = null;
+ }
+
+ ///
+ /// Timer callback that POSTs the current connected-player count to the MMS
+ /// heartbeat endpoint. Fire-and-forget with a continuation that tracks and logs consecutive failures.
+ /// Failures are not retried but are logged and tracked via a consecutive failure counter.
+ ///
+ /// Unused timer state; always null.
+ private void SendHeartbeat(object? state) {
+ string? token;
+ CancellationToken cancellationToken;
+ lock (_sessionLock) {
+ token = _hostToken;
+ if (token == null || _heartbeatCts == null)
+ return;
+ cancellationToken = _heartbeatCts.Token;
+ }
+
+ var players = Interlocked.CompareExchange(ref _connectedPlayers, 0, 0);
+ var heartbeatTask = MmsHttpClient.PostJsonAsync(
+ $"{_baseUrl}{MmsRoutes.LobbyHeartbeat(token)}",
+ BuildHeartbeatJson(players)
+ );
+ heartbeatTask.ContinueWith(
+ task => {
+ if (cancellationToken.IsCancellationRequested) return;
+
+ if (task.IsFaulted) {
+ var failures = Interlocked.Increment(ref _heartbeatFailureCount);
+ Logger.Debug($"MmsHostSessionService: heartbeat send faulted ({failures} consecutive failures).");
+ return;
+ }
+
+ if (task.Result.Success) {
+ Interlocked.Exchange(ref _heartbeatFailureCount, 0);
+ return;
+ }
+
+ var rejectedFailures = Interlocked.Increment(ref _heartbeatFailureCount);
+ Logger.Debug(
+ $"MmsHostSessionService: heartbeat rejected or failed ({rejectedFailures} consecutive failures)."
+ );
+ },
+ TaskScheduler.Default
+ );
+ }
+
+ ///
+ /// Builds the JSON body for a heartbeat POST.
+ ///
+ /// Current connected-player count to report to MMS.
+ /// A JSON string ready to POST to the heartbeat endpoint.
+ private static string BuildHeartbeatJson(int connectedPlayers) =>
+ $"{{\"ConnectedPlayers\":{connectedPlayers}}}";
+
+ ///
+ /// Backing task for . Runs
+ /// .
+ ///
+ /// Token forwarded to .
+ /// UDP send callback forwarded to .
+ /// The active cancellation token source.
+ private async Task RunHostDiscoveryRefreshAsync(
+ string hostDiscoveryToken,
+ Action sendRawAction,
+ CancellationTokenSource cts
+ ) {
+ try {
+ // Defensive check; normal flow is already guarded in StartHostDiscoveryRefresh
+ if (_discoveryHost == null) return;
+
+ await UdpDiscoveryService.SendUntilCancelledAsync(
+ _discoveryHost,
+ hostDiscoveryToken,
+ sendRawAction,
+ cts.Token
+ );
+ } finally {
+ // If StopHostDiscoveryRefresh or a new Start request was called concurrently,
+ // they will have already swapped out _hostDiscoveryRefreshCts and disposed this cts.
+ // CompareExchange checks if we still own it; if so, we clear the field and dispose it ourselves.
+ var currentCts = Interlocked.CompareExchange(ref _hostDiscoveryRefreshCts, null, cts);
+ if (ReferenceEquals(currentCts, cts)) {
+ cts.Dispose();
+ }
+ }
+ }
+
+ ///
+ /// Sends a DELETE to the MMS lobby endpoint. Logs success or warns on failure.
+ /// Intended to be called fire-and-forget after has
+ /// already cleared the local session state.
+ ///
+ /// Bearer token identifying the lobby to delete.
+ /// Lobby ID used only for logging.
+ private async Task SafeDeleteLobbyAsync(string hostToken, string? lobbyId) {
+ var response = await MmsHttpClient.DeleteAsync($"{_baseUrl}{MmsRoutes.LobbyDelete(hostToken)}");
+ if (response.Success) {
+ Logger.Info($"MmsHostSessionService: closed lobby {lobbyId}.");
+ return;
+ }
+
+ Logger.Warn($"MmsHostSessionService: CloseLobby DELETE failed for lobby {lobbyId}.");
+ }
+
+ ///
+ /// Marks the service as disposed, prevents further lobby creation, and closes the active lobby if present.
+ ///
+ public void Dispose() {
+ Interlocked.Exchange(ref _creationLock, 1);
+ lock (_sessionLock) {
+ _disposed = true;
+ }
+
+ CloseLobby();
+ }
+}
diff --git a/SSMP/Networking/Matchmaking/Host/MmsWebSocketHandler.cs b/SSMP/Networking/Matchmaking/Host/MmsWebSocketHandler.cs
new file mode 100644
index 0000000..e92bfde
--- /dev/null
+++ b/SSMP/Networking/Matchmaking/Host/MmsWebSocketHandler.cs
@@ -0,0 +1,414 @@
+using System;
+using System.Collections.Concurrent;
+using System.Net.WebSockets;
+using System.Threading;
+using System.Threading.Tasks;
+using SSMP.Logging;
+using SSMP.Networking.Matchmaking.Parsing;
+using SSMP.Networking.Matchmaking.Protocol;
+using SSMP.Networking.Matchmaking.Utilities;
+
+namespace SSMP.Networking.Matchmaking.Host;
+
+/// Host-MMS WebSocket manager. No auto-reconnect; manual required on drop.
+internal sealed class MmsWebSocketHandler : IDisposable, IAsyncDisposable {
+ /// The base WebSocket URL of the MMS service.
+ private readonly string _wsBaseUrl;
+
+ /// Captures context (usually Unity main thread) for event marshaling.
+ private readonly SynchronizationContext? _mainThreadContext;
+
+ /// Synchronizes swaps of the active socket/CTS pair across overlapping start-stop cycles.
+ private readonly object _stateGate = new();
+
+ /// The underlying WebSocket client.
+ private ClientWebSocket? _socket;
+
+ /// Cancellation source for the background listening loop.
+ private CancellationTokenSource? _cts;
+
+ /// Generation counter to invalidate stale background runs.
+ private int _runVersion;
+
+ /// Awaited by to ensure clean exit.
+ private Task _runTask = Task.CompletedTask;
+
+ ///
+ /// Maximum time to wait for a graceful WebSocket close handshake before
+ /// abandoning and disposing the socket.
+ ///
+ private static readonly TimeSpan CloseHandshakeTimeout = TimeSpan.FromSeconds(2);
+
+ /// Raised on NAT refresh request. Marshaled to construction thread.
+ public event Action? RefreshHostMappingRequested;
+
+ ///
+ /// Raised when MMS signals both sides to start simultaneous hole-punch.
+ /// Arguments: joinId, clientIp, clientPort, hostPort, startTimeMs.
+ ///
+ /// Handlers are always invoked on the that was
+ /// active when this handler was constructed (typically the Unity main thread).
+ /// Calling or from within a handler is safe.
+ /// Calling and awaiting it inline from within a handler
+ /// will deadlock; schedule the await on a separate task instead.
+ ///
+ ///
+ /// Ordering: Events are posted to the main-thread queue in arrival order,
+ /// but Unity processes posted callbacks at its own schedule (typically the next
+ /// frame). Two events received in rapid succession are guaranteed to execute in
+ /// order, but may execute across different frames.
+ ///
+ ///
+ public event Action? StartPunchRequested;
+
+ /// Raised on mapping confirmation. Marshaled to construction thread.
+ public event Action? HostMappingReceived;
+
+ /// Initializes handler. Capture current for marshaling.
+ /// Base WebSocket URL of the MMS service (e.g. wss://mms.example.com).
+ public MmsWebSocketHandler(string wsBaseUrl) {
+ _wsBaseUrl = wsBaseUrl;
+ _mainThreadContext = SynchronizationContext.Current;
+ }
+
+ /// Opens connection and starts listener. Stops existing connection first.
+ /// Bearer token used to authenticate the WebSocket URL.
+ public void Start(string hostToken) {
+ var runVersion = InvalidateActiveRun();
+ _runTask = MmsUtilities.RunBackground(
+ RunAsync(hostToken, runVersion),
+ nameof(MmsWebSocketHandler),
+ "host WebSocket listener"
+ );
+ }
+
+ ///
+ /// Cancels the listening loop and disposes the WebSocket connection.
+ /// Safe to call when no connection is active.
+ ///
+ public void Stop() {
+ InvalidateActiveRun();
+ }
+
+ ///
+ ///
+ ///
+ /// This overload calls but does not await the
+ /// background task. If the background loop is still running when this returns, any
+ /// in-flight I/O may complete after the caller has moved on.
+ /// Prefer in async contexts to guarantee the background
+ /// task has fully exited before disposal completes.
+ ///
+ ///
+ public void Dispose() => Stop();
+
+ /// Stops and awaits background task. Prefer over in async contexts.
+ public async ValueTask DisposeAsync() {
+ Stop();
+ await _runTask.ConfigureAwait(false);
+ }
+
+ ///
+ /// Entry point for the background task. Connects the socket, runs the receive
+ /// loop, then drains any remaining queued events before tearing down.
+ /// Each run creates its own isolated event queue so that overlapping start-stop
+ /// cycles cannot steal or drop events across runs.
+ ///
+ /// Bearer token used to build the WebSocket URL.
+ /// Generation number captured when this run was started.
+ private async Task RunAsync(string hostToken, int runVersion) {
+ var cts = new CancellationTokenSource();
+ var socket = new ClientWebSocket();
+ var eq = new EventQueue();
+
+ if (!TryRegisterRun(runVersion, socket, cts)) {
+ cts.Dispose();
+ socket.Dispose();
+ eq.Dispose();
+ return;
+ }
+
+ try {
+ await ConnectAsync(socket, hostToken, cts.Token);
+ var drainTask = DrainEventQueueAsync(eq);
+ await ReceiveLoopAsync(socket, cts.Token, eq);
+ // Signal the dispatcher to stop after all queued callbacks are posted.
+ eq.Enqueue(null);
+ await drainTask;
+ } catch (Exception ex) when (ex is not OperationCanceledException) {
+ Logger.Error($"MmsWebSocketHandler: error - {ex.Message}");
+ } finally {
+ eq.Dispose();
+ await TearDownSocket(runVersion, socket, cts);
+ }
+ }
+
+ ///
+ /// Connects to the host WebSocket endpoint.
+ ///
+ /// The WebSocket instance owned by the current run.
+ /// Token appended to the WebSocket URL path.
+ /// Cancellation token for the connection attempt.
+ private async Task ConnectAsync(ClientWebSocket socket, string hostToken, CancellationToken cancellationToken) {
+ var uri = new Uri($"{_wsBaseUrl}{MmsRoutes.HostWebSocket(hostToken)}");
+ await socket.ConnectAsync(uri, cancellationToken);
+ Logger.Info("MmsWebSocketHandler: connected");
+ }
+
+ ///
+ /// Reads messages from the supplied until the connection closes or
+ /// cancellation is requested. Each text frame is forwarded to
+ /// . Events are enqueued rather than raised directly.
+ ///
+ /// The WebSocket instance owned by the current run.
+ /// Cancellation token that ends the receive loop.
+ /// Run-local event queue for marshaling event invocations.
+ private async Task ReceiveLoopAsync(ClientWebSocket socket, CancellationToken cancellationToken, EventQueue eq) {
+ while (socket.State == WebSocketState.Open && !cancellationToken.IsCancellationRequested) {
+ WebSocketMessageType messageType;
+ string? message;
+ try {
+ (messageType, message) = await MmsUtilities.ReceiveTextMessageAsync(socket, cancellationToken);
+ } catch (InvalidOperationException ex) {
+ Logger.Error($"MmsWebSocketHandler: disconnecting - {ex.Message}");
+ break;
+ }
+
+ if (messageType == WebSocketMessageType.Close) break;
+ if (messageType != WebSocketMessageType.Text || string.IsNullOrEmpty(message)) continue;
+
+ HandleMessage(message, eq);
+ }
+ }
+
+ ///
+ /// Waits for items in the run-local and dispatches them
+ /// sequentially until a sentinel is dequeued, indicating
+ /// shutdown. Runs unconditionally to completion so that no already-queued events
+ /// are dropped on shutdown.
+ ///
+ /// Each action is posted through when one was
+ /// captured at construction, ensuring all public events are raised on the Unity
+ /// main thread. When no context is available the action is invoked directly.
+ ///
+ ///
+ /// Ordering vs. same-frame execution:
+ /// is fire-and-forget -- this loop does not await completion of the posted
+ /// callback before dequeuing the next item. Consequently:
+ ///
+ /// - Events are enqueued on the main-thread context in strict arrival
+ /// order.
+ /// - Unity drains its posted-callback queue at its own cadence (typically once
+ /// per frame), so two rapidly arriving events are ordered but may execute across
+ /// different frames.
+ /// - Using SynchronizationContext.Send instead would enforce same-frame
+ /// execution but risks deadlocking if a handler calls
+ /// inline, so Post is the correct choice here.
+ /// See the Unity
+ /// Asynchronous Programming docs for background on UnitySynchronizationContext.
+ ///
+ ///
+ ///
+ /// Run-local event queue to drain.
+ private async Task DrainEventQueueAsync(EventQueue eq) {
+ while (true) {
+ await eq.WaitAsync();
+
+ if (!eq.TryDequeue(out var action) || action == null)
+ break;
+
+ if (_mainThreadContext != null)
+ _mainThreadContext.Post(static s => ((Action)s!)(), action);
+ else
+ action();
+ }
+ }
+
+ ///
+ /// Attempts a graceful WebSocket close handshake, clears shared references
+ /// if this run still owns them, then disposes the socket and cancellation source.
+ /// Called from the finally block of .
+ ///
+ /// Generation number for the run being torn down.
+ /// The socket owned by that run.
+ /// The cancellation source owned by that run.
+ private async Task TearDownSocket(int runVersion, ClientWebSocket socket, CancellationTokenSource cts) {
+ lock (_stateGate) {
+ if (_runVersion == runVersion) {
+ if (ReferenceEquals(_socket, socket))
+ _socket = null;
+
+ if (ReferenceEquals(_cts, cts))
+ _cts = null;
+ }
+ }
+
+ if (socket.State is WebSocketState.Open or WebSocketState.CloseReceived) {
+ using var closeCts = new CancellationTokenSource(CloseHandshakeTimeout);
+ try {
+ await socket.CloseAsync(WebSocketCloseStatus.NormalClosure, "stopping", closeCts.Token);
+ } catch {
+ // best-effort only - swallow all exceptions
+ }
+ }
+
+ cts.Dispose();
+ socket.Dispose();
+ Logger.Info("MmsWebSocketHandler: disconnected");
+ }
+
+ ///
+ /// Cancels any active run and returns the next valid version number.
+ /// The cancel is performed inside the lock to prevent a race where
+ /// disposes the CTS on the background thread
+ /// between the lock release and the Cancel() call on the calling thread.
+ ///
+ /// The generation number that should be used by the next background run.
+ private int InvalidateActiveRun() {
+ int nextVersion;
+
+ lock (_stateGate) {
+ _cts?.Cancel();
+ _cts = null;
+ _socket = null;
+ nextVersion = unchecked(++_runVersion);
+ }
+
+ return nextVersion;
+ }
+
+ ///
+ /// Registers the run-local socket and cancellation source if the run is still current.
+ ///
+ /// Generation number captured when the run was started.
+ /// Socket allocated for the run.
+ /// Cancellation source allocated for the run.
+ /// if the run is still current and has become active.
+ private bool TryRegisterRun(int runVersion, ClientWebSocket socket, CancellationTokenSource cts) {
+ lock (_stateGate) {
+ if (_runVersion != runVersion)
+ return false;
+
+ _socket = socket;
+ _cts = cts;
+ return true;
+ }
+ }
+
+ ///
+ /// Extracts the action field from and
+ /// routes it to the appropriate handler method.
+ /// Unrecognised actions are silently ignored.
+ ///
+ /// Decoded UTF-8 text frame received from MMS.
+ /// Run-local event queue for marshalling event invocations.
+ private void HandleMessage(string message, EventQueue eq) {
+ var span = message.AsSpan();
+ var action = MmsJsonParser.ExtractValue(span, MmsFields.Action);
+
+ switch (action) {
+ case MmsActions.RefreshHostMapping: HandleRefreshHostMapping(span, eq); break;
+ case MmsActions.StartPunch: HandleStartPunch(span, eq); break;
+ case MmsActions.HostMappingReceived: HandleHostMappingReceived(eq); break;
+ case MmsActions.JoinFailed: HandleJoinFailed(message); break;
+ default:
+ Logger.Debug($"MmsWebSocketHandler: unknown action '{new string(action)}' mapped to message dropping");
+ break;
+ }
+ }
+
+ ///
+ /// Handles a refresh_host_mapping message by extracting the join ID,
+ /// discovery token, and server timestamp, then enqueuing a raise of
+ /// . Silently ignored if any required
+ /// field is missing or unparseable.
+ ///
+ /// Span over the raw message text.
+ /// Run-local event queue for marshalling event invocations.
+ private void HandleRefreshHostMapping(ReadOnlySpan span, EventQueue eq) {
+ var joinId = MmsJsonParser.ExtractValue(span, MmsFields.JoinId);
+ var token = MmsJsonParser.ExtractValue(span, MmsFields.HostDiscoveryToken);
+ var timeStr = MmsJsonParser.ExtractValue(span, MmsFields.ServerTimeMs);
+
+ if (joinId == null || token == null || !long.TryParse(timeStr, out var time))
+ return;
+
+ Logger.Info($"MmsWebSocketHandler: {MmsActions.RefreshHostMapping} for join {joinId}");
+ eq.Enqueue(() => RefreshHostMappingRequested?.Invoke(joinId, token, time));
+ }
+
+ ///
+ /// Handles a start_punch message by extracting the join ID, client
+ /// endpoint, host port, and start timestamp, then enqueuing a raise of
+ /// . Silently ignored if any required field
+ /// is missing or unparseable.
+ ///
+ /// Span over the raw message text.
+ /// Run-local event queue for marshalling event invocations.
+ private void HandleStartPunch(ReadOnlySpan span, EventQueue eq) {
+ var joinId = MmsJsonParser.ExtractValue(span, MmsFields.JoinId);
+ var clientIp = MmsJsonParser.ExtractValue(span, MmsFields.ClientIp);
+ var clientPortStr = MmsJsonParser.ExtractValue(span, MmsFields.ClientPort);
+ var hostPortStr = MmsJsonParser.ExtractValue(span, MmsFields.HostPort);
+ var startTimeStr = MmsJsonParser.ExtractValue(span, MmsFields.StartTimeMs);
+
+ if (joinId == null ||
+ clientIp == null ||
+ !int.TryParse(clientPortStr, out var clientPort) ||
+ !int.TryParse(hostPortStr, out var hostPort) ||
+ !long.TryParse(startTimeStr, out var startTimeMs))
+ return;
+
+ Logger.Info($"MmsWebSocketHandler: {MmsActions.StartPunch} for join {joinId} -> {clientIp}:{clientPort}");
+ eq.Enqueue(() => StartPunchRequested?.Invoke(joinId, clientIp, clientPort, hostPort, startTimeMs));
+ }
+
+ ///
+ /// Handles a host_mapping_received message by logging and enqueuing
+ /// a raise of .
+ ///
+ /// Run-local event queue for marshalling event invocations.
+ private void HandleHostMappingReceived(EventQueue eq) {
+ Logger.Info($"MmsWebSocketHandler: {MmsActions.HostMappingReceived}");
+ eq.Enqueue(() => HostMappingReceived?.Invoke());
+ }
+
+ ///
+ /// Handles a join_failed message by logging the full message body
+ /// as a warning. No event is raised because the host has no corrective action
+ /// beyond surfacing the diagnostic.
+ ///
+ /// Full raw message text, logged verbatim for diagnostics.
+ private static void HandleJoinFailed(string message) {
+ Logger.Warn($"MmsWebSocketHandler: {MmsActions.JoinFailed} - {message}");
+ }
+
+ ///
+ /// Run-local event queue that pairs a with a
+ /// to provide an async-wait, single-consumer dispatch
+ /// channel. Each creates its own instance so that
+ /// overlapping start-stop cycles cannot steal or drop events across runs.
+ ///
+ private sealed class EventQueue : IDisposable {
+ private readonly ConcurrentQueue _queue = new();
+ private readonly SemaphoreSlim _semaphore = new(0);
+
+ ///
+ /// Enqueues an action (or sentinel) and releases the
+ /// semaphore so the drain loop wakes.
+ ///
+ public void Enqueue(Action? action) {
+ _queue.Enqueue(action);
+ _semaphore.Release();
+ }
+
+ /// Waits until an item is available.
+ public Task WaitAsync() => _semaphore.WaitAsync();
+
+ /// Attempts to dequeue the next item.
+ public bool TryDequeue(out Action? action) => _queue.TryDequeue(out action);
+
+ ///
+ public void Dispose() => _semaphore.Dispose();
+ }
+}
diff --git a/SSMP/Networking/Matchmaking/Join/MmsJoinCoordinator.cs b/SSMP/Networking/Matchmaking/Join/MmsJoinCoordinator.cs
new file mode 100644
index 0000000..976ccf8
--- /dev/null
+++ b/SSMP/Networking/Matchmaking/Join/MmsJoinCoordinator.cs
@@ -0,0 +1,253 @@
+using System;
+using System.Net;
+using System.Net.WebSockets;
+using System.Threading;
+using System.Threading.Tasks;
+using SSMP.Logging;
+using SSMP.Networking.Matchmaking.Parsing;
+using SSMP.Networking.Matchmaking.Protocol;
+using SSMP.Networking.Matchmaking.Utilities;
+
+namespace SSMP.Networking.Matchmaking.Join;
+
+/// Client-side matchmaking coordinator. Drives UDP mapping and awaits hole-punch signals.
+internal sealed class MmsJoinCoordinator {
+ /// Base HTTP URL of the MMS server (e.g. https://mms.example.com).
+ private readonly string _baseUrl;
+
+ ///
+ /// Hostname used for UDP NAT hole-punch discovery, or null if discovery
+ /// is unavailable. When null, begin_client_mapping messages are
+ /// silently skipped.
+ ///
+ private readonly string? _discoveryHost;
+
+ ///
+ /// Initialises a new .
+ ///
+ /// Base HTTP URL of the MMS server.
+ ///
+ /// Hostname of the MMS UDP discovery endpoint, or null to skip
+ /// NAT hole-punch discovery.
+ ///
+ public MmsJoinCoordinator(string baseUrl, string? discoveryHost) {
+ _baseUrl = baseUrl;
+ _discoveryHost = discoveryHost;
+ }
+
+ ///
+ /// Mutable holder for the active UDP discovery ,
+ /// allowing handler methods to update it without ref parameters.
+ ///
+ private sealed class DiscoverySession : IDisposable {
+ ///
+ /// The CTS governing the currently running discovery task, or null
+ /// if no discovery is active.
+ ///
+ public CancellationTokenSource? Cts;
+
+ /// Cancels without disposing it.
+ public void Cancel() {
+ Cts?.Cancel();
+ }
+
+ /// Cancels and disposes if it is set.
+ public void Dispose() {
+ Cts?.Cancel();
+ Cts?.Dispose();
+ Cts = null;
+ }
+ }
+
+ /// Connects to join WebSocket and drives server-directed UDP mapping flow.
+ public async Task CoordinateAsync(
+ string joinId,
+ Action sendRawAction,
+ Action onJoinFailed,
+ CancellationToken cancellationToken
+ ) {
+ if (_discoveryHost == null)
+ Logger.Warn("MmsJoinCoordinator: discovery host unknown; UDP mapping will be skipped");
+
+ using var socket = new ClientWebSocket();
+ using var sessionCts =
+ new CancellationTokenSource(TimeSpan.FromMilliseconds(MmsProtocol.MatchmakingWebSocketTimeoutMs));
+ using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(sessionCts.Token, cancellationToken);
+ var discovery = new DiscoverySession();
+
+ try {
+ await ConnectAsync(socket, joinId, timeoutCts.Token);
+ return await RunMessageLoopAsync(socket, timeoutCts, sendRawAction, discovery, onJoinFailed);
+ } catch (OperationCanceledException) {
+ onJoinFailed("Timeout.");
+ } catch (WebSocketException ex) {
+ onJoinFailed(ex.Message);
+ Logger.Error($"MmsJoinCoordinator: matchmaking WebSocket error: {ex.Message}");
+ } catch (Exception ex) {
+ onJoinFailed(ex.Message);
+ Logger.Error($"MmsJoinCoordinator: CoordinateAsync failed: {ex.Message}");
+ } finally {
+ discovery.Dispose();
+ }
+
+ return null;
+ }
+
+ /// Connects to the MMS join WebSocket URL.
+ private async Task ConnectAsync(ClientWebSocket socket, string joinId, CancellationToken ct) {
+ var wsUrl =
+ $"{MmsUtilities.ToWebSocketUrl(_baseUrl)}{MmsRoutes.JoinWebSocket(joinId)}" +
+ $"?{MmsQueryKeys.MatchmakingVersion}={MmsProtocol.CurrentVersion}";
+
+ await socket.ConnectAsync(new Uri(wsUrl), ct);
+ }
+
+ /// Reads WebSocket frames until terminal signal or timeout.
+ private async Task RunMessageLoopAsync(
+ ClientWebSocket socket,
+ CancellationTokenSource timeoutCts,
+ Action sendRaw,
+ DiscoverySession discovery,
+ Action onJoinFailed
+ ) {
+ while (socket.State == WebSocketState.Open && !timeoutCts.Token.IsCancellationRequested) {
+ WebSocketMessageType messageType;
+ string? message;
+ try {
+ (messageType, message) = await MmsUtilities.ReceiveTextMessageAsync(socket, timeoutCts.Token);
+ } catch (InvalidOperationException ex) {
+ onJoinFailed($"Matchmaking error: {ex.Message}");
+ break;
+ }
+
+ if (messageType == WebSocketMessageType.Close) {
+ onJoinFailed("Connection closed prematurely by server.");
+ break;
+ }
+ if (messageType != WebSocketMessageType.Text || string.IsNullOrEmpty(message)) continue;
+
+ var outcome = await HandleMessage(message, timeoutCts, sendRaw, discovery, onJoinFailed);
+ if (outcome.hasResult) return outcome.result;
+ }
+
+ return null;
+ }
+
+ /// Routes message actions to handlers.
+ private async Task<(bool hasResult, MatchmakingJoinStartResult? result)> HandleMessage(
+ string message,
+ CancellationTokenSource timeoutCts,
+ Action sendRaw,
+ DiscoverySession discovery,
+ Action onJoinFailed
+ ) {
+ var action = MmsJsonParser.ExtractValue(message.AsSpan(), MmsFields.Action);
+
+ switch (action) {
+ case MmsActions.BeginClientMapping:
+ RestartDiscovery(message, sendRaw, discovery);
+ break;
+
+ case MmsActions.StartPunch:
+ var joinStart = await HandleStartPunchAsync(message, timeoutCts, discovery, onJoinFailed);
+ return (true, joinStart);
+
+ case MmsActions.ClientMappingReceived:
+ discovery.Cancel();
+ break;
+
+ case MmsActions.JoinFailed:
+ HandleJoinFailed(message, onJoinFailed);
+ return (true, null);
+
+ default:
+ Logger.Debug($"MmsJoinCoordinator: Unknown action '{new string(action)}' mapped to message dropping");
+ break;
+ }
+
+ return (false, null);
+ }
+
+ /// Restarts UDP discovery with new token.
+ private void RestartDiscovery(
+ string message,
+ Action sendRaw,
+ DiscoverySession discovery
+ ) {
+ var token = MmsJsonParser.ExtractValue(message.AsSpan(), MmsFields.ClientDiscoveryToken);
+ discovery.Cancel();
+ discovery.Cts = StartDiscovery(token, sendRaw);
+ }
+
+ /// Stops discovery, parses payload, and delays until start time.
+ private static async Task HandleStartPunchAsync(
+ string message,
+ CancellationTokenSource timeoutCts,
+ DiscoverySession discovery,
+ Action onJoinFailed
+ ) {
+ discovery.Cancel();
+
+ var joinStart = MmsResponseParser.ParseStartPunch(message.AsSpan());
+ if (joinStart == null) {
+ Logger.Warn($"MmsJoinCoordinator: Failed to parse start punch payload: {message}");
+ onJoinFailed("Invalid start_punch payload received from server.");
+ return null;
+ }
+
+ await DelayUntilAsync(joinStart.StartTimeMs, timeoutCts.Token);
+ return joinStart;
+ }
+
+ ///
+ /// Starts a new UDP discovery task for .
+ /// Returns null without starting anything if
+ /// is null or empty, or if is null.
+ ///
+ /// UDP discovery token from the begin_client_mapping message.
+ /// UDP send callback forwarded to .
+ ///
+ /// A new governing the started discovery
+ /// task, or null if discovery was not started.
+ ///
+ private CancellationTokenSource? StartDiscovery(string? token, Action sendRaw) {
+ if (string.IsNullOrEmpty(token)) {
+ Logger.Warn("MmsJoinCoordinator: begin_client_mapping missing token");
+ return null;
+ }
+
+ if (_discoveryHost == null)
+ return null;
+
+ var cts = new CancellationTokenSource(TimeSpan.FromSeconds(MmsProtocol.DiscoveryDurationSeconds));
+ MmsUtilities.RunBackground(
+ UdpDiscoveryService.SendUntilCancelledAsync(_discoveryHost, token, sendRaw, cts.Token),
+ nameof(MmsJoinCoordinator),
+ "client UDP discovery"
+ );
+ return cts;
+ }
+
+ ///
+ /// Waits until the specified Unix timestamp (in milliseconds) before returning.
+ /// Returns immediately if the target time is already in the past. If the target time
+ /// is far in the future, the delay will simply block until fires.
+ ///
+ /// Target time expressed as milliseconds since the Unix epoch (UTC).
+ /// Cancellation token that can abort the wait early.
+ private static async Task DelayUntilAsync(long targetUnixMs, CancellationToken ct) {
+ var delayMs = targetUnixMs - DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
+ if (delayMs > 0) await Task.Delay(TimeSpan.FromMilliseconds(delayMs), ct);
+ }
+
+ ///
+ /// Extracts the server-supplied failure reason from a join_failed message,
+ /// forwards it to the caller, and records the payload for diagnostics.
+ ///
+ /// Raw JSON WebSocket message from MMS.
+ /// Callback that updates higher-level matchmaking state.
+ private static void HandleJoinFailed(string message, Action onJoinFailed) {
+ onJoinFailed(MmsJsonParser.ExtractValue(message.AsSpan(), MmsFields.Reason) ?? "join_failed");
+ Logger.Warn($"MmsJoinCoordinator: {MmsActions.JoinFailed} - {message}");
+ }
+}
diff --git a/SSMP/Networking/Matchmaking/Join/UdpDiscoveryService.cs b/SSMP/Networking/Matchmaking/Join/UdpDiscoveryService.cs
new file mode 100644
index 0000000..a862e68
--- /dev/null
+++ b/SSMP/Networking/Matchmaking/Join/UdpDiscoveryService.cs
@@ -0,0 +1,116 @@
+using System;
+using System.Net;
+using System.Net.Sockets;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using SSMP.Logging;
+using SSMP.Networking.Matchmaking.Protocol;
+
+namespace SSMP.Networking.Matchmaking.Join;
+
+/// Sends UDP discovery pulses to learn external IP/port for NAT hole-punching.
+internal static class UdpDiscoveryService {
+ /// Expected discovery token length in bytes.
+ private const int ExpectedTokenByteLength = 32;
+
+ /// Resolves endpoint and sends token pulses until cancellation.
+ public static async Task SendUntilCancelledAsync(
+ string discoveryHost,
+ string token,
+ Action sendRaw,
+ CancellationToken cancellationToken
+ ) {
+ var endpoint = await ResolveEndpointAsync(discoveryHost);
+ if (endpoint is null) return;
+
+ var tokenBytes = EncodeToken(token);
+ if (tokenBytes.Length != ExpectedTokenByteLength) {
+ Logger.Error(
+ $"UdpDiscoveryService: discovery token encoded to {tokenBytes.Length} bytes; expected {ExpectedTokenByteLength}. Aborting discovery."
+ );
+ return;
+ }
+
+ await RunDiscoveryLoopAsync(sendRaw, tokenBytes, endpoint, cancellationToken).ConfigureAwait(false);
+ }
+
+ ///
+ /// Resolves to an on
+ /// . Returns null and logs an
+ /// error if DNS resolution yields no addresses.
+ ///
+ private static async Task ResolveEndpointAsync(string host) {
+ try {
+ var addresses = await Dns.GetHostAddressesAsync(host).ConfigureAwait(false);
+
+ if (addresses is { Length: > 0 }) {
+ var address = addresses[0];
+ foreach (var a in addresses) {
+ if (a.AddressFamily != AddressFamily.InterNetwork)
+ continue;
+ address = a;
+ break;
+ }
+ return new IPEndPoint(address, MmsProtocol.DiscoveryPort);
+ }
+
+ Logger.Error($"UdpDiscoveryService: could not resolve host '{host}'");
+ return null;
+ } catch (Exception ex) when (ex is SocketException or OperationCanceledException) {
+ Logger.Warn($"UdpDiscoveryService: DNS resolution failed for '{host}': {ex.Message}");
+ return null;
+ }
+ }
+
+ /// Encodes to a UTF-8 byte array.
+ private static byte[] EncodeToken(string token) =>
+ Encoding.UTF8.GetBytes(token);
+
+ ///
+ /// Loops, sending to
+ /// every until
+ /// fires or a send error occurs.
+ ///
+ private static async Task RunDiscoveryLoopAsync(
+ Action sendRaw,
+ byte[] tokenBytes,
+ IPEndPoint endpoint,
+ CancellationToken cancellationToken
+ ) {
+ while (!cancellationToken.IsCancellationRequested) {
+ TrySend(sendRaw, tokenBytes, endpoint);
+
+ if (!await TryDelayAsync(cancellationToken).ConfigureAwait(false)) return;
+ }
+ }
+
+ ///
+ /// Attempts a single send. Returns false (and logs a warning) on failure.
+ ///
+ private static void TrySend(
+ Action sendRaw,
+ byte[] tokenBytes,
+ IPEndPoint endpoint
+ ) {
+ try {
+ sendRaw(tokenBytes, endpoint);
+ } catch (Exception ex) when (ex is not OperationCanceledException) {
+ Logger.Warn($"UdpDiscoveryService: send error, aborting – {ex}");
+ }
+ }
+
+ ///
+ /// Waits for one discovery interval. Returns false when the
+ /// cancellation token fires (normal shutdown), true otherwise.
+ ///
+ private static async Task TryDelayAsync(CancellationToken cancellationToken) {
+ try {
+ await Task.Delay(MmsProtocol.DiscoveryIntervalMs, cancellationToken)
+ .ConfigureAwait(false);
+ return true;
+ } catch (OperationCanceledException) {
+ return false;
+ }
+ }
+}
diff --git a/SSMP/Networking/Matchmaking/MmsClient.cs b/SSMP/Networking/Matchmaking/MmsClient.cs
index 9b11641..15ece9b 100644
--- a/SSMP/Networking/Matchmaking/MmsClient.cs
+++ b/SSMP/Networking/Matchmaking/MmsClient.cs
@@ -1,751 +1,156 @@
using System;
-using System.Buffers;
using System.Collections.Generic;
-using System.Net.Http;
-using System.Net.WebSockets;
-using System.Text;
+using System.Net;
using System.Threading;
using System.Threading.Tasks;
using SSMP.Logging;
-using System.Net.Sockets;
-using System.Net;
+using SSMP.Networking.Matchmaking.Host;
+using SSMP.Networking.Matchmaking.Join;
+using SSMP.Networking.Matchmaking.Protocol;
+using SSMP.Networking.Matchmaking.Query;
+using SSMP.Networking.Matchmaking.Utilities;
namespace SSMP.Networking.Matchmaking;
-///
-/// High-performance client for the MatchMaking Service (MMS) API.
-/// Handles lobby creation, lookup, heartbeat, and NAT hole-punching coordination.
-///
-internal class MmsClient {
- ///
- /// Base URL of the MMS server (e.g., "http://localhost:5000")
- ///
- private readonly string _baseUrl;
-
- ///
- /// MMS UDP discovery host derived from the configured base URL.
- ///
- private readonly string? _discoveryHost;
-
- ///
- /// Authentication token for host operations (heartbeat, close, pending clients).
- /// Set when a lobby is created, cleared when closed.
- ///
- private string? _hostToken;
-
- ///
- /// The currently active lobby ID, if this client is hosting a lobby.
- ///
- private string? CurrentLobbyId { get; set; }
-
- ///
- /// Timer that sends periodic heartbeats to keep the lobby alive on the MMS.
- /// Fires every 30 seconds while a lobby is active.
- ///
- private Timer? _heartbeatTimer;
-
- ///
- /// Interval between heartbeat requests (30 seconds).
- /// Keeps the lobby registered and prevents timeout on the MMS.
- ///
- private const int HeartbeatIntervalMs = 30000;
-
- ///
- /// HTTP request timeout in milliseconds (5 seconds).
- /// Prevents hanging on unresponsive server.
- ///
- private const int HttpTimeoutMs = 5000;
-
- ///
- /// WebSocket connection for receiving push notifications from MMS.
- ///
- private ClientWebSocket? _hostWebSocket;
-
- ///
- /// Cancellation token source for WebSocket connection.
- ///
- private CancellationTokenSource? _webSocketCts;
-
- ///
- /// Reusable empty JSON object bytes for heartbeat requests.
- /// Eliminates allocations since heartbeats send no data.
- ///
- private static readonly byte[] EmptyJsonBytes = "{}"u8.ToArray();
-
- ///
- /// Shared character array pool for zero-allocation JSON string building.
- /// Reuses buffers across all JSON formatting operations.
- ///
- private static readonly ArrayPool CharPool = ArrayPool.Shared;
-
- ///
- /// Shared HttpClient instance for connection pooling and reuse across all MmsClient instances.
- /// This provides 3-5x performance improvement over creating new connections per request.
- /// Configured for optimal performance with disabled cookies, proxies, and redirects.
- ///
- private static readonly HttpClient HttpClient = CreateHttpClient();
-
- /// The port used for UDP discovery broadcasts.
- private const int DiscoveryPort = 5001;
+/// Public entry point for MMS coordination. Delegates to specialized services.
+internal sealed class MmsClient {
+ private readonly MmsHostSessionService _hostSession;
+ private readonly MmsLobbyQueryService _queries;
+ private readonly MmsJoinCoordinator _joinCoordinator;
- /// An empty JSON object body used for POST requests that require no payload.
- private const string EmptyJsonBody = "{}";
+ /// Last error from most recent operation.
+ public MatchmakingError LastMatchmakingError { get; private set; } = MatchmakingError.None;
- ///
- /// Creates and configures the shared HttpClient with optimal performance settings.
- ///
- /// Configured HttpClient instance for MMS API calls
- private static HttpClient CreateHttpClient() {
- // Configure handler for maximum performance
- var handler = new HttpClientHandler {
- // Skip proxy detection for faster connections
- UseProxy = false,
- // MMS doesn't use cookies
- UseCookies = false,
- // MMS doesn't redirect
- AllowAutoRedirect = false
- };
-
- // Configure ServicePointManager for connection pooling (works in Unity Mono)
- ServicePointManager.DefaultConnectionLimit = 10;
- // Disable Nagle for lower latency
- ServicePointManager.UseNagleAlgorithm = false;
- // Skip 100-Continue handshake
- ServicePointManager.Expect100Continue = false;
-
- return new HttpClient(handler) {
- Timeout = TimeSpan.FromMilliseconds(HttpTimeoutMs)
- };
+ ///
+ public event Action? RefreshHostMappingRequested {
+ add => _hostSession.RefreshHostMappingRequested += value;
+ remove => _hostSession.RefreshHostMappingRequested -= value;
}
- ///
- /// Static constructor to hook process exit and dispose the shared HttpClient.
- /// Ensures that OS-level resources are released when the host process shuts down.
- ///
- static MmsClient() {
- AppDomain.CurrentDomain.ProcessExit += (_, _) => { HttpClient.Dispose(); };
+ ///
+ public event Action? HostMappingReceived {
+ add => _hostSession.HostMappingReceived += value;
+ remove => _hostSession.HostMappingReceived -= value;
}
- ///
- /// Initializes a new instance of the MmsClient.
- ///
- /// Base URL of the MMS server (default: "http://localhost:5000")
- public MmsClient(string baseUrl = "http://localhost:5000") {
- _baseUrl = baseUrl.TrimEnd('/');
+ ///
+ public event Action? StartPunchRequested {
+ add => _hostSession.StartPunchRequested += value;
+ remove => _hostSession.StartPunchRequested -= value;
+ }
- if (Uri.TryCreate(_baseUrl, UriKind.Absolute, out var baseUri)) {
- _discoveryHost = baseUri.Host;
+ public MmsClient(
+ string baseUrl,
+ MmsHostSessionService? hostSession = null,
+ MmsLobbyQueryService? queries = null,
+ MmsJoinCoordinator? joinCoordinator = null
+ ) {
+ var normalizedBaseUrl = baseUrl.TrimEnd('/');
+ if (!Uri.TryCreate(normalizedBaseUrl, UriKind.Absolute, out var uri)) {
+ throw new ArgumentException($"Invalid base URL: {baseUrl}. NAT discovery will fail.", nameof(baseUrl));
}
+
+ var discoveryHost = uri.Host;
+
+ _hostSession = hostSession ??
+ new MmsHostSessionService(
+ normalizedBaseUrl,
+ discoveryHost,
+ new MmsWebSocketHandler(MmsUtilities.ToWebSocketUrl(normalizedBaseUrl))
+ );
+ _queries = queries ?? new MmsLobbyQueryService(normalizedBaseUrl);
+ _joinCoordinator = joinCoordinator ?? new MmsJoinCoordinator(normalizedBaseUrl, discoveryHost);
}
+ /// Updates connected players; triggers heartbeat if count changes.
+ public void SetConnectedPlayers(int count) => _hostSession.SetConnectedPlayers(count);
- ///
- /// Creates a new lobby asynchronously with configuration options.
- /// Non-blocking - runs STUN discovery and HTTP request on background thread.
- ///
- /// Local port the host is listening on.
- /// Whether to list in public browser.
- /// Game version for compatibility.
- /// Type of lobby.
- /// Task containing the lobby code, lobby name, and host discovery token if successful.
+ /// Creates lobby and starts host services.
+ /// Lobby code, lobby name, and host discovery token; all null on failure.
public async Task<(string? lobbyCode, string? lobbyName, string? hostDiscoveryToken)> CreateLobbyAsync(
int hostPort,
bool isPublic = true,
string gameVersion = "unknown",
PublicLobbyType lobbyType = PublicLobbyType.Matchmaking
) {
- try {
- var buffer = CharPool.Rent(512);
- try {
- var localIp = GetLocalIpAddress();
- var length = FormatCreateLobbyJsonPortOnly(
- buffer, hostPort, isPublic, gameVersion, lobbyType, localIp
- );
- Logger.Info($"MmsClient: Creating lobby on port {hostPort}, Local IP: {localIp}");
-
- var json = new string(buffer, 0, length);
- var response = await PostJsonAsync($"{_baseUrl}/lobby", json);
- if (response == null) return (null, null, null);
-
- var lobbyId = ExtractJsonValueSpan(response.AsSpan(), "connectionData");
- var hostToken = ExtractJsonValueSpan(response.AsSpan(), "hostToken");
- var lobbyName = ExtractJsonValueSpan(response.AsSpan(), "lobbyName");
- var lobbyCode = ExtractJsonValueSpan(response.AsSpan(), "lobbyCode");
- var hostDiscoveryToken = ExtractJsonValueSpan(response.AsSpan(), "hostDiscoveryToken");
-
- if (lobbyId == null || hostToken == null || lobbyName == null || lobbyCode == null) {
- Logger.Error($"MmsClient: Invalid response from CreateLobby: {response}");
- return (null, null, null);
- }
-
- _hostToken = hostToken;
- CurrentLobbyId = lobbyId;
- StartHeartbeat();
-
- Logger.Info($"MmsClient: Created lobby {lobbyCode}, token {hostDiscoveryToken}");
- return (lobbyCode, lobbyName, hostDiscoveryToken);
- } finally {
- CharPool.Return(buffer);
- }
- } catch (Exception ex) {
- Logger.Error($"MmsClient: Failed to create lobby: {ex.Message}");
- return (null, null, null);
- }
+ ClearErrors();
+ var result = await _hostSession.CreateLobbyAsync(hostPort, isPublic, gameVersion, lobbyType);
+ LastMatchmakingError = result.error;
+ return result.result;
}
- ///
- /// Registers a Steam lobby with MMS for discovery.
- /// Called after creating a Steam lobby via SteamMatchmaking.CreateLobby().
- ///
- /// The Steam lobby ID (CSteamID as string)
- /// Whether to list in public browser
- /// Game version for compatibility
- /// Task containing the MMS lobby ID if successful, null on failure
+ /// Registers existing Steam lobby for discovery.
+ /// MMS lobby code, or null on failure.
public async Task RegisterSteamLobbyAsync(
string steamLobbyId,
bool isPublic = true,
string gameVersion = "unknown"
) {
- try {
- var json =
- $"{{\"ConnectionData\":\"{steamLobbyId}\",\"IsPublic\":{(isPublic ? "true" : "false")},\"GameVersion\":\"{gameVersion}\",\"LobbyType\":\"steam\"}}";
-
- var response = await PostJsonAsync($"{_baseUrl}/lobby", json);
- if (response == null) return null;
-
- var lobbyId = ExtractJsonValueSpan(response.AsSpan(), "connectionData");
- var hostToken = ExtractJsonValueSpan(response.AsSpan(), "hostToken");
- var lobbyName = ExtractJsonValueSpan(response.AsSpan(), "lobbyName");
- var lobbyCode = ExtractJsonValueSpan(response.AsSpan(), "lobbyCode");
-
- if (lobbyId == null || hostToken == null || lobbyName == null || lobbyCode == null) {
- Logger.Error($"MmsClient: Invalid response from RegisterSteamLobby: {response}");
- return null;
- }
-
- _hostToken = hostToken;
- CurrentLobbyId = lobbyId;
-
- StartHeartbeat();
- Logger.Info($"MmsClient: Registered Steam lobby {steamLobbyId} as MMS lobby {lobbyCode}");
- return lobbyCode;
- } catch (TaskCanceledException) {
- Logger.Warn("MmsClient: Steam lobby registration was canceled");
- return null;
- } catch (Exception ex) {
- Logger.Warn($"MmsClient: Failed to register Steam lobby: {ex.Message}");
- return null;
- }
- }
-
- ///
- /// Gets the list of public lobbies asynchronously.
- /// Non-blocking - runs HTTP request on background thread.
- ///
- /// Optional: filter by Steam or Matchmaking.
- /// Task containing list of public lobby info, or null on failure.
- public async Task?> GetPublicLobbiesAsync(PublicLobbyType? lobbyType = null) {
- try {
- var url = $"{_baseUrl}/lobbies";
- if (lobbyType != null) {
- url += $"?type={lobbyType.ToString().ToLower()}";
- }
-
- var response = await GetJsonAsync(url);
- if (response == null) return null;
-
- var result = new List();
- var span = response.AsSpan();
- var idx = 0;
-
- while (idx < span.Length) {
- var connStart = span[idx..].IndexOf("\"connectionData\":");
- if (connStart == -1) break;
-
- connStart += idx;
- var connectionData = ExtractJsonValueSpan(span[connStart..], "connectionData");
- var name = ExtractJsonValueSpan(span[connStart..], "name");
- var typeString = ExtractJsonValueSpan(span[connStart..], "lobbyType");
- var code = ExtractJsonValueSpan(span[connStart..], "lobbyCode");
-
- PublicLobbyType? type = null;
- if (typeString != null) {
- Enum.TryParse(typeString, true, out PublicLobbyType parsedType);
- type = parsedType;
- }
-
- if (connectionData != null && name != null) {
- result.Add(
- new PublicLobbyInfo(
- connectionData, name, type ?? PublicLobbyType.Matchmaking, code ?? ""
- )
- );
- }
-
- idx = connStart + 1;
- }
-
- return result;
- } catch (Exception ex) {
- Logger.Error($"MmsClient: Failed to get public lobbies: {ex.Message}");
- return null;
- }
+ ClearErrors();
+ var result = await _hostSession.RegisterSteamLobbyAsync(steamLobbyId, isPublic, gameVersion);
+ LastMatchmakingError = result.error;
+ return result.lobbyCode;
}
+ /// Closes active lobby and deregisters from MMS.
+ public void CloseLobby() => _hostSession.CloseLobby();
- ///
- /// Closes the currently hosted lobby and unregisters it from the MMS.
- /// Stops heartbeat and WebSocket connection.
- ///
- public void CloseLobby() {
- if (_hostToken == null) return;
-
- // Stop all connections before closing
- StopHeartbeat();
- StopWebSocket();
-
- try {
- // Send DELETE request to remove lobby from MMS (run on background thread)
- Task.Run(async () => await DeleteRequestAsync($"{_baseUrl}/lobby/{_hostToken}")).Wait(HttpTimeoutMs);
- Logger.Info($"MmsClient: Closed lobby {CurrentLobbyId}");
- } catch (Exception ex) {
- Logger.Warn($"MmsClient: Failed to close lobby: {ex.Message}");
- }
-
- // Clear state
- _hostToken = null;
- CurrentLobbyId = null;
+ /// Retrieves join details from MMS.
+ public async Task JoinLobbyAsync(string lobbyId, int clientPort) {
+ ClearErrors();
+ var result = await _queries.JoinLobbyAsync(lobbyId, clientPort);
+ LastMatchmakingError = result.error;
+ return result.result;
}
- ///
- /// Joins a lobby, performs NAT hole-punching, and returns host connection details.
- ///
- /// The ID of the lobby to join
- /// The local port the client is listening on
- /// Host connection details (connectionData, lobbyType, and optionally lanConnectionData) or null on
- /// failure
- public async Task<(string connectionData, PublicLobbyType lobbyType, string? lanConnectionData, string?
- clientDiscoveryToken)?> JoinLobbyAsync(
- string lobbyId,
- int clientPort
+ /// Drives UDP discovery and WebSocket hole-punch signal wait.
+ public async Task CoordinateMatchmakingJoinAsync(
+ string joinId,
+ Action sendRawAction,
+ CancellationToken cancellationToken = default
) {
- try {
- var jsonRequest = $"{{\"ClientIp\":null,\"ClientPort\":{clientPort}}}";
- var response = await PostJsonAsync($"{_baseUrl}/lobby/{lobbyId}/join", jsonRequest);
-
- if (response == null) return null;
-
- var buffer = CharPool.Rent(response.Length);
- try {
- response.CopyTo(0, buffer, 0, response.Length);
- var span = buffer.AsSpan(0, response.Length);
-
- var connectionData = ExtractJsonValueSpan(span, "connectionData");
- var lobbyTypeString = ExtractJsonValueSpan(span, "lobbyType");
- var lanConnectionData = ExtractJsonValueSpan(span, "lanConnectionData");
- var clientDiscoveryToken = ExtractJsonValueSpan(span, "clientDiscoveryToken");
-
- if (connectionData == null || lobbyTypeString == null) {
- Logger.Error($"MmsClient: Invalid response from JoinLobby: {response}");
- return null;
- }
-
- if (!Enum.TryParse(lobbyTypeString, true, out PublicLobbyType lobbyType)) {
- Logger.Error($"MmsClient: Invalid lobby type from JoinLobby: {lobbyTypeString}");
- return null;
- }
-
- Logger.Info(
- $"MmsClient: Joined lobby {lobbyId}, type: {lobbyType}, connection: {connectionData}, lan: {lanConnectionData}"
- );
- return (connectionData, lobbyType, lanConnectionData, clientDiscoveryToken);
- } finally {
- CharPool.Return(buffer);
- }
- } catch (Exception ex) {
- Logger.Error($"MmsClient: Failed to join lobby: {ex.Message}");
- return null;
- }
- }
-
- ///
- /// Event raised when a pending client needs NAT hole-punching.
- /// Subscribers should send packets to the specified endpoint to punch through NAT.
- ///
- public static event Action? PunchClientRequested;
-
- ///
- /// Starts WebSocket connection to MMS for receiving push notifications.
- /// Should be called after creating a lobby to enable instant client notifications.
- ///
- public void StartPendingClientPolling() {
- if (_hostToken == null) {
- Logger.Error("MmsClient: Cannot start WebSocket without host token");
- return;
- }
-
- // Run WebSocket connection on background thread
- Task.Run(ConnectWebSocketAsync);
- }
-
- ///
- /// Connects to MMS WebSocket and listens for pending client notifications.
- ///
- private async Task ConnectWebSocketAsync() {
- StopWebSocket(); // Ensure no duplicate connections
-
- _webSocketCts = new CancellationTokenSource();
- _hostWebSocket = new ClientWebSocket();
-
- try {
- // Convert http:// to ws://
- var wsUrl = _baseUrl.Replace("http://", "ws://").Replace("https://", "wss://");
- var uri = new Uri($"{wsUrl}/ws/{_hostToken}");
-
- await _hostWebSocket.ConnectAsync(uri, _webSocketCts.Token);
- Logger.Info($"MmsClient: WebSocket connected to MMS");
-
- // Listen for messages
- var buffer = new byte[1024];
- while (_hostWebSocket.State == WebSocketState.Open && !_webSocketCts.Token.IsCancellationRequested) {
- var result = await _hostWebSocket.ReceiveAsync(buffer, _webSocketCts.Token);
- if (result.MessageType == WebSocketMessageType.Close) break;
-
- if (result is { MessageType: WebSocketMessageType.Text, Count: > 0 }) {
- var message = Encoding.UTF8.GetString(buffer, 0, result.Count);
- HandleWebSocketMessage(message);
- }
- }
- } catch (Exception ex) when (ex is not OperationCanceledException) {
- Logger.Error($"MmsClient: WebSocket error: {ex.Message}");
- } finally {
- _hostWebSocket?.Dispose();
- _hostWebSocket = null;
- Logger.Info("MmsClient: WebSocket disconnected");
- }
- }
-
- ///
- /// Handles incoming WebSocket message containing pending client info.
- ///
- private void HandleWebSocketMessage(string message) {
- // Parse JSON: {"clientIp":"x.x.x.x","clientPort":12345}
- var ip = ExtractJsonValueSpan(message.AsSpan(), "clientIp");
- var portStr = ExtractJsonValueSpan(message.AsSpan(), "clientPort");
-
- if (ip != null && int.TryParse(portStr, out var port)) {
- Logger.Info($"MmsClient: WebSocket received pending client {ip}:{port}");
- PunchClientRequested?.Invoke(ip, port);
- }
- }
-
- ///
- /// Stops WebSocket connection.
- ///
- private void StopWebSocket() {
- _webSocketCts?.Cancel();
- _webSocketCts?.Dispose();
- _webSocketCts = null;
- _hostWebSocket?.Dispose();
- _hostWebSocket = null;
- }
-
- ///
- /// Starts the heartbeat timer to keep the lobby alive on the MMS.
- /// Lobbies without heartbeats expire after a timeout period.
- ///
- private void StartHeartbeat() {
- StopHeartbeat(); // Ensure no duplicate timers
- _heartbeatTimer = new Timer(SendHeartbeat, null, HeartbeatIntervalMs, HeartbeatIntervalMs);
- }
-
- ///
- /// Stops the heartbeat timer.
- /// Called when lobby is closed.
- ///
- private void StopHeartbeat() {
- _heartbeatTimer?.Dispose();
- _heartbeatTimer = null;
- }
-
- ///
- /// Timer callback that sends a heartbeat to the MMS.
- /// Uses empty JSON body and reusable byte array to minimize allocations.
- ///
- /// Unused timer state parameter
- private void SendHeartbeat(object? state) {
- if (_hostToken == null) return;
-
- try {
- // Send empty JSON body - just need to hit the endpoint (run on background thread)
- Task.Run(async () => await PostJsonBytesAsync($"{_baseUrl}/lobby/heartbeat/{_hostToken}", EmptyJsonBytes))
- .Wait(HttpTimeoutMs);
- } catch (Exception ex) {
- Logger.Warn($"MmsClient: Heartbeat failed: {ex.Message}");
- }
- }
-
- #region HTTP Helpers (Async with HttpClient)
-
- ///
- /// Performs an HTTP GET request and returns the response body as a string.
- /// Uses ResponseHeadersRead for efficient streaming.
- ///
- /// The URL to GET
- /// Response body as string, or null if request failed
- private static async Task GetJsonAsync(string url) {
- try {
- // ResponseHeadersRead allows streaming without buffering entire response
- var response = await HttpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead);
- if (!response.IsSuccessStatusCode) return null;
-
- return await response.Content.ReadAsStringAsync();
- } catch (HttpRequestException) {
- // Network error or invalid response
- return null;
- } catch (TaskCanceledException) {
- // Timeout exceeded
- return null;
- }
- }
-
- ///
- /// Performs an HTTP POST request with JSON content.
- ///
- /// The URL to POST to
- /// JSON string to send as request body
- /// Response body as string
- private static async Task PostJsonAsync(string url, string json) {
- // StringContent handles UTF-8 encoding and sets Content-Type header
- using var content = new StringContent(json, Encoding.UTF8, "application/json");
- using var response = await HttpClient.PostAsync(url, content);
- return await response.Content.ReadAsStringAsync();
+ ClearErrors();
+ return await _joinCoordinator.CoordinateAsync(joinId, sendRawAction, SetJoinFailed, cancellationToken);
}
- ///
- /// Performs an HTTP POST request with pre-encoded JSON bytes.
- /// More efficient than string-based version for reusable content like heartbeats.
- ///
- /// The URL to POST to
- /// JSON bytes to send as request body
- /// Response body as string
- private static async Task PostJsonBytesAsync(string url, byte[] jsonBytes) {
- using var content = new ByteArrayContent(jsonBytes);
- content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json");
- using var response = await HttpClient.PostAsync(url, content);
- return await response.Content.ReadAsStringAsync();
+ /// Fetches public lobbies from MMS.
+ public async Task?> GetPublicLobbiesAsync(PublicLobbyType? lobbyType = null) {
+ ClearErrors();
+ var result = await _queries.GetPublicLobbiesAsync(lobbyType);
+ LastMatchmakingError = result.error;
+ return result.lobbies;
}
- ///
- /// Performs an HTTP DELETE request.
- /// Used to close lobbies on the MMS.
- ///
- /// The URL to DELETE
- private static async Task DeleteRequestAsync(string url) {
- await HttpClient.DeleteAsync(url);
+ /// Checks server reaching and version compatibility.
+ public async Task ProbeMatchmakingCompatibilityAsync() {
+ ClearErrors();
+ var (isCompatible, error) = await _queries.ProbeMatchmakingCompatibilityAsync();
+ LastMatchmakingError = error;
+ return isCompatible;
}
- #endregion
-
- #region Zero-Allocation JSON Helpers
+ /// Starts host push event listener. Call after creating lobby.
+ public void StartWebSocketConnection() => _hostSession.StartWebSocketConnection();
- ///
- /// Formats JSON for CreateLobby request with port only.
- /// MMS will use the HTTP connection's source IP as the host address.
- ///
- private static int FormatCreateLobbyJsonPortOnly(
- Span buffer,
- int port,
- bool isPublic,
- string gameVersion,
- PublicLobbyType lobbyType,
- string? hostLanIp
- ) {
- var lanIpPart = hostLanIp != null ? $",\"HostLanIp\":\"{hostLanIp}:{port}\"" : "";
- var json =
- $"{{\"HostPort\":{port},\"IsPublic\":{(isPublic ? "true" : "false")},\"GameVersion\":\"{gameVersion}\",\"LobbyType\":\"{lobbyType.ToString().ToLower()}\"{lanIpPart}}}";
- json.AsSpan().CopyTo(buffer);
- return json.Length;
- }
-
- ///
- /// Extracts a JSON value by key from a JSON string using zero allocations.
- /// Supports both string values (quoted) and numeric values (unquoted).
- ///
- /// JSON string to search
- /// Key to find (without quotes)
- /// The value as a string, or null if not found
- ///
- /// This is a simple parser suitable for MMS responses. It assumes well-formed JSON.
- /// Searches for "key": pattern and extracts the following value.
- ///
- private static string? ExtractJsonValueSpan(ReadOnlySpan json, string key) {
- // Build search pattern: "key":
- Span searchKey = stackalloc char[key.Length + 3];
- searchKey[0] = '"';
- key.AsSpan().CopyTo(searchKey[1..]);
- searchKey[key.Length + 1] = '"';
- searchKey[key.Length + 2] = ':';
+ /// Triggers background UDP discovery refresh for given token.
+ public void StartHostDiscoveryRefresh(string hostDiscoveryToken, Action sendRawAction) =>
+ _hostSession.StartHostDiscoveryRefresh(hostDiscoveryToken, sendRawAction);
- // Find the key in JSON
- var idx = json.IndexOf(searchKey, StringComparison.Ordinal);
- if (idx == -1) return null;
-
- var valueStart = idx + searchKey.Length;
-
- // Skip any whitespace after the colon
- while (valueStart < json.Length && char.IsWhiteSpace(json[valueStart]))
- valueStart++;
-
- if (valueStart >= json.Length) return null;
-
- // Determine if value is quoted (string) or unquoted (number)
- if (json[valueStart] == '"') {
- // String value - find closing quote
- var valueEnd = json[(valueStart + 1)..].IndexOf('"');
- return valueEnd == -1 ? null : json.Slice(valueStart + 1, valueEnd).ToString();
- } else {
- // Numeric value - read until non-digit character
- var valueEnd = valueStart;
- while (valueEnd < json.Length &&
- (char.IsDigit(json[valueEnd]) || json[valueEnd] == '.' || json[valueEnd] == '-'))
- valueEnd++;
- return json.Slice(valueStart, valueEnd - valueStart).ToString();
- }
- }
-
- #endregion
-
- ///
- /// Gets the local IP address of the machine.
- /// Uses a UDP socket to determine the routing to the internet to pick the correct interface.
- /// Will not actually establish a connection, so used IP and port (8.8.8.8:65530) are irrelevant.
- ///
- private static string? GetLocalIpAddress() {
- try {
- using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, 0);
- socket.Connect("8.8.8.8", 65530);
- return (socket.LocalEndPoint as IPEndPoint)?.Address.ToString();
- } catch {
- return null;
- }
- }
+ /// Stops active host discovery refresh loop.
+ public void StopHostDiscoveryRefresh() => _hostSession.StopHostDiscoveryRefresh();
///
- /// Performs UDP port discovery by sending packets and polling the TCP verification endpoint.
- /// Returns the discovered external port, or null if discovery times out.
+ /// Signals a join failure with a specific reason.
///
- /// Discovery token issued by MMS for this host or client session.
- /// Callback that sends the raw token bytes through the caller's pre-bound UDP socket.
- /// The discovered external port, or null if discovery fails or times out.
- public async Task PerformDiscoveryAsync(
- string token,
- Action sendRawAction
- ) {
- if (_discoveryHost is null) {
- Logger.Error("MmsClient: MMS URL must use a direct IP address for UDP discovery");
- return null;
- }
-
- using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
-
- var tokenBytes = Encoding.UTF8.GetBytes(token);
- var encodedToken = Uri.EscapeDataString(token);
-
- // Resolve the host address for the MMS discovery service
- var hostAddresses = await Dns.GetHostAddressesAsync(_discoveryHost);
- if (hostAddresses == null || hostAddresses.Length == 0) {
- Logger.Error($"MmsClient: Could not resolve discovery host ({_discoveryHost}) to IP address");
- return null;
- }
-
- var ipEndpoint = new IPEndPoint(hostAddresses[0], DiscoveryPort);
-
- // Send UDP packets in background until discovery succeeds or times out
- var udpTask = SendDiscoveryPacketsAsync(tokenBytes, ipEndpoint, sendRawAction, cts.Token);
-
- try {
- while (!cts.Token.IsCancellationRequested) {
- var response = await PostJsonAsync(
- $"{_baseUrl}/lobby/discovery/verify/{encodedToken}",
- EmptyJsonBody
- );
-
- if (response is not null) {
- var portStr = ExtractJsonValueSpan(response.AsSpan(), "externalPort");
-
- if (int.TryParse(portStr, out var port)) {
- cts.Cancel();
- return port;
- }
- }
-
- await Task.Delay(1000, cts.Token);
- }
- } catch (OperationCanceledException) {
- // Either timed out (cts expired) or canceled after successful discovery above
- } finally {
- // Ensure the UDP loop has exited before returning
- await udpTask;
- }
-
- return null;
+ private void SetJoinFailed(string reason) {
+ Logger.Warn($"MmsClient: matchmaking join failed – {reason}");
+ LastMatchmakingError = MatchmakingError.JoinFailed;
}
///
- /// Sends discovery packets asynchronously in a loop until canceled.
+ /// Clears the internal and HTTP error states.
///
- /// The bytes of the discovery token to send.
- /// The target IP endpoint (MMS discovery port).
- /// Action to send raw UDP data.
- /// Token to cancel the sending loop.
- /// A task representing the asynchronous operation.
- private static async Task SendDiscoveryPacketsAsync(
- byte[] tokenBytes,
- IPEndPoint endpoint,
- Action sendRawAction,
- CancellationToken cancellationToken
- ) {
- while (!cancellationToken.IsCancellationRequested) {
- try {
- sendRawAction(tokenBytes, endpoint);
- } catch (SocketException ex) {
- Logger.Debug($"Transient socket error during UDP discovery send - Exception: {ex}");
- } catch (Exception ex) {
- Logger.Warn($"Unexpected error during UDP discovery send; aborting loop - Exception: {ex}");
- return;
- }
-
- try {
- await Task.Delay(500, cancellationToken);
- } catch (OperationCanceledException) {
- break;
- }
- }
+ private void ClearErrors() {
+ LastMatchmakingError = MatchmakingError.None;
}
}
-
-///
-/// Public lobby information for the lobby browser.
-///
-public record PublicLobbyInfo(
- // IP:Port for Matchmaking, Steam lobby ID for Steam
- string ConnectionData,
- string Name,
- PublicLobbyType LobbyType,
- string LobbyCode
-);
-
-///
-/// Enum for public lobby types.
-///
-public enum PublicLobbyType {
- ///
- /// Standalone matchmaking through MMS.
- ///
- Matchmaking,
- ///
- /// Steam matchmaking through MMS.
- ///
- Steam
-}
diff --git a/SSMP/Networking/Matchmaking/Parsing/MmsJsonParser.cs b/SSMP/Networking/Matchmaking/Parsing/MmsJsonParser.cs
new file mode 100644
index 0000000..031ed34
--- /dev/null
+++ b/SSMP/Networking/Matchmaking/Parsing/MmsJsonParser.cs
@@ -0,0 +1,166 @@
+using System;
+using System.Buffers;
+using System.Globalization;
+using System.Linq;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
+using SSMP.Networking.Matchmaking.Protocol;
+
+namespace SSMP.Networking.Matchmaking.Parsing;
+
+/// Reads and writes the small JSON payloads used by MMS.
+internal static class MmsJsonParser {
+ ///
+ /// A scoped rental of a pooled char buffer. Disposing returns the buffer to the shared
+ /// pool, making double-return safe and ensuring the caller cannot forget to release.
+ ///
+ internal sealed class CharLease : IDisposable {
+ private static readonly ArrayPool Pool = ArrayPool.Shared;
+ private char[]? _buffer;
+
+ /// The number of valid characters in .
+ private int Length { get; }
+
+ /// The serialized JSON content, valid only while this lease is undisposed.
+ public ReadOnlySpan Span => _buffer != null
+ ? _buffer.AsSpan(0, Length)
+ : ReadOnlySpan.Empty;
+
+ internal CharLease(char[] buffer, int length) {
+ _buffer = buffer;
+ Length = length;
+ }
+
+ /// Returns the rented buffer to the pool. Safe to call more than once.
+ public void Dispose() {
+ var buf = _buffer;
+ if (buf == null) {
+ return;
+ }
+
+ _buffer = null;
+ Pool.Return(buf);
+ }
+ }
+
+ ///
+ /// Parses a JSON string and returns the first property with the requested key.
+ /// Returns null when the payload is invalid or the key is missing.
+ ///
+ private static string? ExtractValue(string json, string key) {
+ try {
+ var token = JToken.Parse(json);
+ var property = FindPropertyRecursive(token, key);
+ return property == null ? null : ConvertTokenToString(property.Value);
+ } catch (JsonReaderException) {
+ return null;
+ }
+ }
+
+ ///
+ /// Span-based wrapper for callers that already have a message buffer.
+ /// Converts once, then reuses the string overload.
+ ///
+ public static string? ExtractValue(ReadOnlySpan json, string key) => ExtractValue(json.ToString(), key);
+
+ ///
+ /// Serializes the create-lobby payload into a scoped .
+ /// Dispose the returned lease (e.g. with using) to return the buffer to the pool.
+ ///
+ public static CharLease FormatCreateLobbyJson(
+ int port,
+ bool isPublic,
+ string gameVersion,
+ PublicLobbyType lobbyType,
+ string? hostLanIp
+ ) {
+ var payload = new JObject {
+ [MmsFields.HostPortRequest] = port,
+ [MmsFields.IsPublicRequest] = isPublic,
+ [MmsFields.GameVersionRequest] = gameVersion,
+ [MmsFields.LobbyTypeRequest] = SerializeLobbyType(lobbyType)
+ };
+
+ if (hostLanIp != null) {
+ payload[MmsFields.HostLanIpRequest] = $"{hostLanIp}:{port}";
+ }
+
+ // Matchmaking lobbies carry a protocol version so MMS can reject stale clients.
+ if (lobbyType == PublicLobbyType.Matchmaking) {
+ payload[MmsFields.MatchmakingVersionRequest] = MmsProtocol.CurrentVersion;
+ }
+
+ var json = payload.ToString(Formatting.None);
+ var buffer = ArrayPool.Shared.Rent(json.Length);
+ json.AsSpan().CopyTo(buffer);
+ return new CharLease(buffer, json.Length);
+ }
+
+ /// Walks nested objects and arrays until it finds a matching property name.
+ private static JProperty? FindPropertyRecursive(JToken token, string key) {
+ switch (token.Type) {
+ case JTokenType.Object: {
+ foreach (var property in token.Children()) {
+ if (string.Equals(property.Name, key, StringComparison.Ordinal)) {
+ return property;
+ }
+
+ var nestedMatch = FindPropertyRecursive(property.Value, key);
+ if (nestedMatch != null) {
+ return nestedMatch;
+ }
+ }
+
+ return null;
+ }
+
+ case JTokenType.Array:
+ return token.Children()
+ .Select(child => FindPropertyRecursive(child, key))
+ .OfType()
+ .FirstOrDefault();
+
+ // Scalar and leaf token types contain no child properties to search.
+ case JTokenType.None:
+ case JTokenType.Constructor:
+ case JTokenType.Property:
+ case JTokenType.Comment:
+ case JTokenType.Integer:
+ case JTokenType.Float:
+ case JTokenType.String:
+ case JTokenType.Boolean:
+ case JTokenType.Null:
+ case JTokenType.Undefined:
+ case JTokenType.Date:
+ case JTokenType.Raw:
+ case JTokenType.Bytes:
+ case JTokenType.Guid:
+ case JTokenType.Uri:
+ case JTokenType.TimeSpan:
+ default:
+ return null;
+ }
+ }
+
+ ///
+ /// Maps the local enum to the lowercase lobby type string MMS expects.
+ /// Throws for unrecognized values so that
+ /// new enum members are caught at development time rather than silently misrouted.
+ ///
+ private static string SerializeLobbyType(PublicLobbyType lobbyType) => lobbyType switch {
+ PublicLobbyType.Matchmaking => "matchmaking",
+ PublicLobbyType.Steam => "steam",
+ _ => throw new ArgumentOutOfRangeException(nameof(lobbyType), lobbyType,
+ $"No MMS name defined for lobby type '{lobbyType}'.")
+ };
+
+ /// Converts a JSON token into the string shape expected by existing callers.
+ private static string? ConvertTokenToString(JToken token) => token.Type switch {
+ JTokenType.Null => null,
+ JTokenType.String => token.Value(),
+ JTokenType.Integer => token.Value().ToString(CultureInfo.InvariantCulture),
+ JTokenType.Float => token.Value().ToString(CultureInfo.InvariantCulture),
+ JTokenType.Boolean => token.Value() ? "true" : "false",
+ _ => token.ToString(Formatting.None)
+ };
+}
diff --git a/SSMP/Networking/Matchmaking/Parsing/MmsResponseParser.cs b/SSMP/Networking/Matchmaking/Parsing/MmsResponseParser.cs
new file mode 100644
index 0000000..dfff463
--- /dev/null
+++ b/SSMP/Networking/Matchmaking/Parsing/MmsResponseParser.cs
@@ -0,0 +1,180 @@
+using System;
+using System.Collections.Generic;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
+using SSMP.Logging;
+using SSMP.Networking.Matchmaking.Protocol;
+
+namespace SSMP.Networking.Matchmaking.Parsing;
+
+/// Builds Matchmaking models from raw MMS response payloads.
+internal static class MmsResponseParser {
+ ///
+ /// Reads the create-lobby response fields needed to start a host session.
+ /// Returns false when any required field is missing.
+ ///
+ ///
+ /// is intentionally excluded from the required-field
+ /// check: MMS omits it for non-matchmaking lobbies, so null is a valid server response and
+ /// callers must guard against it rather than treating it as a parse failure.
+ ///
+ public static bool TryParseLobbyActivation(
+ string response,
+ out string? lobbyId,
+ out string? hostToken,
+ out string? lobbyName,
+ out string? lobbyCode,
+ out string? hostDiscoveryToken
+ ) {
+ var root = ParseJsonObject(response);
+ lobbyId = root?.Value(MmsFields.ConnectionData);
+ hostToken = root?.Value(MmsFields.HostToken);
+ lobbyName = root?.Value(MmsFields.LobbyName);
+ lobbyCode = root?.Value(MmsFields.LobbyCode);
+ hostDiscoveryToken = root?.Value(MmsFields.HostDiscoveryToken);
+
+ return lobbyId != null && hostToken != null && lobbyName != null && lobbyCode != null;
+ }
+
+ ///
+ /// Parses the join response into the local result model.
+ /// Returns null when MMS omits the required connection fields.
+ ///
+ public static JoinLobbyResult? ParseJoinLobbyResult(string response) {
+ var root = ParseJsonObject(response);
+ if (root == null) {
+ return null;
+ }
+
+ var connectionData = root.Value(MmsFields.ConnectionData);
+ var lobbyTypeString = root.Value(MmsFields.LobbyType);
+
+ if (connectionData == null || lobbyTypeString == null) {
+ return null;
+ }
+
+ return new JoinLobbyResult {
+ ConnectionData = connectionData,
+ LobbyType = ParseLobbyType(lobbyTypeString),
+ LanConnectionData = root.Value(MmsFields.LanConnectionData),
+ JoinId = root.Value(MmsFields.JoinId)
+ };
+ }
+
+ ///
+ /// Parses the public lobby listing returned by MMS.
+ /// Returns an empty list when the payload is malformed.
+ ///
+ public static List ParsePublicLobbies(string response) {
+ try {
+ var lobbies = ParseLobbiesAsArray(response);
+ return ExtractValidLobbies(lobbies);
+ } catch (JsonReaderException) {
+ Logger.Debug("MmsResponseParser: Failed to parse public lobbies JSON.");
+ return [];
+ }
+ }
+
+ ///
+ /// Parses the start-punch message sent before synchronized hole punching.
+ /// Returns null when the host endpoint or timestamp is missing.
+ ///
+ private static MatchmakingJoinStartResult? ParseStartPunch(string json) {
+ var root = ParseJsonObject(json);
+ if (root == null) {
+ return null;
+ }
+
+ var hostIp = root.Value(MmsFields.HostIp);
+ var hostPort = root.Value(MmsFields.HostPort);
+ var startTime = root.Value(MmsFields.StartTimeMs);
+
+ if (hostIp == null || hostPort == null || startTime == null) {
+ return null;
+ }
+
+ return new MatchmakingJoinStartResult {
+ HostIp = hostIp,
+ HostPort = hostPort.Value,
+ StartTimeMs = startTime.Value
+ };
+ }
+
+ /// Span-based wrapper for callers that already work with message spans.
+ public static MatchmakingJoinStartResult? ParseStartPunch(ReadOnlySpan span) =>
+ ParseStartPunch(span.ToString());
+
+ /// Normalizes lobby-list payloads so callers can always iterate a JSON array.
+ private static JArray ParseLobbiesAsArray(string response) {
+ return JToken.Parse(response) switch {
+ JArray array => array,
+ JObject obj => [obj],
+ var other => LogAndReturnEmpty(other)
+ };
+
+ static JArray LogAndReturnEmpty(JToken other) {
+ Logger.Debug($"MmsResponseParser: Unexpected lobby payload token type '{other.Type}', expected array or object.");
+ return [];
+ }
+ }
+
+ /// Filters malformed lobby entries and converts the valid ones to models.
+ private static List ExtractValidLobbies(JArray lobbies) {
+ var result = new List(lobbies.Count);
+
+ foreach (var token in lobbies) {
+ if (token is not JObject lobbyObject) {
+ Logger.Debug("MmsResponseParser: Skipped non-object lobby entry.");
+ continue;
+ }
+
+ var lobby = TryParseLobbyEntry(lobbyObject);
+ if (lobby != null) {
+ result.Add(lobby);
+ } else {
+ Logger.Debug("MmsResponseParser: Skipped unparseable lobby entry.");
+ }
+ }
+
+ return result;
+ }
+
+ /// Parses one lobby entry from the public lobby list.
+ private static PublicLobbyInfo? TryParseLobbyEntry(JObject lobby) {
+ var connectionData = lobby.Value(MmsFields.ConnectionData);
+ var name = lobby.Value(MmsFields.Name);
+ var lobbyCode = lobby.Value(MmsFields.LobbyCode);
+
+ // All three fields are required: a missing lobby code would break client-side
+ // code entry and is not a valid state a well-formed MMS response can produce.
+ if (connectionData == null || name == null || lobbyCode == null) {
+ return null;
+ }
+
+ var lobbyTypeString = lobby.Value(MmsFields.LobbyType);
+ var lobbyType = lobbyTypeString != null
+ ? ParseLobbyType(lobbyTypeString)
+ : PublicLobbyType.Matchmaking;
+
+ return new PublicLobbyInfo(connectionData, name, lobbyType, lobbyCode);
+ }
+
+ /// Parses a lobby type string and defaults unknown values to Matchmaking.
+ private static PublicLobbyType ParseLobbyType(string lobbyTypeString) {
+ if (Enum.TryParse(lobbyTypeString, ignoreCase: true, out PublicLobbyType lobbyType)) {
+ return lobbyType;
+ }
+
+ Logger.Debug($"MmsResponseParser: Unknown lobby type '{lobbyTypeString}', defaulting to Matchmaking.");
+ return PublicLobbyType.Matchmaking;
+ }
+
+ /// Parses a JSON object root and returns null when the payload is invalid.
+ private static JObject? ParseJsonObject(string json) {
+ try {
+ return JObject.Parse(json);
+ } catch (JsonReaderException) {
+ return null;
+ }
+ }
+}
diff --git a/SSMP/Networking/Matchmaking/Protocol/MmsActions.cs b/SSMP/Networking/Matchmaking/Protocol/MmsActions.cs
new file mode 100644
index 0000000..119debf
--- /dev/null
+++ b/SSMP/Networking/Matchmaking/Protocol/MmsActions.cs
@@ -0,0 +1,27 @@
+namespace SSMP.Networking.Matchmaking.Protocol;
+
+///
+/// WebSocket action field values exchanged between the MMS server
+/// and both host and client connections.
+///
+internal static class MmsActions
+{
+ /// Sent by the server to a joining client to initiate its NAT mapping process.
+ public const string BeginClientMapping = "begin_client_mapping";
+
+ /// Sent by the server to a host to request it to start hole punching towards a client.
+ public const string StartPunch = "start_punch";
+
+ /// Sent by the client to the server once its NAT mapping has been successfully determined.
+ public const string ClientMappingReceived = "client_mapping_received";
+
+ /// Sent by the server to a client when a join request has failed.
+ public const string JoinFailed = "join_failed";
+
+ /// Sent by the server to a host to request a refresh of its NAT mapping.
+ public const string RefreshHostMapping = "refresh_host_mapping";
+
+ /// Sent by the host to the server once its NAT mapping has been successfully refreshed or determined.
+ public const string HostMappingReceived = "host_mapping_received";
+
+}
diff --git a/SSMP/Networking/Matchmaking/Protocol/MmsFields.cs b/SSMP/Networking/Matchmaking/Protocol/MmsFields.cs
new file mode 100644
index 0000000..eac7fff
--- /dev/null
+++ b/SSMP/Networking/Matchmaking/Protocol/MmsFields.cs
@@ -0,0 +1,97 @@
+namespace SSMP.Networking.Matchmaking.Protocol;
+
+///
+/// JSON property names used in MMS request and response bodies.
+///
+internal static class MmsFields
+{
+ /// The action being performed in a WebSocket message.
+ public const string Action = "action";
+
+ /// The error code returned in a failed response.
+ public const string ErrorCode = "errorCode";
+
+ /// A machine-readable failure reason returned in an error or control message.
+ public const string Reason = "reason";
+
+ /// The version of the protocol or application.
+ public const string Version = "version";
+
+ /// A general-purpose name field (e.g., player name).
+ public const string Name = "name";
+
+ /// The current server time in milliseconds.
+ public const string ServerTimeMs = "serverTimeMs";
+
+ /// The start time of an event in milliseconds.
+ public const string StartTimeMs = "startTimeMs";
+
+ /// Opaque connection data used for NAT traversal.
+ public const string ConnectionData = "connectionData";
+
+ /// A unique token identifying a host session.
+ public const string HostToken = "hostToken";
+
+ /// The display name of the lobby.
+ public const string LobbyName = "lobbyName";
+
+ /// A short code used to join a specific lobby.
+ public const string LobbyCode = "lobbyCode";
+
+ /// A token used by the host for discovery purposes.
+ public const string HostDiscoveryToken = "hostDiscoveryToken";
+
+ /// The type of lobby (e.g., Public, Private).
+ public const string LobbyType = "lobbyType";
+
+ /// Connection data specifically for LAN connections.
+ public const string LanConnectionData = "lanConnectionData";
+
+ /// A token used by the client for discovery purposes.
+ public const string ClientDiscoveryToken = "clientDiscoveryToken";
+
+ /// A unique identifier for a join attempt.
+ public const string JoinId = "joinId";
+
+ /// The public IP address of the host.
+ public const string HostIp = "hostIp";
+
+ /// The public port of the host.
+ public const string HostPort = "hostPort";
+
+ /// The public IP address of the client.
+ public const string ClientIp = "clientIp";
+
+ /// The public port of the client.
+ public const string ClientPort = "clientPort";
+
+ // Request-specific fields (often used in HTTP POST bodies)
+
+ /// The port the host is listening on (Request field).
+ public const string HostPortRequest = "HostPort";
+
+ /// Whether the lobby is public (Request field).
+ public const string IsPublicRequest = "IsPublic";
+
+ /// The version of the game (Request field).
+ public const string GameVersionRequest = "GameVersion";
+
+ /// The type of lobby (Request field).
+ public const string LobbyTypeRequest = "LobbyType";
+
+ /// The LAN IP address of the host (Request field).
+ public const string HostLanIpRequest = "HostLanIp";
+
+ /// Opaque connection data (Request field).
+ public const string ConnectionDataRequest = "ConnectionData";
+
+ /// The IP address of the client (Request field).
+ public const string ClientIpRequest = "ClientIp";
+
+ /// The port of the client (Request field).
+ public const string ClientPortRequest = "ClientPort";
+
+ /// The version of the matchmaking system (Request field).
+ public const string MatchmakingVersionRequest = "MatchmakingVersion";
+
+}
diff --git a/SSMP/Networking/Matchmaking/Protocol/MmsModels.cs b/SSMP/Networking/Matchmaking/Protocol/MmsModels.cs
new file mode 100644
index 0000000..5d066d2
--- /dev/null
+++ b/SSMP/Networking/Matchmaking/Protocol/MmsModels.cs
@@ -0,0 +1,92 @@
+namespace SSMP.Networking.Matchmaking.Protocol;
+
+/// MatchMaking Service (MMS) protocol constants.
+internal static class MmsProtocol {
+ /// The current version of the matchmaking protocol.
+ public const int CurrentVersion = 1;
+
+ /// Error code returned by MMS when the client version is too old.
+ public const string UpdateRequiredErrorCode = "update_required";
+
+ /// Interval for sending heartbeats to the server.
+ public const int HeartbeatIntervalMs = 30_000;
+
+ /// Timeout for HTTP requests to the MMS API.
+ public const int HttpTimeoutMs = 5_000;
+
+ /// The UDP port used for NAT discovery.
+ public const int DiscoveryPort = 6001;
+
+ /// Timeout for the client-side matchmaking WebSocket handshake.
+ public const int MatchmakingWebSocketTimeoutMs = 20_000;
+
+ /// Duration for which UDP discovery packets are sent.
+ public const int DiscoveryDurationSeconds = 15;
+
+ /// Interval between individual UDP discovery packets.
+ public const int DiscoveryIntervalMs = 500;
+}
+
+/// Matchmaking error classification.
+internal enum MatchmakingError {
+ /// No error.
+ None,
+
+ /// The client matchmaking version is outdated.
+ UpdateRequired,
+
+ /// The join operation failed (e.g. timeout or invalid ID).
+ JoinFailed,
+
+ /// A generic network error occurred.
+ NetworkFailure
+}
+
+/// Supported MMS lobby subtypes.
+public enum PublicLobbyType {
+ /// Standalone matchmaking through MMS.
+ Matchmaking,
+
+ /// Steam matchmaking through MMS.
+ Steam
+}
+
+/// Lobby join request metadata.
+internal sealed class JoinLobbyResult {
+ /// The connection string for the lobby (e.g. "IP:Port" or Steam ID).
+ public required string ConnectionData { get; init; }
+
+ /// The type of the lobby.
+ public required PublicLobbyType LobbyType { get; init; }
+
+ /// Optional LAN connection string for local play.
+ public string? LanConnectionData { get; init; }
+
+ /// Unique ID for the join session.
+ public string? JoinId { get; init; }
+}
+
+/// NAT hole-punch peer data and synchronized start time.
+internal sealed class MatchmakingJoinStartResult {
+ /// The resolved public IP of the host.
+ public required string HostIp { get; init; }
+
+ /// The resolved public port of the host.
+ public required int HostPort { get; init; }
+
+ /// The Unix timestamp (ms) when both sides should start punching.
+ public required long StartTimeMs { get; init; }
+}
+
+/// Registry entry for the lobby browser.
+/// The connection string for the lobby (e.g. "IP:Port" or Steam ID).
+/// The display name of the lobby.
+/// The type of the lobby (e.g. Matchmaking or Steam).
+/// A short alphanumeric code used to join the lobby.
+public record PublicLobbyInfo(
+ string ConnectionData,
+ string Name,
+ PublicLobbyType LobbyType,
+ string LobbyCode
+);
+
diff --git a/SSMP/Networking/Matchmaking/Protocol/MmsQueryKeys.cs b/SSMP/Networking/Matchmaking/Protocol/MmsQueryKeys.cs
new file mode 100644
index 0000000..e5270e8
--- /dev/null
+++ b/SSMP/Networking/Matchmaking/Protocol/MmsQueryKeys.cs
@@ -0,0 +1,14 @@
+namespace SSMP.Networking.Matchmaking.Protocol;
+
+///
+/// Query-string parameter keys appended to MMS request URLs.
+///
+internal static class MmsQueryKeys
+{
+ /// The type of lobby (e.g. Matchmaking or Steam).
+ public const string Type = "type";
+
+ /// The version of the matchmaking protocol being used by the client.
+ public const string MatchmakingVersion = "matchmakingVersion";
+
+}
diff --git a/SSMP/Networking/Matchmaking/Protocol/MmsRoutes.cs b/SSMP/Networking/Matchmaking/Protocol/MmsRoutes.cs
new file mode 100644
index 0000000..3fcece1
--- /dev/null
+++ b/SSMP/Networking/Matchmaking/Protocol/MmsRoutes.cs
@@ -0,0 +1,45 @@
+namespace SSMP.Networking.Matchmaking.Protocol;
+
+///
+/// MMS REST and WebSocket route segments. Use the static helper methods to
+/// build parameterised paths; use the constants directly for fixed routes.
+///
+internal static class MmsRoutes {
+ /// MMS health-check and version endpoint.
+ public const string Root = "/health";
+
+ /// Base path for all lobby operations.
+ public const string Lobby = "/lobby";
+
+ /// Public lobby listing endpoint.
+ public const string Lobbies = "/lobbies";
+
+ /// Base path for all WebSocket connections.
+ private const string WebSocketBase = "/ws";
+
+ /// Builds the URL path for a client to join a specific lobby.
+ /// The unique identifier or short code of the lobby.
+ /// The formatted join route.
+ public static string LobbyJoin(string lobbyId) => $"{Lobby}/{lobbyId}/join";
+
+ /// Builds the URL path for a host to send a heartbeat for its lobby.
+ /// The token identifying the host session.
+ /// The formatted heartbeat route.
+ public static string LobbyHeartbeat(string hostToken) => $"{Lobby}/heartbeat/{hostToken}";
+
+ /// Builds the URL path for a host to delete its lobby.
+ /// The token identifying the host session.
+ /// The formatted delete route.
+ public static string LobbyDelete(string hostToken) => $"{Lobby}/{hostToken}";
+
+ /// Builds the WebSocket path for a client to connect for matchmaking coordination.
+ /// The unique join attempt identifier.
+ /// The formatted WebSocket join route.
+ public static string JoinWebSocket(string joinId) => $"{WebSocketBase}/join/{joinId}";
+
+ /// Builds the WebSocket path for a host to connect for matchmaking coordination.
+ /// The token identifying the host session.
+ /// The formatted WebSocket host route.
+ public static string HostWebSocket(string hostToken) => $"{WebSocketBase}/{hostToken}";
+}
+
diff --git a/SSMP/Networking/Matchmaking/Query/MmsLobbyQueryService.cs b/SSMP/Networking/Matchmaking/Query/MmsLobbyQueryService.cs
new file mode 100644
index 0000000..3b5ea04
--- /dev/null
+++ b/SSMP/Networking/Matchmaking/Query/MmsLobbyQueryService.cs
@@ -0,0 +1,159 @@
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using SSMP.Logging;
+using SSMP.Networking.Matchmaking.Parsing;
+using SSMP.Networking.Matchmaking.Protocol;
+using SSMP.Networking.Matchmaking.Transport;
+using SSMP.Networking.Matchmaking.Utilities;
+
+namespace SSMP.Networking.Matchmaking.Query;
+
+/// Non-host queries: joining, browsing public lobbies, and health/version probes.
+internal sealed class MmsLobbyQueryService {
+ /// Base HTTP URL of the MMS server (e.g. https://mms.example.com).
+ private readonly string _baseUrl;
+
+ /// Initializes a new .
+ public MmsLobbyQueryService(string baseUrl) {
+ _baseUrl = baseUrl;
+ }
+
+ /// Sends join request with local UDP port for NAT mapping.
+ public async Task<(JoinLobbyResult? result, MatchmakingError error)>
+ JoinLobbyAsync(string lobbyId, int clientPort) {
+ var response = await MmsHttpClient.PostJsonAsync(
+ $"{_baseUrl}{MmsRoutes.LobbyJoin(lobbyId)}",
+ BuildJoinRequestJson(clientPort)
+ );
+ if (!response.Success || response.Body == null)
+ return (null, response.Error);
+
+ var result = ParseAndLogJoinResult(lobbyId, response.Body);
+ return result == null
+ ? (null, MatchmakingError.NetworkFailure)
+ : (result, MatchmakingError.None);
+ }
+
+ /// Retrieves public lobbies, filtered by type and protocol version.
+ public async Task<(List? lobbies, MatchmakingError error)> GetPublicLobbiesAsync(
+ PublicLobbyType? lobbyType = null
+ ) {
+ var url = BuildPublicLobbiesUrl(lobbyType);
+ var response = await MmsHttpClient.GetAsync(url);
+ if (!response.Success || response.Body == null) {
+ return (null, response.Error);
+ }
+
+ var startIdx = MmsUtilities.SkipWhitespace(response.Body.AsSpan(), 0);
+ if (startIdx >= response.Body.Length || response.Body[startIdx] != '[') {
+ Logger.Warn($"MmsLobbyQueryService: GetPublicLobbiesAsync received malformed non-array response (length={response.Body.Length}).");
+ return (null, MatchmakingError.NetworkFailure);
+ }
+
+ return (MmsResponseParser.ParsePublicLobbies(response.Body), MatchmakingError.None);
+ }
+
+ /// Probes server health and protocol compatibility.
+ public async Task<(bool? isCompatible, MatchmakingError error)> ProbeMatchmakingCompatibilityAsync() {
+ var response = await MmsHttpClient.GetAsync($"{_baseUrl}{MmsRoutes.Root}");
+ if (!response.Success || response.Body == null)
+ return (null, response.Error);
+
+ if (!TryParseServerVersion(response.Body, out var serverVersion))
+ return (null, MatchmakingError.NetworkFailure);
+
+ return CheckVersionCompatibility(serverVersion);
+ }
+
+ ///
+ /// Builds the JSON request body for a lobby join, advertising the client's
+ /// local port and protocol version. ClientIp is sent as null;
+ /// MMS infers the external IP from the incoming socket address.
+ ///
+ /// Local UDP port the client is listening on.
+ /// A JSON string ready to POST to the join endpoint.
+ private static string BuildJoinRequestJson(int clientPort) =>
+ $"{{\"{MmsFields.ClientIpRequest}\":null," +
+ $"\"{MmsFields.ClientPortRequest}\":{clientPort}," +
+ $"\"{MmsFields.MatchmakingVersionRequest}\":{MmsProtocol.CurrentVersion}}}";
+
+ ///
+ /// Parses the join response and logs the outcome. Returns the result on
+ /// success or logs an error and returns null on parse failure.
+ ///
+ /// Lobby ID, used only for log messages.
+ /// Raw JSON response body from the join endpoint.
+ /// A populated , or null if parsing failed.
+ private static JoinLobbyResult? ParseAndLogJoinResult(string lobbyId, string response) {
+ var joinResult = MmsResponseParser.ParseJoinLobbyResult(response);
+ if (joinResult == null) {
+ var hasJoinId = MmsJsonParser.ExtractValue(response.AsSpan(), MmsFields.JoinId) != null;
+ var hasConnectionData = MmsJsonParser.ExtractValue(response.AsSpan(), MmsFields.ConnectionData) != null;
+ Logger.Error(
+ $"MmsLobbyQueryService: invalid JoinLobby response (length={response.Length}, hasJoinId={hasJoinId}, hasConnectionData={hasConnectionData})."
+ );
+ return null;
+ }
+
+ Logger.Info($"MmsLobbyQueryService: joined lobby {lobbyId}, type={joinResult.LobbyType}.");
+ return joinResult;
+ }
+
+ ///
+ /// Extracts and parses the version field from an MMS health response.
+ /// Logs a warning and returns false if the field is absent or non-numeric.
+ ///
+ /// Raw JSON health response body.
+ /// Receives the parsed version number on success; 0 on failure.
+ /// true if a valid integer version was found; false otherwise.
+ private static bool TryParseServerVersion(string response, out int serverVersion) {
+ var versionString = MmsJsonParser.ExtractValue(response.AsSpan(), MmsFields.Version);
+ if (int.TryParse(versionString, out serverVersion)) return true;
+
+ Logger.Warn("MmsLobbyQueryService: MMS health response did not include a valid protocol version.");
+ return false;
+ }
+
+ ///
+ /// Compares against
+ /// and returns the appropriate
+ /// compatibility result. Logs a warning on mismatch.
+ ///
+ /// Protocol version reported by the MMS health endpoint.
+ ///
+ /// (true, None) if versions match;
+ /// (false, UpdateRequired) if they differ.
+ ///
+ private static (bool? isCompatible, MatchmakingError error) CheckVersionCompatibility(int serverVersion) {
+ if (serverVersion == MmsProtocol.CurrentVersion)
+ return (true, MatchmakingError.None);
+
+ Logger.Warn(
+ $"MmsLobbyQueryService: MMS protocol mismatch " +
+ $"(client={MmsProtocol.CurrentVersion}, server={serverVersion})."
+ );
+ return (false, MatchmakingError.UpdateRequired);
+ }
+
+ ///
+ /// Builds the public lobbies query URL, appending a type filter when
+ /// is specified and a matchmakingVersion
+ /// parameter when the type is .
+ ///
+ ///
+ /// Optional lobby type filter. null returns the unfiltered lobbies URL.
+ ///
+ /// The fully constructed URL string ready for an HTTP GET request.
+ private string BuildPublicLobbiesUrl(PublicLobbyType? lobbyType) {
+ var url = $"{_baseUrl}{MmsRoutes.Lobbies}";
+ if (lobbyType == null) return url;
+
+ var typeString = lobbyType == PublicLobbyType.Matchmaking ? "matchmaking" : "steam";
+ url += $"?{MmsQueryKeys.Type}={typeString}";
+ if (lobbyType == PublicLobbyType.Matchmaking)
+ url += $"&{MmsQueryKeys.MatchmakingVersion}={MmsProtocol.CurrentVersion}";
+
+ return url;
+ }
+}
diff --git a/SSMP/Networking/Matchmaking/Transport/MmsHttpClient.cs b/SSMP/Networking/Matchmaking/Transport/MmsHttpClient.cs
new file mode 100644
index 0000000..9122f2b
--- /dev/null
+++ b/SSMP/Networking/Matchmaking/Transport/MmsHttpClient.cs
@@ -0,0 +1,117 @@
+using System;
+using System.Net;
+using System.Net.Http;
+using System.Text;
+using System.Threading.Tasks;
+using SSMP.Networking.Matchmaking.Parsing;
+using SSMP.Networking.Matchmaking.Protocol;
+
+namespace SSMP.Networking.Matchmaking.Transport;
+
+/// HTTP transport for MMS; shared for pool reuse.
+internal static class MmsHttpClient {
+ /// Shared HTTP client instance for connection pooling.
+ private static readonly HttpClient Http = CreateHttpClient();
+
+ static MmsHttpClient() {
+ // Note: ProcessExit only fires on graceful shutdown.
+ // Hard crashes will bypass this, meaning the OS will clean up the socket.
+ AppDomain.CurrentDomain.ProcessExit += (_, _) => Http.Dispose();
+ }
+
+ ///
+ /// Performs a GET request to the specified URL.
+ ///
+ public static async Task GetAsync(string url) {
+ try {
+ using var response = await Http.GetAsync(url);
+ var body = await response.Content.ReadAsStringAsync();
+ return new MmsHttpResponse(
+ response.IsSuccessStatusCode,
+ body,
+ InspectErrorBody(response.StatusCode, body)
+ );
+ } catch (Exception ex) when (IsTransient(ex)) {
+ return new MmsHttpResponse(false, null, MatchmakingError.NetworkFailure);
+ }
+ }
+
+ ///
+ /// Performs a POST request with a JSON body to the specified URL.
+ ///
+ public static async Task PostJsonAsync(string url, string json) {
+ try {
+ using var content = new StringContent(json, Encoding.UTF8, "application/json");
+ using var response = await Http.PostAsync(url, content);
+ var body = await response.Content.ReadAsStringAsync();
+ return new MmsHttpResponse(
+ response.IsSuccessStatusCode,
+ body,
+ InspectErrorBody(response.StatusCode, body)
+ );
+ } catch (Exception ex) when (IsTransient(ex)) {
+ return new MmsHttpResponse(false, null, MatchmakingError.NetworkFailure);
+ }
+ }
+
+ /// Returns HTTP metadata and classified matchmaking errors without throwing.
+ public static async Task DeleteAsync(string url) {
+ try {
+ using var response = await Http.DeleteAsync(url);
+ var body = await response.Content.ReadAsStringAsync();
+ return new MmsHttpResponse(
+ response.IsSuccessStatusCode,
+ body,
+ InspectErrorBody(response.StatusCode, body)
+ );
+ } catch (Exception ex) when (IsTransient(ex)) {
+ return new MmsHttpResponse(false, null, MatchmakingError.NetworkFailure);
+ }
+ }
+
+ ///
+ /// Checks the response body for MMS-specific error codes.
+ ///
+ /// The HTTP status code.
+ /// The response body.
+ private static MatchmakingError InspectErrorBody(HttpStatusCode status, string? body) {
+ if ((int) status < 400 || body == null) return MatchmakingError.None;
+
+ var errorCode = MmsJsonParser.ExtractValue(body.AsSpan(), MmsFields.ErrorCode);
+ return errorCode == MmsProtocol.UpdateRequiredErrorCode
+ ? MatchmakingError.UpdateRequired
+ : MatchmakingError.NetworkFailure;
+ }
+
+ ///
+ /// Determines if an exception represents a transient network issue.
+ ///
+ /// The exception to check.
+ /// true if transient; otherwise, false.
+ private static bool IsTransient(Exception ex) =>
+ ex is HttpRequestException or TaskCanceledException;
+
+ ///
+ /// Configures and returns an optimized instance.
+ ///
+ /// A new instance.
+ private static HttpClient CreateHttpClient() {
+ var handler = new HttpClientHandler {
+ UseProxy = false,
+ UseCookies = false,
+ AllowAutoRedirect = false
+ };
+
+ var client = new HttpClient(handler) {
+ Timeout = TimeSpan.FromMilliseconds(MmsProtocol.HttpTimeoutMs)
+ };
+ client.DefaultRequestHeaders.ExpectContinue = false;
+ return client;
+ }
+}
+
+internal readonly record struct MmsHttpResponse(
+ bool Success,
+ string? Body,
+ MatchmakingError Error
+);
diff --git a/SSMP/Networking/Matchmaking/Utilities/MmsUtilities.cs b/SSMP/Networking/Matchmaking/Utilities/MmsUtilities.cs
new file mode 100644
index 0000000..c0978e5
--- /dev/null
+++ b/SSMP/Networking/Matchmaking/Utilities/MmsUtilities.cs
@@ -0,0 +1,161 @@
+using System;
+using System.Buffers;
+using System.Net;
+using System.Net.Sockets;
+using System.Net.WebSockets;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using SSMP.Logging;
+
+namespace SSMP.Networking.Matchmaking.Utilities;
+
+/// Stateless helpers for MMS protocol handling.
+internal static class MmsUtilities {
+ /// Converts http(s) to ws(s). Throws if not absolute.
+ public static string ToWebSocketUrl(string httpUrl) {
+ if (!Uri.TryCreate(httpUrl, UriKind.Absolute, out var uri))
+ throw new ArgumentException("Matchmaking URL must be an absolute URI.", nameof(httpUrl));
+
+ var scheme = uri.Scheme switch {
+ "http" => "ws",
+ "https" => "wss",
+ _ => throw new ArgumentException("Matchmaking URL must use http or https.", nameof(httpUrl))
+ };
+
+ var builder = new UriBuilder(uri) { Scheme = scheme };
+ return builder.Uri.AbsoluteUri.TrimEnd('/');
+ }
+
+ /// Returns JSON literal for boolean.
+ public static string BoolToJson(bool value) => value ? "true" : "false";
+
+ /// Logs unexpected failures in fire-and-forget task.
+ /// The task to monitor.
+ /// Component name included in failure logs.
+ /// Human-readable operation label for diagnostics.
+ public static Task RunBackground(Task task, string owner, string operationName) =>
+ ObserveAsync(task, owner, operationName);
+
+ /// Assembles text frames from WebSocket. Returns null payload for non-text/close.
+ public static async Task<(WebSocketMessageType messageType, string? message)> ReceiveTextMessageAsync(
+ ClientWebSocket socket,
+ CancellationToken cancellationToken,
+ int maxMessageBytes = 16 * 1024
+ ) {
+ const int chunkSize = 1024;
+ var buffer = ArrayPool.Shared.Rent(chunkSize);
+ var writer = new ArrayBufferWriter();
+
+ try {
+ while (true) {
+ var frame = await socket.ReceiveAsync(new ArraySegment(buffer), cancellationToken);
+ if (frame.MessageType == WebSocketMessageType.Close)
+ return (frame.MessageType, null);
+
+ AppendFrame(writer, buffer, frame.Count, maxMessageBytes);
+
+ if (!frame.EndOfMessage)
+ continue;
+
+ return frame.MessageType != WebSocketMessageType.Text
+ ? (frame.MessageType, null)
+ : (frame.MessageType,
+ writer.WrittenCount == 0 ? string.Empty : Encoding.UTF8.GetString(writer.WrittenSpan));
+ }
+ } finally {
+ ArrayPool.Shared.Return(buffer);
+ }
+ }
+
+ /// Deterministically identifies outbound IPv4 via dummy UDP connect.
+ public static string? GetLocalIpAddress() {
+ try {
+ using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, 0);
+ socket.Connect("8.8.8.8", 65530);
+ return (socket.LocalEndPoint as IPEndPoint)?.Address.ToString();
+ } catch (Exception ex) {
+ Logger.Debug($"MmsUtilities: GetLocalIpAddress failed: {ex.Message}");
+ return null;
+ }
+ }
+
+ ///
+ /// Awaits a background task and suppresses expected cancellation while logging unexpected failures.
+ ///
+ /// The task being observed.
+ /// Component name included in failure logs.
+ /// Human-readable operation label for diagnostics.
+ private static async Task ObserveAsync(Task task, string owner, string operationName) {
+ try {
+ await task.ConfigureAwait(false);
+ } catch (OperationCanceledException) {
+ /*ignored*/
+ } catch (Exception ex) {
+ Logger.Warn($"{owner}: {operationName} failed: {ex.Message}");
+ }
+ }
+
+ ///
+ /// Appends a single received frame into the accumulating message buffer while enforcing the message size cap.
+ ///
+ /// The destination buffer holding the message assembled so far.
+ /// Scratch receive buffer containing the latest frame bytes.
+ /// Number of valid bytes currently in .
+ /// Maximum total message size allowed.
+ private static void AppendFrame(ArrayBufferWriter writer, byte[] buffer, int count, int maxMessageBytes) {
+ if (count <= 0)
+ return;
+
+ var nextLength = writer.WrittenCount + count;
+ if (nextLength > maxMessageBytes)
+ throw new InvalidOperationException("Matchmaking WebSocket message exceeded the maximum size.");
+
+ buffer.AsSpan(0, count).CopyTo(writer.GetSpan(count));
+ writer.Advance(count);
+ }
+
+
+ ///
+ /// Advances an index past any whitespace characters in a JSON span.
+ ///
+ /// The JSON character span being parsed.
+ /// The position to start skipping from.
+ /// The index of the first non-whitespace character, or .Length if the rest of the span is whitespace.
+ public static int SkipWhitespace(ReadOnlySpan json, int index) {
+ while (index < json.Length && char.IsWhiteSpace(json[index]))
+ index++;
+
+ return index;
+ }
+
+ ///
+ /// Escapes a string for safe embedding in JSON, encoding special characters
+ /// and non-printable control characters as their JSON escape sequences.
+ ///
+ /// The raw string to escape.
+ /// A JSON-safe escaped string, without surrounding quotes.
+ public static string EscapeJsonString(string value) {
+ var builder = new StringBuilder(value.Length);
+ foreach (var ch in value) {
+ switch (ch) {
+ case '"': builder.Append("\\\""); break;
+ case '\\': builder.Append(@"\\"); break;
+ case '/': builder.Append("\\/"); break;
+ case '\b': builder.Append("\\b"); break;
+ case '\f': builder.Append("\\f"); break;
+ case '\n': builder.Append("\\n"); break;
+ case '\r': builder.Append("\\r"); break;
+ case '\t': builder.Append("\\t"); break;
+ default:
+ if (char.IsControl(ch))
+ builder.AppendFormat("\\u{0:X4}", (int) ch);
+ else
+ builder.Append(ch);
+ break;
+ }
+ }
+
+ return builder.ToString();
+ }
+}
diff --git a/SSMP/Networking/Transport/HolePunch/HolePunchEncryptedTransport.cs b/SSMP/Networking/Transport/HolePunch/HolePunchEncryptedTransport.cs
index a6891c4..34365f6 100644
--- a/SSMP/Networking/Transport/HolePunch/HolePunchEncryptedTransport.cs
+++ b/SSMP/Networking/Transport/HolePunch/HolePunchEncryptedTransport.cs
@@ -1,6 +1,7 @@
using System;
using System.Net;
using System.Net.Sockets;
+using System.Threading.Tasks;
using System.Threading;
using SSMP.Logging;
using SSMP.Networking.Client;
@@ -56,6 +57,11 @@ internal class HolePunchEncryptedTransport : IEncryptedTransport {
///
private const int PunchPacketDelayMs = 50;
+ ///
+ /// Small synchronous burst sent before DTLS starts so port-restricted NATs see the peer endpoint immediately.
+ ///
+ private const int InitialPunchPacketCount = 5;
+
///
/// The IP address used for self-connecting (host connecting to own server).
/// Localhost connections bypass hole-punching as no NAT traversal is needed.
@@ -235,9 +241,10 @@ public void Disconnect() {
/// Hole-punching sequence:
/// 1. Reuse pre-bound socket from STUN discovery (or create new one)
/// 2. Configure socket to ignore ICMP Port Unreachable errors
- /// 3. Send 100 "PUNCH" packets over 5 seconds to open NAT mapping
+ /// 3. Send a short priming burst to open the NAT mapping
/// 4. Connect socket to peer endpoint
- /// 5. Return socket for DTLS handshake
+ /// 5. Continue punching in the background while DTLS handshakes
+ /// 6. Return socket for DTLS handshake
///
private static Socket PerformHolePunch(string address, int port) {
// Attempt to reuse the socket passed from ConnectInterface
@@ -266,14 +273,11 @@ private static Socket PerformHolePunch(string address, int port) {
// Parse target endpoint
var endpoint = new IPEndPoint(IPAddress.Parse(address), port);
- Logger.Debug($"HolePunch: Sending {PunchPacketCount} punch packets to {endpoint}");
+ Logger.Debug($"HolePunch: Sending initial punch burst ({InitialPunchPacketCount} packets) to {endpoint}");
- // Send punch packets to create/maintain NAT mapping
- // Each packet refreshes the NAT timer and increases chance of success
- for (var i = 0; i < PunchPacketCount; i++) {
+ // Prime the NAT mapping immediately before DTLS begins.
+ for (var i = 0; i < InitialPunchPacketCount; i++) {
socket.SendTo(PunchPacket, endpoint);
-
- // Wait between packets to spread them over time
Thread.Sleep(PunchPacketDelayMs);
}
@@ -281,6 +285,7 @@ private static Socket PerformHolePunch(string address, int port) {
// This is important for DTLS which expects point-to-point communication
socket.Connect(endpoint);
+ StartBackgroundPunchBurst(socket, endpoint);
Logger.Info($"HolePunch: NAT traversal complete, socket connected to {endpoint}");
return socket;
} catch (Exception ex) {
@@ -290,6 +295,31 @@ private static Socket PerformHolePunch(string address, int port) {
}
}
+ ///
+ /// Continues sending punch packets while DTLS handshakes so stricter NATs keep the mapping alive.
+ ///
+ private static void StartBackgroundPunchBurst(Socket socket, IPEndPoint endpoint) {
+ _ = Task.Run(() => {
+ try {
+ Logger.Debug(
+ $"HolePunch: Continuing background punch burst ({PunchPacketCount - InitialPunchPacketCount} packets) to {endpoint}"
+ );
+ for (var i = InitialPunchPacketCount; i < PunchPacketCount; i++) {
+ socket.Send(PunchPacket);
+ Thread.Sleep(PunchPacketDelayMs);
+ }
+
+ Logger.Debug($"HolePunch: Background punch burst complete to {endpoint}");
+ } catch (ObjectDisposedException) {
+ // Socket closed during disconnect or failed handshake.
+ } catch (SocketException ex) {
+ Logger.Debug($"HolePunch: Background punch burst stopped for {endpoint}: {ex.Message}");
+ } catch (Exception ex) {
+ Logger.Warn($"HolePunch: Background punch burst failed for {endpoint}: {ex.Message}");
+ }
+ });
+ }
+
///
/// Handles data received from the DTLS client.
/// Forwards decrypted data to subscribers of .
diff --git a/SSMP/Networking/Transport/HolePunch/HolePunchEncryptedTransportServer.cs b/SSMP/Networking/Transport/HolePunch/HolePunchEncryptedTransportServer.cs
index 62dd9d7..ea128fc 100644
--- a/SSMP/Networking/Transport/HolePunch/HolePunchEncryptedTransportServer.cs
+++ b/SSMP/Networking/Transport/HolePunch/HolePunchEncryptedTransportServer.cs
@@ -2,7 +2,6 @@
using System.Collections.Concurrent;
using System.Net;
using System.Net.Sockets;
-using System.Threading;
using System.Threading.Tasks;
using SSMP.Logging;
using SSMP.Networking.Matchmaking;
@@ -67,27 +66,33 @@ public HolePunchEncryptedTransportServer(MmsClient? mmsClient = null) {
///
public void Start(int port) {
Logger.Info($"HolePunch Server: Starting on port {port}");
-
- // Subscribe to punch coordination
- MmsClient.PunchClientRequested += OnPunchClientRequested;
+
+ if (_mmsClient != null) {
+ _mmsClient.RefreshHostMappingRequested += OnHostMappingRefreshRequested;
+ _mmsClient.HostMappingReceived += OnHostMappingReceived;
+ _mmsClient.StartPunchRequested += OnStartPunchRequested;
+ }
var socket = PreBoundSocket;
PreBoundSocket = null;
_dtlsServer.Start(port, socket);
+
+ _mmsClient?.StartWebSocketConnection();
}
///
public void Stop() {
Logger.Info("HolePunch Server: Stopping");
-
- // Close MMS lobby if we have an MMS client
- _mmsClient?.CloseLobby();
- _mmsClient = null;
-
- // Unsubscribe from punch coordination
- MmsClient.PunchClientRequested -= OnPunchClientRequested;
-
+
+ if (_mmsClient != null) {
+ _mmsClient.RefreshHostMappingRequested -= OnHostMappingRefreshRequested;
+ _mmsClient.HostMappingReceived -= OnHostMappingReceived;
+ _mmsClient.StartPunchRequested -= OnStartPunchRequested;
+ _mmsClient.CloseLobby();
+ _mmsClient = null;
+ }
+
_dtlsServer.Stop();
_clients.Clear();
}
@@ -95,13 +100,14 @@ public void Stop() {
///
/// Called when MMS notifies us of a client needing punch-back.
///
- private void OnPunchClientRequested(string clientIp, int clientPort) {
+ private void OnStartPunchRequested(string joinId, string clientIp, int clientPort, int hostPort, long startTimeMs) {
+ _mmsClient?.StopHostDiscoveryRefresh();
if (!IPAddress.TryParse(clientIp, out var ip)) {
Logger.Warn($"HolePunch Server: Invalid client IP: {clientIp}");
return;
}
-
- PunchToClient(new IPEndPoint(ip, clientPort));
+
+ _ = PunchToClientAsync(new IPEndPoint(ip, clientPort), startTimeMs);
}
///
@@ -113,25 +119,6 @@ public void DisconnectClient(IEncryptedTransportClient client) {
_clients.TryRemove(hpClient.EndPoint, out _);
}
- ///
- /// Initiates hole punch to a client that wants to connect.
- /// Uses the DTLS server's socket so the punch comes from the correct port.
- ///
- /// The client's public endpoint.
- private void PunchToClient(IPEndPoint clientEndpoint) {
- // Run on background thread to avoid blocking the calling thread for 5 seconds
- Task.Run(() => {
- Logger.Debug($"HolePunch Server: Punching to client at {clientEndpoint}");
-
- for (var i = 0; i < PunchPacketCount; i++) {
- _dtlsServer.SendRaw(PunchPacket, clientEndpoint);
- Thread.Sleep(PunchPacketDelayMs);
- }
-
- Logger.Info($"HolePunch Server: Punch packets sent to {clientEndpoint}");
- });
- }
-
///
/// Callback method for when data is received from a server client.
///
@@ -144,4 +131,44 @@ private void OnClientDataReceived(DtlsServerClient dtlsClient, byte[] data, int
client.RaiseDataReceived(data, length);
}
+
+ ///
+ /// Called when MMS asks the host to refresh its matchmaking mapping on the live UDP server socket.
+ ///
+ private void OnHostMappingRefreshRequested(string joinId, string hostDiscoveryToken, long serverTimeMs) {
+ Logger.Info($"HolePunch Server: Refreshing host mapping for join {joinId}");
+ _mmsClient?.StartHostDiscoveryRefresh(hostDiscoveryToken, (data, endpoint) => _dtlsServer.SendRaw(data, endpoint));
+ }
+
+ ///
+ /// Called when MMS confirms it has learned the host's current external mapping.
+ ///
+ private void OnHostMappingReceived() {
+ Logger.Info("HolePunch Server: Host mapping learned by MMS, stopping refresh");
+ _mmsClient?.StopHostDiscoveryRefresh();
+ }
+
+ ///
+ /// Sends UDP packets to ,
+ /// spaced ms apart, starting at .
+ /// Exceptions are caught and logged rather than propagated, since this runs fire-and-forget.
+ ///
+ private async Task PunchToClientAsync(IPEndPoint clientEndpoint, long startTimeMs)
+ {
+ try
+ {
+ Logger.Debug($"HolePunch Server: Punching to client at {clientEndpoint}");
+ var delay = startTimeMs - DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
+ if (delay > 0) await Task.Delay(TimeSpan.FromMilliseconds(delay));
+
+ for (var i = 0; i < PunchPacketCount; i++)
+ {
+ _dtlsServer.SendRaw(PunchPacket, clientEndpoint);
+ await Task.Delay(PunchPacketDelayMs);
+ }
+
+ Logger.Info($"HolePunch Server: Punch complete to {clientEndpoint}");
+ }
+ catch (Exception ex) { Logger.Error($"HolePunch Server: Punch to {clientEndpoint} failed – {ex.Message}"); }
+ }
}
diff --git a/SSMP/Networking/Transport/SteamP2P/SteamEncryptedTransport.cs b/SSMP/Networking/Transport/SteamP2P/SteamEncryptedTransport.cs
index e9ed6d7..634be6c 100644
--- a/SSMP/Networking/Transport/SteamP2P/SteamEncryptedTransport.cs
+++ b/SSMP/Networking/Transport/SteamP2P/SteamEncryptedTransport.cs
@@ -241,7 +241,7 @@ private void ReceivePackets() {
public void Disconnect() {
if (!_isConnected) return;
- // Signal shutdown first
+ // Signal shutdown first so the receive loop exits at the top of its next iteration.
_isConnected = false;
_receiveTokenSource?.Cancel();
@@ -257,8 +257,6 @@ public void Disconnect() {
SteamNetworking.CloseP2PSessionWithUser(_remoteSteamId);
}
- _remoteSteamId = NilSteamId;
-
if (_receiveThread != null) {
if (!_receiveThread.Join(5000)) {
Logger.Warn("Steam P2P: Receive thread did not terminate within 5 seconds");
@@ -267,6 +265,9 @@ public void Disconnect() {
_receiveThread = null;
}
+ // Safe to zero connection state only after the thread has exited.
+ _remoteSteamId = NilSteamId;
+
_receiveTokenSource?.Dispose();
_receiveTokenSource = null;
}
diff --git a/SSMP/Ui/Component/LobbyBrowserPanel.cs b/SSMP/Ui/Component/LobbyBrowserPanel.cs
index 8addc21..8e95ede 100644
--- a/SSMP/Ui/Component/LobbyBrowserPanel.cs
+++ b/SSMP/Ui/Component/LobbyBrowserPanel.cs
@@ -1,6 +1,6 @@
using System;
using System.Collections.Generic;
-using SSMP.Networking.Matchmaking;
+using SSMP.Networking.Matchmaking.Protocol;
using UnityEngine;
using UnityEngine.UI;
using Object = UnityEngine.Object;
diff --git a/SSMP/Ui/Component/LobbyConfigPanel.cs b/SSMP/Ui/Component/LobbyConfigPanel.cs
index bc00f05..41246f3 100644
--- a/SSMP/Ui/Component/LobbyConfigPanel.cs
+++ b/SSMP/Ui/Component/LobbyConfigPanel.cs
@@ -1,5 +1,5 @@
using System;
-using SSMP.Networking.Matchmaking;
+using SSMP.Networking.Matchmaking.Protocol;
using UnityEngine;
using UnityEngine.UI;
using Object = UnityEngine.Object;
@@ -293,51 +293,6 @@ private static GameObject CreateText(
return go;
}
- private static GameObject CreateInputField(Vector2 pos, float width, float height, string placeholder) {
- var go = new GameObject("InputField");
- var rect = go.AddComponent();
- rect.anchorMin = rect.anchorMax = new Vector2(0.5f, 1f);
- rect.pivot = new Vector2(0.5f, 0.5f);
- rect.anchoredPosition = new Vector2(pos.x, pos.y - height / 2f);
- rect.sizeDelta = new Vector2(width, height);
-
- var bg = go.AddComponent();
- bg.color = new Color(0.15f, 0.15f, 0.18f, 1f);
-
- var textGo = new GameObject("Text");
- var textRect = textGo.AddComponent();
- textRect.anchorMin = Vector2.zero;
- textRect.anchorMax = Vector2.one;
- textRect.offsetMin = new Vector2(10f, 5f);
- textRect.offsetMax = new Vector2(-10f, -5f);
- var text = textGo.AddComponent();
- text.font = Resources.FontManager.UIFontRegular;
- text.fontSize = 14;
- text.color = Color.white;
- text.alignment = TextAnchor.MiddleLeft;
- textGo.transform.SetParent(go.transform, false);
-
- var placeholderGo = new GameObject("Placeholder");
- var phRect = placeholderGo.AddComponent();
- phRect.anchorMin = Vector2.zero;
- phRect.anchorMax = Vector2.one;
- phRect.offsetMin = new Vector2(10f, 5f);
- phRect.offsetMax = new Vector2(-10f, -5f);
- var phText = placeholderGo.AddComponent();
- phText.font = Resources.FontManager.UIFontRegular;
- phText.fontSize = 14;
- phText.color = new Color(0.5f, 0.5f, 0.5f, 1f);
- phText.alignment = TextAnchor.MiddleLeft;
- phText.text = placeholder;
- placeholderGo.transform.SetParent(go.transform, false);
-
- var input = go.AddComponent();
- input.textComponent = text;
- input.placeholder = phText;
-
- return go;
- }
-
private static GameObject CreateButton(
string text,
Vector2 pos,
diff --git a/SSMP/Ui/Component/TextComponent.cs b/SSMP/Ui/Component/TextComponent.cs
index fd3b2f3..24935f5 100644
--- a/SSMP/Ui/Component/TextComponent.cs
+++ b/SSMP/Ui/Component/TextComponent.cs
@@ -17,8 +17,9 @@ public TextComponent(
string text,
int fontSize,
FontStyle fontStyle = FontStyle.Normal,
- TextAnchor alignment = TextAnchor.MiddleCenter
- ) : this(componentGroup, position, size, new Vector2(0.5f, 0.5f), text, fontSize, fontStyle, alignment) {
+ TextAnchor alignment = TextAnchor.MiddleCenter,
+ bool wrap = false
+ ) : this(componentGroup, position, size, new Vector2(0.5f, 0.5f), text, fontSize, fontStyle, alignment, wrap) {
}
public TextComponent(
@@ -29,9 +30,10 @@ public TextComponent(
string text,
int fontSize,
FontStyle fontStyle = FontStyle.Normal,
- TextAnchor alignment = TextAnchor.MiddleCenter
+ TextAnchor alignment = TextAnchor.MiddleCenter,
+ bool wrap = false
) : base(componentGroup, position, size) {
- _textObject = CreateTextObject(text, fontSize, fontStyle, alignment, pivot);
+ _textObject = CreateTextObject(text, fontSize, fontStyle, alignment, pivot, wrap);
AddSizeFitter();
AddOutline();
}
@@ -85,7 +87,14 @@ public float GetPreferredWidth() {
/// Create the Unity Text object with all the parameters.
///
/// The Unity object.
- private Text CreateTextObject(string text, int fontSize, FontStyle fontStyle, TextAnchor alignment, Vector2 pivot) {
+ private Text CreateTextObject(
+ string text,
+ int fontSize,
+ FontStyle fontStyle,
+ TextAnchor alignment,
+ Vector2 pivot,
+ bool wrap
+ ) {
var textObj = GameObject.AddComponent();
textObj.supportRichText = true;
@@ -94,7 +103,7 @@ private Text CreateTextObject(string text, int fontSize, FontStyle fontStyle, Te
textObj.fontSize = fontSize;
textObj.fontStyle = fontStyle;
textObj.alignment = alignment;
- textObj.horizontalOverflow = HorizontalWrapMode.Overflow;
+ textObj.horizontalOverflow = wrap ? HorizontalWrapMode.Wrap : HorizontalWrapMode.Overflow;
textObj.verticalOverflow = VerticalWrapMode.Overflow;
textObj.rectTransform.pivot = pivot;
textObj.raycastTarget = false; // do not block input
diff --git a/SSMP/Ui/ConnectInterface.cs b/SSMP/Ui/ConnectInterface.cs
index e9e450b..eaf9f73 100644
--- a/SSMP/Ui/ConnectInterface.cs
+++ b/SSMP/Ui/ConnectInterface.cs
@@ -6,6 +6,7 @@
using SSMP.Game.Settings;
using SSMP.Networking.Client;
using SSMP.Networking.Matchmaking;
+using SSMP.Networking.Matchmaking.Protocol;
using SSMP.Ui.Component;
using Steamworks;
using SSMP.Networking.Transport.Common;
@@ -158,6 +159,11 @@ internal class ConnectInterface {
///
private const float FeedbackTextOffset = 310f;
+ ///
+ /// Height of the bottom feedback area so longer matchmaking messages can wrap cleanly.
+ ///
+ private const float FeedbackTextHeight = 72f;
+
#endregion
#region UI Text Constants
@@ -329,6 +335,22 @@ internal class ConnectInterface {
///
private const string ErrorUnknown = "Failed to connect:\nUnknown reason";
+ ///
+ /// Large blocking message shown when the client must update before using matchmaking.
+ ///
+ private const string MatchmakingUpdateRequiredText =
+ "Please update to the latest version in order to use matchmaking!";
+
+ ///
+ /// Temporary message shown while the client verifies matchmaking compatibility.
+ ///
+ private const string MatchmakingCheckingText = "Checking matchmaking compatibility...";
+
+ ///
+ /// Blocking message shown when MMS cannot be reached, so matchmaking stays unavailable.
+ ///
+ private const string MatchmakingUnavailableText = "Unable to contact matchmaking server right now.";
+
#endregion
#region Fields
@@ -473,6 +495,31 @@ internal class ConnectInterface {
///
private Coroutine? _feedbackHideCoroutine;
+ ///
+ /// Whether the current bottom feedback message is being driven by matchmaking status UI.
+ ///
+ private bool _isMatchmakingFeedbackActive;
+
+ ///
+ /// Whether MMS has reported that this client is too old for matchmaking.
+ ///
+ private bool _isMatchmakingVersionBlocked;
+
+ ///
+ /// Whether the UI is currently verifying matchmaking compatibility with MMS.
+ ///
+ private bool _isCheckingMatchmakingVersion;
+
+ ///
+ /// Whether MMS has been reached successfully and the matchmaking version check passed.
+ ///
+ private bool _isMatchmakingReady;
+
+ ///
+ /// Tracks the currently selected tab so async UI refreshes preserve the active view.
+ ///
+ private Tab _activeTab = Tab.Matchmaking;
+
///
/// Public accessor for the MMS client.
/// Used by server manager to pass to HolePunch transport for lobby cleanup.
@@ -650,6 +697,7 @@ public ConnectInterface(ModSettings modSettings, ComponentGroup connectGroup) {
FinalizeLayout();
+ BeginMatchmakingVersionCheck();
SwitchTab(Tab.Matchmaking);
}
@@ -1028,11 +1076,12 @@ private ITextComponent CreateFeedbackText(float contentY) {
var feedback = new TextComponent(
_backgroundGroup,
new Vector2(InitialX, contentY),
- new Vector2(ContentWidth, LabelHeight),
+ new Vector2(ContentWidth, FeedbackTextHeight),
new Vector2(0.5f, 1f),
"",
UiManager.SubTextFontSize,
- alignment: TextAnchor.UpperCenter
+ alignment: TextAnchor.UpperCenter,
+ wrap: true
);
feedback.SetActive(false);
@@ -1082,6 +1131,8 @@ _directConnectButton is Component.Component connectComp &&
///
/// The tab to activate.
private void SwitchTab(Tab tab) {
+ _activeTab = tab;
+
// Hide lobby browsers and config panels if visible
_lobbyBrowserPanel.Hide();
_steamLobbyBrowserPanel?.Hide();
@@ -1093,9 +1144,15 @@ private void SwitchTab(Tab tab) {
_directIpTab.SetTabActive(tab == Tab.DirectIp);
// Show only the active tab's content
- _matchmakingGroup.SetActive(tab == Tab.Matchmaking);
+ _matchmakingGroup.SetActive(
+ tab == Tab.Matchmaking &&
+ _isMatchmakingReady &&
+ !_isMatchmakingVersionBlocked &&
+ !_isCheckingMatchmakingVersion
+ );
_steamGroup?.SetActive(tab == Tab.Steam);
_directIpGroup.SetActive(tab == Tab.DirectIp);
+ RefreshMatchmakingStatusFeedback();
}
///
@@ -1105,6 +1162,10 @@ private void SwitchTab(Tab tab) {
public void SetMenuActive(bool active) {
_backgroundPanel.SetActive(active);
_glowingNotch.SetActive(active);
+
+ if (active && !_isMatchmakingVersionBlocked && !_isCheckingMatchmakingVersion) {
+ BeginMatchmakingVersionCheck();
+ }
}
#endregion
@@ -1116,6 +1177,10 @@ public void SetMenuActive(bool active) {
/// Looks up lobby via MMS and connects to the host.
///
private void OnLobbyConnectButtonPressed() {
+ if (IsMatchmakingBlocked()) {
+ return;
+ }
+
if (!ValidateUsername(out var username)) {
return;
}
@@ -1144,27 +1209,76 @@ private IEnumerator JoinLobbyCoroutine(string lobbyId, string username) {
var task = MmsClient.JoinLobbyAsync(lobbyId, clientPort);
yield return new WaitUntil(() => task.IsCompleted);
+ if (!task.IsCompletedSuccessfully) {
+ CleanupHolePunchSocket(holePunchSocket);
+ Logger.Error(
+ $"ConnectInterface: JoinLobbyAsync failed: {task.Exception?.GetBaseException().Message ?? "cancelled"}"
+ );
+ ShowFeedback(Color.red, "Lobby not found, offline, or join failed");
+ yield break;
+ }
+
var lobbyInfo = task.Result;
if (lobbyInfo == null) {
CleanupHolePunchSocket(holePunchSocket);
+
+ if (MmsClient.LastMatchmakingError == MatchmakingError.UpdateRequired) {
+ ActivateMatchmakingVersionBlock();
+ yield break;
+ }
+
ShowFeedback(Color.red, "Lobby not found, offline, or join failed");
yield break;
}
- var (connectionData, lobbyType, lanConnectionData, clientDiscoveryToken) = lobbyInfo.Value;
-
- yield return RunDiscoveryCoroutine(
- holePunchSocket,
- clientDiscoveryToken,
- "MmsClient: UDP discovery timed out, falling back to local port"
- );
+ var connectionData = lobbyInfo.ConnectionData;
+ var lobbyType = lobbyInfo.LobbyType;
+ var lanConnectionData = lobbyInfo.LanConnectionData;
+ var joinId = lobbyInfo.JoinId;
// Handle connection based on lobby type
if (lobbyType == PublicLobbyType.Steam) {
CleanupHolePunchSocket(holePunchSocket);
ConnectToSteamLobby(connectionData, username);
} else {
- ConnectToMatchmakingLobby(connectionData, lanConnectionData, username, holePunchSocket);
+ if (string.IsNullOrEmpty(joinId)) {
+ CleanupHolePunchSocket(holePunchSocket);
+ ShowFeedback(Color.red, "Lobby not found, offline, or join failed");
+ yield break;
+ }
+
+ var joinTask = MmsClient.CoordinateMatchmakingJoinAsync(
+ joinId,
+ (data, endpoint) => { holePunchSocket.SendTo(data, endpoint); }
+ );
+
+ yield return new WaitUntil(() => joinTask.IsCompleted);
+
+ if (!joinTask.IsCompletedSuccessfully) {
+ CleanupHolePunchSocket(holePunchSocket);
+ Logger.Error(
+ $"ConnectInterface: CoordinateMatchmakingJoinAsync failed: {joinTask.Exception?.GetBaseException().Message ?? "cancelled"}"
+ );
+ ShowFeedback(Color.red, "Lobby not found, offline, or join failed");
+ yield break;
+ }
+
+ var joinStart = joinTask.Result;
+ if (joinStart == null) {
+ CleanupHolePunchSocket(holePunchSocket);
+
+ if (MmsClient.LastMatchmakingError == MatchmakingError.UpdateRequired) {
+ ActivateMatchmakingVersionBlock();
+ yield break;
+ }
+
+ ShowFeedback(Color.red, "Lobby not found, offline, or join failed");
+ yield break;
+ }
+
+ ConnectToMatchmakingLobby(
+ $"{joinStart.HostIp}:{joinStart.HostPort}", lanConnectionData, username, holePunchSocket
+ );
}
}
@@ -1173,6 +1287,10 @@ private IEnumerator JoinLobbyCoroutine(string lobbyId, string username) {
/// Shows the lobby configuration panel.
///
private void OnHostLobbyButtonPressed() {
+ if (IsMatchmakingBlocked()) {
+ return;
+ }
+
if (!ValidateUsername(out _)) {
return;
}
@@ -1295,21 +1413,27 @@ string username
yield return new WaitUntil(() => task.IsCompleted);
- var (lobbyId, lobbyName, hostDiscoveryToken) = task.Result;
- if (lobbyId == null || lobbyName == null) {
- ShowFeedback(Color.red, "Failed to create lobby. Is MMS running?");
+ if (!task.IsCompletedSuccessfully) {
CleanupHolePunchSocket(holePunchSocket);
+ Logger.Error(
+ $"ConnectInterface: CreateLobbyAsync failed: {task.Exception?.GetBaseException().Message ?? "cancelled"}"
+ );
+ ShowFeedback(Color.red, "Failed to create lobby. Is MMS running?");
yield break;
}
- yield return RunDiscoveryCoroutine(
- holePunchSocket,
- hostDiscoveryToken,
- "MmsClient: UDP discovery timed out, falling back to mapping-less hosting"
- );
+ var (lobbyId, lobbyName, _) = task.Result;
+ if (lobbyId == null || lobbyName == null) {
+ if (MmsClient.LastMatchmakingError == MatchmakingError.UpdateRequired) {
+ CleanupHolePunchSocket(holePunchSocket);
+ ActivateMatchmakingVersionBlock();
+ yield break;
+ }
- // Start polling for pending clients to punch back
- MmsClient.StartPendingClientPolling();
+ ShowFeedback(Color.red, "Failed to create lobby. Is MMS running?");
+ CleanupHolePunchSocket(holePunchSocket);
+ yield break;
+ }
// For private lobbies, show invite code in ChatBox so it's easily shareable
if (visibility == LobbyVisibility.Private) {
@@ -1335,6 +1459,10 @@ string username
/// Fetches and displays public lobbies from the MMS.
///
private void OnBrowseMatchmakingLobbiesPressed() {
+ if (IsMatchmakingBlocked()) {
+ return;
+ }
+
// Hide matchmaking content and show lobby browser
_matchmakingGroup.SetActive(false);
_lobbyBrowserPanel.Show();
@@ -1354,6 +1482,12 @@ private IEnumerator FetchLobbiesCoroutine() {
var lobbies = task.Result;
if (lobbies == null) {
+ if (MmsClient.LastMatchmakingError == MatchmakingError.UpdateRequired) {
+ _lobbyBrowserPanel.Hide();
+ ActivateMatchmakingVersionBlock();
+ yield break;
+ }
+
ShowFeedback(Color.red, "Failed to fetch lobbies. Is MMS running?");
yield break;
}
@@ -1691,6 +1825,7 @@ private void SaveConnectionSettings(string address, int port, string username) {
/// The color of the feedback text.
/// The message to display.
private void ShowFeedback(Color color, string message) {
+ _isMatchmakingFeedbackActive = false;
_feedbackHideCoroutine = ConnectInterfaceHelpers.SetFeedbackText(
_feedbackText,
color,
@@ -1699,6 +1834,105 @@ private void ShowFeedback(Color color, string message) {
);
}
+ ///
+ /// Activates the persistent matchmaking version block UI.
+ ///
+ private void ActivateMatchmakingVersionBlock() {
+ _isCheckingMatchmakingVersion = false;
+ _isMatchmakingReady = false;
+ _isMatchmakingVersionBlocked = true;
+ _lobbyBrowserPanel.Hide();
+ _lobbyConfigPanel.Hide();
+ SwitchTab(Tab.Matchmaking);
+ }
+
+ ///
+ /// Starts an immediate matchmaking compatibility probe so the UI can block before showing actions.
+ ///
+ private void BeginMatchmakingVersionCheck() {
+ if (_isCheckingMatchmakingVersion || _isMatchmakingVersionBlocked) {
+ return;
+ }
+
+ _isMatchmakingReady = false;
+ _isCheckingMatchmakingVersion = true;
+ SwitchTab(_activeTab);
+ MonoBehaviourUtil.Instance.StartCoroutine(CheckMatchmakingVersionCoroutine());
+ }
+
+ ///
+ /// Contacts MMS first and only enables matchmaking when the server is both reachable and compatible.
+ ///
+ private IEnumerator CheckMatchmakingVersionCoroutine() {
+ var task = MmsClient.ProbeMatchmakingCompatibilityAsync();
+ yield return new WaitUntil(() => task.IsCompleted);
+
+ if (MmsClient.LastMatchmakingError == MatchmakingError.UpdateRequired) {
+ ActivateMatchmakingVersionBlock();
+ yield break;
+ }
+
+ _isCheckingMatchmakingVersion = false;
+ _isMatchmakingReady = task.Result == true;
+ SwitchTab(_activeTab);
+ }
+
+ ///
+ /// Keeps matchmaking status messages aligned with the standard bottom feedback area.
+ ///
+ private void RefreshMatchmakingStatusFeedback() {
+ if (_activeTab != Tab.Matchmaking) {
+ if (_isMatchmakingFeedbackActive) {
+ _feedbackText.SetActive(false);
+ _isMatchmakingFeedbackActive = false;
+ }
+
+ return;
+ }
+
+ if (_isCheckingMatchmakingVersion) {
+ SetMatchmakingStatusFeedback(MatchmakingCheckingText, Color.yellow);
+ return;
+ }
+
+ if (_isMatchmakingVersionBlocked) {
+ SetMatchmakingStatusFeedback(MatchmakingUpdateRequiredText, Color.red);
+ return;
+ }
+
+ if (!_isMatchmakingReady) {
+ SetMatchmakingStatusFeedback(MatchmakingUnavailableText, Color.red);
+ return;
+ }
+
+ if (_isMatchmakingFeedbackActive) {
+ _feedbackText.SetActive(false);
+ _isMatchmakingFeedbackActive = false;
+ }
+ }
+
+ ///
+ /// Returns whether matchmaking is blocked for this client version.
+ ///
+ private bool IsMatchmakingBlocked() {
+ return _isMatchmakingVersionBlocked;
+ }
+
+ ///
+ /// Displays matchmaking-owned status feedback without clobbering feedback from other tabs.
+ ///
+ private void SetMatchmakingStatusFeedback(string message, Color color) {
+ if (_feedbackHideCoroutine != null) {
+ MonoBehaviourUtil.Instance.StopCoroutine(_feedbackHideCoroutine);
+ _feedbackHideCoroutine = null;
+ }
+
+ _isMatchmakingFeedbackActive = true;
+ _feedbackText.SetText(message);
+ _feedbackText.SetColor(color);
+ _feedbackText.SetActive(true);
+ }
+
///
/// Resets the connection buttons to their default state after a connection attempt.
///
@@ -1749,31 +1983,6 @@ private static int GetSocketPort(Socket socket) {
return ((IPEndPoint) socket.LocalEndPoint!).Port;
}
- ///
- /// Performs MMS-backed UDP discovery for a pre-bound hole-punch socket.
- ///
- private IEnumerator RunDiscoveryCoroutine(Socket holePunchSocket, string? discoveryToken, string timeoutMessage) {
- if (string.IsNullOrEmpty(discoveryToken)) {
- yield break;
- }
-
- ShowFeedback(Color.yellow, "Mapping external port...");
-
- var discoveryTask = MmsClient.PerformDiscoveryAsync(
- discoveryToken,
- (data, endpoint) => { holePunchSocket.SendTo(data, endpoint); }
- );
-
- yield return new WaitUntil(() => discoveryTask.IsCompleted);
-
- if (discoveryTask.Result == null) {
- Logger.Warn(timeoutMessage);
- yield break;
- }
-
- Logger.Info($"MmsClient: Discovered external port {discoveryTask.Result}");
- }
-
///
/// Handles connection to a matchmaking lobby with LAN/public fallback.
///