diff --git a/MMS/Bootstrap/HttpsCertificateConfigurator.cs b/MMS/Bootstrap/HttpsCertificateConfigurator.cs
new file mode 100644
index 0000000..718ee1a
--- /dev/null
+++ b/MMS/Bootstrap/HttpsCertificateConfigurator.cs
@@ -0,0 +1,104 @@
+using System.Security.Cryptography;
+using System.Security.Cryptography.X509Certificates;
+
+namespace MMS.Bootstrap;
+
+///
+/// Configures Kestrel HTTPS bindings from PEM certificate files in the working directory.
+///
+internal static class HttpsCertificateConfigurator {
+ ///
+ /// The filename of the PEM-encoded certificate.
+ ///
+ private const string CertFile = "cert.pem";
+
+ ///
+ /// The filename of the PEM-encoded private key.
+ ///
+ private const string KeyFile = "key.pem";
+
+ ///
+ /// Reads cert.pem and key.pem from the working directory and configures
+ /// Kestrel to terminate TLS with that certificate on port 5000.
+ ///
+ /// The web application builder to configure.
+ ///
+ /// if the certificate was loaded and Kestrel was configured;
+ /// if either file is missing, unreadable, or malformed.
+ ///
+ public static bool TryConfigure(WebApplicationBuilder builder) {
+ if (!TryReadPemFiles(out var pem, out var key))
+ return false;
+
+ if (!TryCreateCertificate(pem, key, out var certificate))
+ return false;
+
+ builder.WebHost.ConfigureKestrel(server =>
+ server.ListenAnyIP(5000, listen => listen.UseHttps(certificate!))
+ );
+
+ return true;
+ }
+
+ ///
+ /// Reads the PEM certificate and key files from the working directory.
+ ///
+ /// The contents of cert.pem if successful.
+ /// The contents of key.pem if successful.
+ ///
+ /// if both files were read successfully; otherwise .
+ ///
+ private static bool TryReadPemFiles(out string pem, out string key) {
+ pem = key = string.Empty;
+
+ if (!File.Exists(CertFile)) {
+ ProgramState.Logger.LogError("Certificate file '{File}' does not exist", CertFile);
+ return false;
+ }
+
+ if (!File.Exists(KeyFile)) {
+ ProgramState.Logger.LogError("Key file '{File}' does not exist", KeyFile);
+ return false;
+ }
+
+ try {
+ pem = File.ReadAllText(CertFile);
+ key = File.ReadAllText(KeyFile);
+ return true;
+ } catch (Exception e) {
+ ProgramState.Logger.LogError(e, "Could not read '{CertFile}' or '{KeyFile}'", CertFile, KeyFile);
+ return false;
+ }
+ }
+
+ ///
+ /// Attempts to construct an from PEM-encoded certificate and key material.
+ /// We export and re-import to force the private key into Windows-backed key storage (if using Windows). On Windows
+ /// PEM-created certificates can have the ephemeral private key that works in code but is unreliable for TLS.
+ /// Re-importing as `PKCS#12` with key storage flags transforms the key in a form Kestrel can use without issues.
+ ///
+ /// The PEM-encoded certificate.
+ /// The PEM-encoded private key.
+ /// The resulting certificate if successful; otherwise .
+ ///
+ /// if the certificate was created successfully otherwise .
+ ///
+ private static bool TryCreateCertificate(string pem, string key, out X509Certificate2? certificate) {
+ certificate = null;
+ try {
+ using var ephemeralCertificate = X509Certificate2.CreateFromPem(pem, key);
+ var pkcs12 = ephemeralCertificate.Export(X509ContentType.Pkcs12);
+ certificate = X509CertificateLoader.LoadPkcs12(
+ pkcs12,
+ password: null,
+ X509KeyStorageFlags.PersistKeySet |
+ X509KeyStorageFlags.MachineKeySet |
+ X509KeyStorageFlags.Exportable
+ );
+ return true;
+ } catch (CryptographicException e) {
+ ProgramState.Logger.LogError(e, "Could not create certificate from PEM files");
+ return false;
+ }
+ }
+}
diff --git a/MMS/Bootstrap/ProgramState.cs b/MMS/Bootstrap/ProgramState.cs
new file mode 100644
index 0000000..a4a8b5d
--- /dev/null
+++ b/MMS/Bootstrap/ProgramState.cs
@@ -0,0 +1,23 @@
+namespace MMS.Bootstrap;
+
+///
+/// Stores runtime application state that needs to be shared across startup helpers and endpoint mappings.
+///
+internal static class ProgramState {
+ ///
+ /// Gets or sets a value indicating whether the application is running in a development environment.
+ ///
+ public static bool IsDevelopment { get; internal set; }
+
+ ///
+ /// Gets or sets the shared application logger.
+ /// Assigned by before HTTPS configuration runs and
+ /// later replaced with the built host logger after application startup completes.
+ ///
+ public static ILogger Logger { get; internal set; } = null!;
+
+ ///
+ /// Gets the fixed UDP port used for discovery packets.
+ ///
+ public static int DiscoveryPort => 5001;
+}
diff --git a/MMS/Bootstrap/ServiceCollectionExtensions.cs b/MMS/Bootstrap/ServiceCollectionExtensions.cs
new file mode 100644
index 0000000..a557c2d
--- /dev/null
+++ b/MMS/Bootstrap/ServiceCollectionExtensions.cs
@@ -0,0 +1,206 @@
+using System.Net;
+using System.Threading.RateLimiting;
+using Microsoft.AspNetCore.HttpOverrides;
+using Microsoft.AspNetCore.RateLimiting;
+using MMS.Services.Lobbies;
+using MMS.Services.Matchmaking;
+using MMS.Services.Network;
+using static MMS.Contracts.Responses;
+
+namespace MMS.Bootstrap;
+
+///
+/// Extension methods for registering MMS services and infrastructure concerns.
+///
+internal static class ServiceCollectionExtensions {
+ ///
+ /// Registers MMS application services and hosted background services.
+ ///
+ /// The service collection being configured.
+ public static void AddMmsCoreServices(this IServiceCollection services) {
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddHostedService();
+ services.AddHostedService();
+ }
+
+ ///
+ /// Registers logging, forwarded headers, HTTP logging, and rate limiting for MMS.
+ ///
+ /// The service collection being configured.
+ /// The application configuration, used to bind infrastructure settings such as forwarded header options.
+ /// Whether the app is running in development.
+ public static void AddMmsInfrastructure(
+ this IServiceCollection services,
+ IConfiguration configuration,
+ bool isDevelopment
+ ) {
+ services.AddMmsLogging(isDevelopment);
+ services.AddMmsForwardedHeaders(configuration);
+ services.AddMmsRateLimiting();
+ }
+
+ ///
+ /// Configures structured console logging.
+ /// Enables HTTP request logging when running in development.
+ ///
+ /// The service collection being configured.
+ /// Whether the app is running in development.
+ private static void AddMmsLogging(this IServiceCollection services, bool isDevelopment) {
+ services.AddLogging(builder => {
+ builder.ClearProviders();
+ builder.AddSimpleConsole(options => {
+ options.SingleLine = true;
+ options.IncludeScopes = false;
+ options.TimestampFormat = "HH:mm:ss ";
+ }
+ );
+ }
+ );
+
+ if (isDevelopment)
+ services.AddHttpLogging(_ => { });
+ }
+
+ ///
+ /// Configures forwarded header processing for reverse proxy support.
+ /// Enables forwarding of X-Forwarded-For, X-Forwarded-Host,
+ /// and X-Forwarded-Proto headers.
+ ///
+ /// The service collection being configured.
+ ///
+ /// The application configuration. Reads ForwardedHeaders:KnownProxies as an array of IP address strings
+ /// and ForwardedHeaders:KnownNetworks as an array of CIDR notation strings to populate
+ /// and respectively.
+ ///
+ private static void AddMmsForwardedHeaders(this IServiceCollection services, IConfiguration configuration) {
+ services.Configure(options => {
+ options.ForwardedHeaders =
+ ForwardedHeaders.XForwardedFor |
+ ForwardedHeaders.XForwardedHost |
+ ForwardedHeaders.XForwardedProto;
+
+ // Trust individual reverse-proxy IPs from configuration so ASP.NET Core
+ // accepts forwarded client IP/protocol/host headers from those addresses.
+ foreach (var proxy in configuration.GetSection("ForwardedHeaders:KnownProxies").Get() ?? []) {
+ if (IPAddress.TryParse(proxy, out var address))
+ options.KnownProxies.Add(address);
+ }
+
+ // Trust whole proxy networks expressed as CIDR ranges. Invalid entries are
+ // ignored so a malformed value does not break startup.
+ foreach (var network in configuration.GetSection("ForwardedHeaders:KnownNetworks").Get() ??
+ []) {
+ if (!TryParseNetwork(network, out var ipNetwork))
+ continue;
+
+ options.KnownNetworks.Add(ipNetwork);
+ }
+ }
+ );
+ }
+
+ ///
+ /// Attempts to parse a CIDR notation string into an .
+ ///
+ /// The CIDR string to parse, expected in the format address/prefixLength (e.g. 192.168.1.0/24).
+ ///
+ /// When this method returns , contains the parsed ;
+ /// otherwise, the default value.
+ ///
+ ///
+ /// if was successfully parsed; otherwise, .
+ ///
+ private static bool TryParseNetwork(string value, out Microsoft.AspNetCore.HttpOverrides.IPNetwork network) {
+ network = null!;
+
+ var slashIndex = value.IndexOf('/');
+ if (slashIndex <= 0 || slashIndex >= value.Length - 1)
+ return false;
+
+ var prefixText = value[(slashIndex + 1)..];
+ if (!IPAddress.TryParse(value[..slashIndex], out var prefix) ||
+ !int.TryParse(prefixText, out var prefixLength)) {
+ return false;
+ }
+
+ network = new Microsoft.AspNetCore.HttpOverrides.IPNetwork(prefix, prefixLength);
+ return true;
+ }
+
+ ///
+ /// Registers IP-based fixed-window rate limiting policies for all MMS endpoints.
+ /// Rejected requests receive a 429 Too Many Requests response.
+ ///
+ ///
+ /// Policies:
+ ///
+ /// - create5 requests per 30 seconds.
+ /// - search10 requests per 10 seconds.
+ /// - join5 requests per 30 seconds.
+ ///
+ ///
+ /// The service collection being configured.
+ private static void AddMmsRateLimiting(this IServiceCollection services) {
+ services.AddRateLimiter(options => {
+ options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
+ options.OnRejected = async (context, token) => {
+ await context.HttpContext.Response.WriteAsJsonAsync(
+ new ErrorResponse("Too many requests. Please try again later."),
+ cancellationToken: token
+ );
+ };
+
+ options.AddFixedWindowPolicy("create", permitLimit: 5, windowSeconds: 30);
+ options.AddFixedWindowPolicy("search", permitLimit: 10, windowSeconds: 10);
+ options.AddFixedWindowPolicy("join", permitLimit: 5, windowSeconds: 30);
+ }
+ );
+ }
+
+ ///
+ /// Adds a named IP-keyed fixed-window rate limiter policy to the rate limiter options.
+ ///
+ /// The rate limiter options to configure.
+ /// The name used to reference this policy on endpoints.
+ /// Maximum number of requests allowed per window.
+ /// Duration of the rate limit window in seconds.
+ private static void AddFixedWindowPolicy(
+ this RateLimiterOptions options,
+ string policyName,
+ int permitLimit,
+ int windowSeconds
+ ) {
+ options.AddPolicy(
+ policyName,
+ context => RateLimitPartition.GetFixedWindowLimiter(
+ partitionKey: GetRateLimitPartitionKey(context),
+ factory: _ => new FixedWindowRateLimiterOptions {
+ PermitLimit = permitLimit,
+ Window = TimeSpan.FromSeconds(windowSeconds),
+ QueueLimit = 0
+ }
+ )
+ );
+ }
+
+ ///
+ /// Determines the rate limit partition key for an HTTP request.
+ /// Uses the first IP address from the X-Forwarded-For header when present,
+ /// falling back to , then .
+ ///
+ /// The current HTTP context.
+ /// A string key identifying the client for rate limiting purposes.
+ private static string GetRateLimitPartitionKey(HttpContext context) {
+ var forwardedFor = context.Request.Headers["X-Forwarded-For"].ToString();
+ if (!string.IsNullOrWhiteSpace(forwardedFor))
+ return forwardedFor.Split(',')[0].Trim();
+
+ return context.Connection.RemoteIpAddress?.ToString()
+ ?? context.Connection.Id;
+ }
+}
diff --git a/MMS/Bootstrap/WebApplicationExtensions.cs b/MMS/Bootstrap/WebApplicationExtensions.cs
new file mode 100644
index 0000000..13e53b8
--- /dev/null
+++ b/MMS/Bootstrap/WebApplicationExtensions.cs
@@ -0,0 +1,35 @@
+namespace MMS.Bootstrap;
+
+///
+/// Extension methods for configuring the MMS middleware pipeline.
+///
+internal static class WebApplicationExtensions {
+ ///
+ /// Applies the MMS middleware pipeline and binds the listener URL.
+ ///
+ /// The web application to configure.
+ /// Whether the app is running in development.
+ /// The same web application for chaining.
+ public static void UseMmsPipeline(this WebApplication app, bool isDevelopment) {
+ if (isDevelopment)
+ app.UseHttpLogging();
+ else
+ app.UseExceptionHandler("/error");
+
+ app.UseForwardedHeaders();
+ app.UseRateLimiter();
+ app.UseWebSockets();
+ app.Urls.Add(isDevelopment ? "http://0.0.0.0:5000" : "https://0.0.0.0:5000");
+ }
+
+ ///
+ /// Configures HTTPS for MMS when not running in development.
+ ///
+ /// The web application builder to configure.
+ /// Whether the app is running in development.
+ ///
+ /// when startup can continue; otherwise .
+ ///
+ public static bool TryConfigureMmsHttps(this WebApplicationBuilder builder, bool isDevelopment) =>
+ isDevelopment || HttpsCertificateConfigurator.TryConfigure(builder);
+}
diff --git a/MMS/Contracts/Requests.cs b/MMS/Contracts/Requests.cs
new file mode 100644
index 0000000..c731bff
--- /dev/null
+++ b/MMS/Contracts/Requests.cs
@@ -0,0 +1,45 @@
+using JetBrains.Annotations;
+
+namespace MMS.Contracts;
+
+///
+/// Request DTOs accepted by the MMS HTTP API.
+///
+internal static class Requests {
+ ///
+ /// Request payload for lobby creation.
+ ///
+ /// Host IP address (matchmaking only, optional - defaults to connection IP).
+ /// Host UDP port (matchmaking only).
+ /// Steam lobby ID (Steam only).
+ /// "steam" or "matchmaking" (default: "matchmaking").
+ /// Host LAN address for same-network fast-path discovery.
+ /// Whether the lobby appears in public browser listings (default: ).
+ /// Client matchmaking protocol version for compatibility checks.
+ [UsedImplicitly]
+ internal record CreateLobbyRequest(
+ string? HostIp,
+ int? HostPort,
+ string? ConnectionData,
+ string? LobbyType,
+ string? HostLanIp,
+ bool? IsPublic,
+ int? MatchmakingVersion
+ );
+
+ ///
+ /// Request payload for a lobby join attempt.
+ ///
+ /// Client IP override (optional - defaults to the connection's remote IP).
+ /// Client UDP port for NAT hole-punching.
+ /// Client matchmaking protocol version for compatibility checks.
+ [UsedImplicitly]
+ internal record JoinLobbyRequest(string? ClientIp, int ClientPort, int? MatchmakingVersion);
+
+ ///
+ /// Request payload for a lobby heartbeat.
+ ///
+ /// Number of remote players currently connected to the host.
+ [UsedImplicitly]
+ internal record HeartbeatRequest(int ConnectedPlayers);
+}
diff --git a/MMS/Contracts/Responses.cs b/MMS/Contracts/Responses.cs
new file mode 100644
index 0000000..730ddc5
--- /dev/null
+++ b/MMS/Contracts/Responses.cs
@@ -0,0 +1,92 @@
+using JetBrains.Annotations;
+
+namespace MMS.Contracts;
+
+///
+/// Response DTOs returned by the MMS HTTP API.
+///
+internal static class Responses {
+ ///
+ /// Response payload returned by the health check endpoint.
+ ///
+ /// The service name.
+ /// The current matchmaking protocol version.
+ /// Static health status string (e.g. "healthy").
+ [UsedImplicitly]
+ internal record HealthResponse(string Service, int Version, string Status);
+
+ ///
+ /// Response payload returned when a lobby is created.
+ ///
+ /// Connection identifier (IP:Port or Steam lobby ID).
+ /// Secret token required for host operations (heartbeat, close).
+ /// Display name assigned to the lobby.
+ /// Human-readable invite code (e.g. ABC123).
+ /// Token the host sends via UDP so MMS can map its external port.
+ [UsedImplicitly]
+ internal record CreateLobbyResponse(
+ string ConnectionData,
+ string HostToken,
+ string LobbyName,
+ string LobbyCode,
+ string? HostDiscoveryToken
+ );
+
+ ///
+ /// Response payload returned when listing lobbies.
+ ///
+ /// Connection identifier (IP:Port or Steam lobby ID).
+ /// Display name.
+ /// "steam" or "matchmaking".
+ /// Human-readable invite code.
+ [UsedImplicitly]
+ internal record LobbyResponse(
+ string ConnectionData,
+ string Name,
+ string LobbyType,
+ string LobbyCode
+ );
+
+ ///
+ /// Response payload returned for join attempts.
+ ///
+ /// Host connection data (IP:Port or Steam lobby ID).
+ /// "steam" or "matchmaking".
+ /// Client public IP as observed by MMS.
+ /// Client public port as observed by MMS.
+ /// Host LAN address returned when client and host share a network.
+ /// Token the client sends via UDP so MMS can map its external port.
+ /// Identifier for the WebSocket rendezvous session.
+ [UsedImplicitly]
+ internal record JoinResponse(
+ string ConnectionData,
+ string LobbyType,
+ string ClientIp,
+ int ClientPort,
+ string? LanConnectionData,
+ string? ClientDiscoveryToken,
+ string? JoinId
+ );
+
+ ///
+ /// Response payload returned when a discovery token has resolved to an external port.
+ ///
+ /// The external UDP port discovered for the token sender.
+ [UsedImplicitly]
+ internal record DiscoveryResponse(int ExternalPort);
+
+ ///
+ /// Response payload used for API errors.
+ ///
+ /// Human-readable error description.
+ /// Optional machine-readable error code (e.g. "update_required").
+ [UsedImplicitly]
+ internal record ErrorResponse(string Error, string? ErrorCode = null);
+
+ ///
+ /// Response payload used for simple status responses.
+ ///
+ /// Short status string (e.g. "alive", "pending").
+ [UsedImplicitly]
+ internal record StatusResponse(string Status);
+}
diff --git a/MMS/Features/EndpointRouteBuilderExtensions.cs b/MMS/Features/EndpointRouteBuilderExtensions.cs
new file mode 100644
index 0000000..579fd0d
--- /dev/null
+++ b/MMS/Features/EndpointRouteBuilderExtensions.cs
@@ -0,0 +1,24 @@
+using MMS.Features.Health;
+using MMS.Features.Lobbies;
+using MMS.Features.WebSockets;
+
+namespace MMS.Features;
+
+///
+/// Composes all MMS endpoint groups onto the web application.
+///
+internal static class EndpointRouteBuilderExtensions {
+ ///
+ /// Maps all HTTP and WebSocket endpoints exposed by MMS.
+ ///
+ /// The web application to map endpoints onto.
+ public static void MapMmsEndpoints(this WebApplication app) {
+ var lobby = app.MapGroup("/lobby");
+ var webSockets = app.MapGroup("/ws");
+ var joinWebSockets = webSockets.MapGroup("/join");
+
+ app.MapHealthEndpoints();
+ app.MapLobbyEndpoints(lobby);
+ WebSocketEndpoints.MapWebSocketEndpoints(webSockets, joinWebSockets);
+ }
+}
diff --git a/MMS/Features/Health/HealthEndpoints.cs b/MMS/Features/Health/HealthEndpoints.cs
new file mode 100644
index 0000000..c0bfac5
--- /dev/null
+++ b/MMS/Features/Health/HealthEndpoints.cs
@@ -0,0 +1,28 @@
+using MMS.Contracts;
+using MMS.Models;
+using MMS.Http;
+
+namespace MMS.Features.Health;
+
+///
+/// Maps health and monitoring endpoints.
+///
+internal static class HealthEndpoints {
+ ///
+ /// Maps health-related MMS endpoints.
+ ///
+ /// The web application to map endpoints onto.
+ public static void MapHealthEndpoints(this WebApplication app) {
+ app.Endpoint()
+ .Get("/health")
+ .Handler(HealthCheck)
+ .WithName("HealthCheck")
+ .Build();
+ }
+
+ ///
+ /// Returns the service name, current matchmaking protocol version, and a static health status.
+ ///
+ private static IResult HealthCheck() =>
+ Results.Ok(new Responses.HealthResponse("MMS", MatchmakingProtocol.CurrentVersion, "healthy"));
+}
diff --git a/MMS/Features/Lobbies/LobbyEndpointHandlers.cs b/MMS/Features/Lobbies/LobbyEndpointHandlers.cs
new file mode 100644
index 0000000..e90d1b4
--- /dev/null
+++ b/MMS/Features/Lobbies/LobbyEndpointHandlers.cs
@@ -0,0 +1,296 @@
+using System.Net;
+using Microsoft.AspNetCore.Http.HttpResults;
+using MMS.Bootstrap;
+using MMS.Features.Matchmaking;
+using MMS.Models;
+using MMS.Models.Lobbies;
+using MMS.Services.Lobbies;
+using MMS.Services.Matchmaking;
+using MMS.Services.Utility;
+using static MMS.Contracts.Requests;
+using static MMS.Contracts.Responses;
+
+// ReSharper disable once CheckNamespace
+namespace MMS.Features.Lobbies;
+
+///
+/// Contains handler and validation logic for lobby-oriented MMS endpoints.
+/// Pair this with , which owns route registration
+/// and delegates each mapped route to the handlers in this class.
+///
+internal static class LobbyEndpointHandlers {
+ ///
+ /// Returns all lobbies, optionally filtered by type.
+ ///
+ internal static Ok> GetLobbies(LobbyService lobbyService, string? type = null) {
+ var lobbies = lobbyService.GetLobbies(type)
+ .Select(l => new LobbyResponse(
+ l.AdvertisedConnectionData,
+ l.LobbyName,
+ l.LobbyType,
+ l.LobbyCode
+ )
+ );
+ return TypedResults.Ok(lobbies);
+ }
+
+ ///
+ /// Creates a new lobby (Steam or Matchmaking).
+ ///
+ internal static IResult CreateLobby(
+ CreateLobbyRequest request,
+ LobbyService lobbyService,
+ LobbyNameService lobbyNameService,
+ HttpContext context
+ ) {
+ var lobbyType = request.LobbyType ?? "matchmaking";
+
+ if (string.Equals(lobbyType, "matchmaking", StringComparison.OrdinalIgnoreCase) &&
+ !MatchmakingVersionValidation.Validate(request.MatchmakingVersion))
+ return MatchmakingOutdatedResult();
+
+ if (!TryResolveConnectionData(request, lobbyType, context, out var connectionData, out var error))
+ return error!;
+
+ var lobbyName = lobbyNameService.GenerateLobbyName();
+ var lobby = lobbyService.CreateLobby(
+ connectionData,
+ lobbyName,
+ lobbyType,
+ request.HostLanIp,
+ request.IsPublic ?? true
+ );
+
+ ProgramState.Logger.LogInformation(
+ "[LOBBY] Created: '{LobbyName}' [{LobbyType}] ({Visibility}) -> {ConnectionData} (Code: {LobbyCode})",
+ lobby.LobbyName,
+ lobby.LobbyType,
+ lobby.IsPublic ? "Public" : "Private",
+ PrivacyFormatter.Format(lobby.AdvertisedConnectionData),
+ lobby.LobbyCode
+ );
+
+ return TypedResults.Created(
+ $"/lobby/{lobby.LobbyCode}",
+ new CreateLobbyResponse(
+ lobby.AdvertisedConnectionData,
+ lobby.HostToken,
+ lobby.LobbyName,
+ lobby.LobbyCode,
+ lobby.HostDiscoveryToken
+ )
+ );
+ }
+
+ ///
+ /// Returns the externally discovered port for a discovery token when available.
+ ///
+ ///
+ /// Retained for compatibility. The active matchmaking client flow uses the WebSocket
+ /// rendezvous instead of polling this endpoint.
+ ///
+ internal static IResult VerifyDiscovery(string token, JoinSessionService joinService) {
+ var port = joinService.GetDiscoveredPort(token);
+ return port is null
+ ? TypedResults.Ok(new StatusResponse("pending"))
+ : TypedResults.Ok(new DiscoveryResponse(port.Value));
+ }
+
+ ///
+ /// Closes a lobby by host token.
+ ///
+ internal static Results> CloseLobby(
+ string token,
+ LobbyService lobbyService,
+ JoinSessionService joinService
+ ) {
+ if (!lobbyService.RemoveLobbyByToken(token, joinService.CleanupSessionsForLobby))
+ return TypedResults.NotFound(new ErrorResponse("Lobby not found"));
+
+ ProgramState.Logger.LogInformation("[LOBBY] Closed by host");
+ return TypedResults.NoContent();
+ }
+
+ ///
+ /// Refreshes the lobby heartbeat to prevent expiration.
+ ///
+ internal static Results, NotFound> Heartbeat(
+ string token,
+ HeartbeatRequest request,
+ LobbyService lobbyService
+ ) {
+ return lobbyService.Heartbeat(token, request.ConnectedPlayers)
+ ? TypedResults.Ok(new StatusResponse("alive"))
+ : TypedResults.NotFound(new ErrorResponse("Lobby not found"));
+ }
+
+ ///
+ /// Registers a client join attempt, returning host connection info and rendezvous metadata.
+ ///
+ internal static IResult JoinLobby(
+ string connectionData,
+ JoinLobbyRequest request,
+ LobbyService lobbyService,
+ JoinSessionService joinService,
+ HttpContext context
+ ) {
+ var lobby = lobbyService.GetLobbyByCode(connectionData) ?? lobbyService.GetLobby(connectionData);
+ if (lobby == null)
+ return TypedResults.NotFound(new ErrorResponse("Lobby not found"));
+
+ if (string.Equals(lobby.LobbyType, "matchmaking", StringComparison.OrdinalIgnoreCase) &&
+ !MatchmakingVersionValidation.Validate(request.MatchmakingVersion))
+ return MatchmakingOutdatedResult();
+
+ if (!TryResolveClientAddress(request, context, out var clientIp, out var clientIpError))
+ return clientIpError!;
+
+ ProgramState.Logger.LogInformation(
+ "[JOIN] {ClientEndPoint} -> {LobbyEndPoint}",
+ $"{PrivacyFormatter.Format(clientIp)}:{request.ClientPort}",
+ PrivacyFormatter.Format(lobby.AdvertisedConnectionData)
+ );
+
+ var lanConnectionData = TryResolveLanConnectionData(lobby, clientIp);
+
+ if (!string.Equals(lobby.LobbyType, "matchmaking", StringComparison.OrdinalIgnoreCase)) {
+ return TypedResults.Ok(
+ new JoinResponse(
+ lobby.AdvertisedConnectionData,
+ lobby.LobbyType,
+ clientIp,
+ request.ClientPort,
+ lanConnectionData,
+ null,
+ null
+ )
+ );
+ }
+
+ var session = joinService.CreateJoinSession(lobby, clientIp);
+ if (session == null)
+ return TypedResults.NotFound(new ErrorResponse("Lobby not found"));
+
+ return TypedResults.Ok(
+ new JoinResponse(
+ lobby.AdvertisedConnectionData,
+ lobby.LobbyType,
+ clientIp,
+ request.ClientPort,
+ lanConnectionData,
+ session.ClientDiscoveryToken,
+ session.JoinId
+ )
+ );
+ }
+
+ ///
+ /// Resolves the connectionData string for a lobby being created.
+ ///
+ /// The create-lobby request.
+ /// The resolved lobby type.
+ /// The current HTTP context.
+ ///
+ /// When this method returns , contains the resolved connection data string.
+ /// Otherwise, .
+ ///
+ ///
+ /// When this method returns , contains the validation error result to return to the caller.
+ /// Otherwise, .
+ ///
+ ///
+ /// when connection data was resolved successfully; otherwise .
+ ///
+ private static bool TryResolveConnectionData(
+ CreateLobbyRequest request,
+ string lobbyType,
+ HttpContext context,
+ out string connectionData,
+ out IResult? error
+ ) {
+ connectionData = string.Empty;
+ error = null;
+
+ if (string.Equals(lobbyType, "steam", StringComparison.OrdinalIgnoreCase)) {
+ if (string.IsNullOrEmpty(request.ConnectionData)) {
+ error = TypedResults.BadRequest(new ErrorResponse("Steam lobby requires ConnectionData"));
+ return false;
+ }
+
+ connectionData = request.ConnectionData;
+ return true;
+ }
+
+ var rawHostIp = request.HostIp ?? context.Connection.RemoteIpAddress?.ToString();
+ if (string.IsNullOrEmpty(rawHostIp) || !IPAddress.TryParse(rawHostIp, out var parsedHostIp)) {
+ error = TypedResults.BadRequest(new ErrorResponse("Invalid IP address"));
+ return false;
+ }
+
+ if (request.HostPort is null or <= 0 or > 65535) {
+ error = TypedResults.BadRequest(new ErrorResponse("Invalid port number"));
+ return false;
+ }
+
+ connectionData = $"{parsedHostIp}:{request.HostPort}";
+ return true;
+ }
+
+ ///
+ /// Resolves and validates the client IP address for a join request.
+ ///
+ private static bool TryResolveClientAddress(
+ JoinLobbyRequest request,
+ HttpContext context,
+ out string clientIp,
+ out IResult? error
+ ) {
+ clientIp = string.Empty;
+ error = null;
+
+ var rawClientIp = request.ClientIp ?? context.Connection.RemoteIpAddress?.ToString();
+ if (string.IsNullOrEmpty(rawClientIp) || !IPAddress.TryParse(rawClientIp, out var parsedIp)) {
+ error = TypedResults.BadRequest(new ErrorResponse("Invalid IP address"));
+ return false;
+ }
+
+ if (request.ClientPort is <= 0 or > 65535) {
+ error = TypedResults.BadRequest(new ErrorResponse("Invalid port"));
+ return false;
+ }
+
+ clientIp = parsedIp.ToString();
+ return true;
+ }
+
+ ///
+ /// Returns the host LAN address when the joining client shares the host's WAN IP.
+ ///
+ private static string? TryResolveLanConnectionData(Lobby lobby, string clientIp) {
+ if (string.IsNullOrEmpty(lobby.HostLanIp))
+ return null;
+
+ var hostWanIp = lobby.ConnectionData.Split(':')[0];
+ if (clientIp != hostWanIp)
+ return null;
+
+ ProgramState.Logger.LogInformation(
+ "[JOIN] Local network detected - returning LAN IP: {HostLanIp}",
+ PrivacyFormatter.Format(lobby.HostLanIp)
+ );
+
+ return lobby.HostLanIp;
+ }
+
+ ///
+ /// Returns a bad request result indicating the client's matchmaking version is outdated.
+ ///
+ private static IResult MatchmakingOutdatedResult() =>
+ TypedResults.BadRequest(
+ new ErrorResponse(
+ "Please update to the latest version in order to use matchmaking!",
+ MatchmakingProtocol.UpdateRequiredErrorCode
+ )
+ );
+
+}
diff --git a/MMS/Features/Lobbies/LobbyEndpoints.cs b/MMS/Features/Lobbies/LobbyEndpoints.cs
new file mode 100644
index 0000000..fe51416
--- /dev/null
+++ b/MMS/Features/Lobbies/LobbyEndpoints.cs
@@ -0,0 +1,57 @@
+using MMS.Http;
+
+// ReSharper disable once CheckNamespace
+namespace MMS.Features.Lobbies;
+
+///
+/// Maps lobby-oriented MMS HTTP endpoints.
+/// Pair this with , which contains the
+/// corresponding handler and validation logic for the routes defined here.
+///
+internal static class LobbyEndpoints {
+ ///
+ /// Maps lobby management and matchmaking HTTP endpoints.
+ ///
+ /// The web application to map non-lobby-root endpoints onto.
+ /// The grouped route builder for /lobby routes.
+ public static void MapLobbyEndpoints(this WebApplication app, RouteGroupBuilder lobby) {
+ app.Endpoint()
+ .Get("/lobbies")
+ .Handler(LobbyEndpointHandlers.GetLobbies)
+ .WithName("ListLobbies")
+ .RequireRateLimiting("search")
+ .Build();
+
+ lobby.Endpoint()
+ .Post("")
+ .Handler(LobbyEndpointHandlers.CreateLobby)
+ .WithName("CreateLobby")
+ .RequireRateLimiting("create")
+ .Build();
+
+ lobby.Endpoint()
+ .Delete("/{token}")
+ .Handler(LobbyEndpointHandlers.CloseLobby)
+ .WithName("CloseLobby")
+ .Build();
+
+ lobby.Endpoint()
+ .Post("/heartbeat/{token}")
+ .Handler(LobbyEndpointHandlers.Heartbeat)
+ .WithName("Heartbeat")
+ .Build();
+
+ lobby.Endpoint()
+ .Post("/discovery/verify/{token}")
+ .Handler(LobbyEndpointHandlers.VerifyDiscovery)
+ .WithName("VerifyDiscovery")
+ .Build();
+
+ lobby.Endpoint()
+ .Post("/{connectionData}/join")
+ .Handler(LobbyEndpointHandlers.JoinLobby)
+ .WithName("JoinLobby")
+ .RequireRateLimiting("join")
+ .Build();
+ }
+}
diff --git a/MMS/Features/Matchmaking/MatchmakingVersionValidation.cs b/MMS/Features/Matchmaking/MatchmakingVersionValidation.cs
new file mode 100644
index 0000000..0670594
--- /dev/null
+++ b/MMS/Features/Matchmaking/MatchmakingVersionValidation.cs
@@ -0,0 +1,24 @@
+using MMS.Models;
+
+namespace MMS.Features.Matchmaking;
+
+///
+/// Shared matchmaking protocol version validation helpers.
+///
+internal static class MatchmakingVersionValidation {
+ ///
+ /// Returns if matches the current protocol version.
+ ///
+ /// The client-reported matchmaking protocol version.
+ public static bool Validate(int? matchmakingVersion) =>
+ matchmakingVersion == MatchmakingProtocol.CurrentVersion;
+
+ ///
+ /// Parses and validates a matchmaking version from a query string value.
+ ///
+ /// Raw query string value.
+ /// if the version is present and matches the current protocol.
+ public static bool TryValidate(string? matchmakingVersion) =>
+ int.TryParse(matchmakingVersion, out var parsedVersion) &&
+ Validate(parsedVersion);
+}
diff --git a/MMS/Features/WebSockets/WebSocketEndpoints.cs b/MMS/Features/WebSockets/WebSocketEndpoints.cs
new file mode 100644
index 0000000..12ebee6
--- /dev/null
+++ b/MMS/Features/WebSockets/WebSocketEndpoints.cs
@@ -0,0 +1,292 @@
+using System.Net.Sockets;
+using System.Net.WebSockets;
+using MMS.Bootstrap;
+using MMS.Features.Matchmaking;
+using MMS.Http;
+using MMS.Models;
+using MMS.Models.Matchmaking;
+using MMS.Services.Lobbies;
+using MMS.Services.Matchmaking;
+using MMS.Services.Utility;
+using static MMS.Contracts.Responses;
+
+namespace MMS.Features.WebSockets;
+
+///
+/// Maps WebSocket endpoints used by hosts and matchmaking join sessions.
+///
+internal static class WebSocketEndpoints {
+ ///
+ /// Maps all MMS WebSocket endpoints onto the provided route group builders.
+ ///
+ /// The grouped route builder for /ws routes.
+ /// The grouped route builder for /ws/join routes.
+ public static void MapWebSocketEndpoints(
+ RouteGroupBuilder webSockets,
+ RouteGroupBuilder joinWebSockets
+ ) {
+ webSockets.Endpoint()
+ .Map("/{token}")
+ .Handler(HandleHostWebSocketAsync)
+ .Build();
+
+ joinWebSockets.Endpoint()
+ .Map("/{joinId}")
+ .Handler(HandleJoinWebSocketAsync)
+ .Build();
+ }
+
+ ///
+ /// Handles the persistent host WebSocket used for push notifications.
+ /// Keeps the socket open until the host closes the connection or disconnects.
+ ///
+ private static async Task HandleHostWebSocketAsync(
+ HttpContext context,
+ string token,
+ LobbyService lobbyService
+ ) {
+ if (!context.WebSockets.IsWebSocketRequest) {
+ context.Response.StatusCode = StatusCodes.Status400BadRequest;
+ return;
+ }
+
+ var lobby = lobbyService.GetLobbyByToken(token);
+ if (lobby == null) {
+ context.Response.StatusCode = StatusCodes.Status404NotFound;
+ return;
+ }
+
+ using var webSocket = await context.WebSockets.AcceptWebSocketAsync();
+ var previousSocket = lobby.HostWebSocket;
+ if (previousSocket != null && !ReferenceEquals(previousSocket, webSocket))
+ await CloseReplacedHostSocketAsync(previousSocket, PrivacyFormatter.Format(lobby.ConnectionData), context.RequestAborted);
+
+ lobby.HostWebSocket = webSocket;
+
+ ProgramState.Logger.LogInformation(
+ "[WS] Host connected for lobby {LobbyIdentifier}",
+ PrivacyFormatter.Format(lobby.ConnectionData)
+ );
+
+ try {
+ await DrainHostWebSocketAsync(webSocket, PrivacyFormatter.Format(lobby.ConnectionData));
+ } finally {
+ if (ReferenceEquals(lobby.HostWebSocket, webSocket))
+ lobby.HostWebSocket = null;
+
+ ProgramState.Logger.LogInformation(
+ "[WS] Host disconnected from lobby {LobbyIdentifier}",
+ PrivacyFormatter.Format(lobby.ConnectionData)
+ );
+ }
+ }
+
+ ///
+ /// Reads and discards incoming frames until the socket closes or the connection is reset.
+ /// The host WebSocket is receive-only; all meaningful communication is server-to-host push.
+ ///
+ /// The accepted host WebSocket.
+ /// The lobby identifier, used for diagnostic logging on unexpected disconnects.
+ private static async Task DrainHostWebSocketAsync(WebSocket webSocket, string lobbyIdentifier) {
+ var buffer = new byte[1024];
+ try {
+ while (webSocket.State == WebSocketState.Open) {
+ var result = await webSocket.ReceiveAsync(buffer, CancellationToken.None);
+ if (result.MessageType == WebSocketMessageType.Close)
+ break;
+ }
+ } catch (WebSocketException ex) {
+ ProgramState.Logger.LogDebug(
+ ex, "[WS] Host socket closed unexpectedly for lobby {LobbyIdentifier}", lobbyIdentifier
+ );
+ // Host disconnected without a proper close handshake.
+ } catch (Exception ex) when (ex.InnerException is SocketException) {
+ ProgramState.Logger.LogDebug(ex, "[WS] Host socket reset for lobby {LobbyIdentifier}", lobbyIdentifier);
+ // Connection was forcibly reset during shutdown or game exit.
+ }
+ }
+
+ ///
+ /// Attempts a graceful close of a host WebSocket that has been superseded by a newer connection,
+ /// falling back to an abort if the socket is not in a closeable state or if the close fails.
+ ///
+ /// The WebSocket to close and dispose.
+ /// The lobby identifier, used for diagnostic logging on failure.
+ /// A token to cancel the close handshake.
+ private static async Task CloseReplacedHostSocketAsync(
+ WebSocket previousSocket,
+ string lobbyIdentifier,
+ CancellationToken cancellationToken
+ ) {
+ try {
+ if (previousSocket.State is WebSocketState.Open or WebSocketState.CloseReceived) {
+ await previousSocket.CloseAsync(
+ WebSocketCloseStatus.NormalClosure,
+ "Replaced by newer host connection",
+ cancellationToken
+ );
+ } else {
+ previousSocket.Abort();
+ }
+ } catch (Exception ex) {
+ ProgramState.Logger.LogDebug(
+ ex,
+ "[WS] Failed to close replaced host socket for lobby {LobbyIdentifier}",
+ lobbyIdentifier
+ );
+ previousSocket.Abort();
+ } finally {
+ previousSocket.Dispose();
+ }
+ }
+
+ ///
+ /// Handles the short-lived matchmaking WebSocket used during join rendezvous.
+ /// Validates the request, attaches the socket to the session, orchestrates the
+ /// host reachability check, then waits for the client to disconnect.
+ ///
+ private static async Task HandleJoinWebSocketAsync(
+ HttpContext context,
+ string joinId,
+ LobbyService lobbyService,
+ JoinSessionService joinService
+ ) {
+ if (!context.WebSockets.IsWebSocketRequest) {
+ context.Response.StatusCode = StatusCodes.Status400BadRequest;
+ return;
+ }
+
+ if (!ValidateMatchmakingVersion(context))
+ return;
+
+ var session = joinService.GetJoinSession(joinId);
+ if (session == null) {
+ context.Response.StatusCode = StatusCodes.Status404NotFound;
+ return;
+ }
+
+ using var webSocket = await context.WebSockets.AcceptWebSocketAsync();
+ if (!joinService.AttachJoinWebSocket(joinId, webSocket)) {
+ await webSocket.CloseAsync(
+ WebSocketCloseStatus.PolicyViolation,
+ "join session not found",
+ context.RequestAborted
+ );
+ return;
+ }
+
+ await joinService.SendBeginClientMappingAsync(joinId, context.RequestAborted);
+
+ if (!await EnsureHostReachableAsync(context, joinId, session, lobbyService, joinService)) {
+ if (webSocket.State is WebSocketState.Open or WebSocketState.CloseReceived) {
+ await webSocket.CloseAsync(
+ WebSocketCloseStatus.NormalClosure,
+ "Host unreachable",
+ context.RequestAborted
+ );
+ }
+
+ return;
+ }
+
+ await DrainJoinWebSocketAsync(webSocket, joinId, joinService, context.RequestAborted);
+ }
+
+ ///
+ /// Validates the matchmakingVersion query parameter.
+ /// Writes a 400 Bad Request response if validation fails.
+ ///
+ /// The current HTTP context.
+ ///
+ /// if the version is acceptable; otherwise .
+ ///
+ private static bool ValidateMatchmakingVersion(HttpContext context) {
+ if (MatchmakingVersionValidation.TryValidate(context.Request.Query["matchmakingVersion"]))
+ return true;
+
+ context.Response.StatusCode = StatusCodes.Status400BadRequest;
+ context.Response
+ .WriteAsJsonAsync(
+ new ErrorResponse(
+ "Please update to the latest version in order to use matchmaking!",
+ MatchmakingProtocol.UpdateRequiredErrorCode
+ ),
+ context.RequestAborted
+ )
+ .GetAwaiter()
+ .GetResult();
+ return false;
+ }
+
+ ///
+ /// Verifies that the host WebSocket for the session's lobby is reachable,
+ /// and requests a port refresh if the lobby has no known external port.
+ /// Fails the join session if the host cannot be reached.
+ ///
+ /// The current HTTP context, used for cancellation.
+ /// The join session identifier.
+ /// The resolved join session.
+ /// The lobby service for host lookup.
+ /// The join session service for signaling.
+ ///
+ /// if the host is reachable and the session may proceed;
+ /// otherwise .
+ ///
+ private static async Task EnsureHostReachableAsync(
+ HttpContext context,
+ string joinId,
+ JoinSession session,
+ LobbyService lobbyService,
+ JoinSessionService joinService
+ ) {
+ var lobby = lobbyService.GetLobby(session.LobbyConnectionData);
+ if (lobby?.HostWebSocket is not { State: WebSocketState.Open }) {
+ await joinService.FailJoinSessionAsync(joinId, "host_unreachable", context.RequestAborted);
+ return false;
+ }
+
+ if (lobby.ExternalPort != null)
+ return true;
+
+ var refreshSent = await joinService.SendHostRefreshRequestAsync(joinId, context.RequestAborted);
+ if (!refreshSent) {
+ await joinService.FailJoinSessionAsync(joinId, "host_unreachable", context.RequestAborted);
+ return false;
+ }
+
+ return true;
+ }
+
+ ///
+ /// Reads and discards incoming frames on the join WebSocket until the client disconnects
+ /// or the request is canceled. Fails the session as client_disconnected on exit
+ /// if the session is still active.
+ ///
+ /// The accepted join WebSocket.
+ /// The join session identifier.
+ /// The join session service used to fail the session on disconnect.
+ /// The request cancellation token.
+ private static async Task DrainJoinWebSocketAsync(
+ WebSocket webSocket,
+ string joinId,
+ JoinSessionService joinService,
+ CancellationToken cancellationToken
+ ) {
+ var buffer = new byte[256];
+ try {
+ while (webSocket.State == WebSocketState.Open) {
+ var result = await webSocket.ReceiveAsync(buffer, cancellationToken);
+ if (result.MessageType == WebSocketMessageType.Close)
+ break;
+ }
+ } catch (OperationCanceledException) {
+ // Request was canceled so session cleanup is handled in finally.
+ } catch (WebSocketException) {
+ // Client disconnected without a proper close handshake.
+ } finally {
+ if (joinService.GetJoinSession(joinId) != null)
+ await joinService.FailJoinSessionAsync(joinId, "client_disconnected", CancellationToken.None);
+ }
+ }
+
+}
diff --git a/MMS/Http/EndpointBuilder.cs b/MMS/Http/EndpointBuilder.cs
new file mode 100644
index 0000000..050b0ad
--- /dev/null
+++ b/MMS/Http/EndpointBuilder.cs
@@ -0,0 +1,127 @@
+namespace MMS.Http;
+
+///
+/// Fluent builder for registering minimal API endpoints with a compact, readable syntax.
+///
+public sealed class EndpointBuilder(IEndpointRouteBuilder routes) {
+ private string _method = "GET";
+ private string _route = "/";
+ private Delegate? _handler;
+ private string? _name;
+ private string? _rateLimitingPolicy;
+
+ ///
+ /// Configures the endpoint as an HTTP GET route.
+ ///
+ /// The route pattern to map.
+ /// The same builder for chaining.
+ public EndpointBuilder Get(string route) {
+ _method = "GET";
+ _route = route;
+ return this;
+ }
+
+ ///
+ /// Configures the endpoint as an HTTP POST route.
+ ///
+ /// The route pattern to map.
+ /// The same builder for chaining.
+ public EndpointBuilder Post(string route) {
+ _method = "POST";
+ _route = route;
+ return this;
+ }
+
+ ///
+ /// Configures the endpoint as an HTTP DELETE route.
+ ///
+ /// The route pattern to map.
+ /// The same builder for chaining.
+ public EndpointBuilder Delete(string route) {
+ _method = "DELETE";
+ _route = route;
+ return this;
+ }
+
+ ///
+ /// Configures the endpoint as a generic mapped route, useful for WebSocket handlers.
+ ///
+ /// The route pattern to map.
+ /// The same builder for chaining.
+ public EndpointBuilder Map(string route) {
+ _method = "MAP";
+ _route = route;
+ return this;
+ }
+
+ ///
+ /// Sets the request handler delegate.
+ ///
+ /// The delegate to invoke when the endpoint matches.
+ /// The same builder for chaining.
+ public EndpointBuilder Handler(Delegate handler) {
+ _handler = handler;
+ return this;
+ }
+
+ ///
+ /// Sets the endpoint name.
+ ///
+ /// The endpoint name.
+ /// The same builder for chaining.
+ public EndpointBuilder WithName(string name) {
+ _name = name;
+ return this;
+ }
+
+ ///
+ /// Applies an ASP.NET Core rate-limiting policy to the endpoint.
+ ///
+ /// The name of the rate-limiting policy.
+ /// The same builder for chaining.
+ public EndpointBuilder RequireRateLimiting(string policyName) {
+ _rateLimitingPolicy = policyName;
+ return this;
+ }
+
+ ///
+ /// Builds and registers the configured endpoint.
+ ///
+ /// The same route builder that created this endpoint.
+ public void Build() {
+ ArgumentNullException.ThrowIfNull(_handler);
+
+ var endpoint = _method switch {
+ "GET" => routes.MapGet(_route, _handler),
+ "POST" => routes.MapPost(_route, _handler),
+ "DELETE" => routes.MapDelete(_route, _handler),
+ "MAP" => routes.Map(_route, _handler),
+ _ => throw new NotSupportedException($"Method {_method} is not supported.")
+ };
+
+ if (_name is not null)
+ endpoint.WithName(_name);
+
+ if (_rateLimitingPolicy is not null)
+ endpoint.RequireRateLimiting(_rateLimitingPolicy);
+ }
+}
+
+///
+/// Extension methods for starting fluent endpoint registrations.
+///
+internal static class FluentEndpointBuilderExtensions {
+ ///
+ /// Starts building an endpoint on a web application.
+ ///
+ /// The application to map onto.
+ /// A new endpoint builder.
+ public static EndpointBuilder Endpoint(this WebApplication app) => new(app);
+
+ ///
+ /// Starts building an endpoint on a grouped route builder.
+ ///
+ /// The route group to map onto.
+ /// A new endpoint builder.
+ public static EndpointBuilder Endpoint(this RouteGroupBuilder group) => new(group);
+}
diff --git a/MMS/Models/Lobby.cs b/MMS/Models/Lobbies/Lobby.cs
similarity index 83%
rename from MMS/Models/Lobby.cs
rename to MMS/Models/Lobbies/Lobby.cs
index 1093d57..54b338c 100644
--- a/MMS/Models/Lobby.cs
+++ b/MMS/Models/Lobbies/Lobby.cs
@@ -1,12 +1,6 @@
-using System.Collections.Concurrent;
using System.Net.WebSockets;
-namespace MMS.Models;
-
-///
-/// Client waiting for NAT hole-punch.
-///
-public record PendingClient(string ClientIp, int ClientPort, DateTime RequestedAt);
+namespace MMS.Models.Lobbies;
///
/// Game lobby. ConnectionData serves as both identifier and connection info.
@@ -19,7 +13,8 @@ public class Lobby(
string lobbyName,
string lobbyType = "matchmaking",
string? hostLanIp = null,
- bool isPublic = true
+ bool isPublic = true,
+ string? hostDiscoveryToken = null
) {
/// Stable connection data used as the lobby identity and storage key.
public string ConnectionData { get; } = connectionData;
@@ -45,9 +40,6 @@ public class Lobby(
/// Timestamp of the last heartbeat from the host.
public DateTime LastHeartbeat { get; set; } = DateTime.UtcNow;
- /// Queue of clients waiting for NAT hole-punch.
- public ConcurrentQueue PendingClients { get; } = new();
-
/// True if no heartbeat received in the last 60 seconds.
public bool IsDead => DateTime.UtcNow - LastHeartbeat > TimeSpan.FromSeconds(60);
@@ -55,7 +47,7 @@ public class Lobby(
public int? ExternalPort { get; internal set; }
/// Token used for UDP port discovery.
- public string? HostDiscoveryToken { get; init; }
+ public string? HostDiscoveryToken { get; } = hostDiscoveryToken;
/// Connection data that should be advertised to clients.
public string AdvertisedConnectionData {
diff --git a/MMS/Models/DiscoveryTokenMetadata.cs b/MMS/Models/Matchmaking/DiscoveryTokenMetadata.cs
similarity index 65%
rename from MMS/Models/DiscoveryTokenMetadata.cs
rename to MMS/Models/Matchmaking/DiscoveryTokenMetadata.cs
index 2eedba5..2ab286c 100644
--- a/MMS/Models/DiscoveryTokenMetadata.cs
+++ b/MMS/Models/Matchmaking/DiscoveryTokenMetadata.cs
@@ -1,4 +1,4 @@
-namespace MMS.Models;
+namespace MMS.Models.Matchmaking;
///
/// Metadata for an active NAT traversal discovery session.
@@ -8,7 +8,7 @@ public sealed class DiscoveryTokenMetadata {
/// The UTC timestamp when this discovery token was created.
/// Used for automatic cleanup of stale sessions.
///
- public DateTime CreatedAt { get; } = DateTime.UtcNow;
+ public DateTime CreatedAt { get; init; } = DateTime.UtcNow;
///
/// The external port discovered via UDP.
@@ -17,16 +17,10 @@ public sealed class DiscoveryTokenMetadata {
public int? DiscoveredPort { get; set; }
///
- /// The invite code of the lobby this token is associated with.
+ /// The join session associated with the client discovery token.
/// Only populated for client discovery tokens.
///
- public string? LobbyCode { get; init; }
-
- ///
- /// The public IP address of the client performing discovery.
- /// Only populated for client discovery tokens.
- ///
- public string? ClientIp { get; init; }
+ public string? JoinId { get; init; }
///
/// The connection data of the host lobby.
diff --git a/MMS/Models/Matchmaking/JoinSession.cs b/MMS/Models/Matchmaking/JoinSession.cs
new file mode 100644
index 0000000..ac2c200
--- /dev/null
+++ b/MMS/Models/Matchmaking/JoinSession.cs
@@ -0,0 +1,43 @@
+using System.Net.WebSockets;
+
+namespace MMS.Models.Matchmaking;
+
+///
+/// Short-lived matchmaking rendezvous session for a single join attempt.
+///
+public sealed class JoinSession {
+ ///
+ /// Unique identifier for this join attempt.
+ ///
+ public required string JoinId { get; init; }
+
+ ///
+ /// Connection data of the lobby being joined.
+ ///
+ public required string LobbyConnectionData { get; init; }
+
+ ///
+ /// Public IP address of the joining client.
+ ///
+ public required string ClientIp { get; init; }
+
+ ///
+ /// Client discovery token used to map the external port.
+ ///
+ public required string ClientDiscoveryToken { get; init; }
+
+ ///
+ /// Externally visible client port once discovery completes.
+ ///
+ public int? ClientExternalPort { get; set; }
+
+ ///
+ /// The client WebSocket attached to this join attempt, if connected.
+ ///
+ public WebSocket? ClientWebSocket { get; set; }
+
+ ///
+ /// Timestamp when this join session should expire.
+ ///
+ public DateTime ExpiresAtUtc { get; } = DateTime.UtcNow.AddSeconds(20);
+}
diff --git a/MMS/Models/MatchmakingProtocol.cs b/MMS/Models/MatchmakingProtocol.cs
new file mode 100644
index 0000000..13de033
--- /dev/null
+++ b/MMS/Models/MatchmakingProtocol.cs
@@ -0,0 +1,21 @@
+namespace MMS.Models;
+
+///
+/// Shared constants for the strict matchmaking rendezvous protocol.
+///
+internal static class MatchmakingProtocol {
+ ///
+ /// The current matchmaking protocol version required for MMS matchmaking operations.
+ ///
+ public const int CurrentVersion = 1;
+
+ ///
+ /// Error code returned when a client must update before using matchmaking.
+ ///
+ public const string UpdateRequiredErrorCode = "update_required";
+
+ ///
+ /// Offset applied before issuing synchronized start_punch instructions.
+ ///
+ public const int PunchTimingOffsetMs = 250;
+}
diff --git a/MMS/Program.cs b/MMS/Program.cs
index 611309e..feb45ba 100644
--- a/MMS/Program.cs
+++ b/MMS/Program.cs
@@ -1,597 +1,52 @@
-using System.Net;
-using System.Net.WebSockets;
-using System.Security.Cryptography;
-using System.Security.Cryptography.X509Certificates;
-using System.Text.Json;
-using System.Threading.RateLimiting;
-using JetBrains.Annotations;
-using Microsoft.AspNetCore.Http.HttpResults;
-using Microsoft.AspNetCore.HttpOverrides;
-using MMS.Services;
+using MMS.Bootstrap;
+using MMS.Features;
namespace MMS;
///
-/// Main class for the MatchMaking Server.
+/// Entry point and composition root for the MatchMaking Server.
///
// ReSharper disable once ClassNeverInstantiated.Global
public class Program {
///
- /// Whether we are running a development environment.
- ///
- internal static bool IsDevelopment { get; private set; }
-
- ///
- /// The logger for logging information to the console.
- ///
- private static ILogger Logger { get; set; } = null!;
-
- ///
- /// Entrypoint for the MMS.
+ /// Application entry point.
///
+ /// Command-line arguments.
public static void Main(string[] args) {
var builder = WebApplication.CreateBuilder(args);
+ var isDevelopment = builder.Environment.IsDevelopment();
+ using var startupLoggerFactory = CreateStartupLoggerFactory();
- IsDevelopment = builder.Environment.IsDevelopment();
+ ProgramState.IsDevelopment = isDevelopment;
+ ProgramState.Logger = startupLoggerFactory.CreateLogger(nameof(Program));
- builder.Logging.ClearProviders();
- builder.Logging.AddSimpleConsole(options => {
- options.SingleLine = true;
- options.IncludeScopes = false;
- options.TimestampFormat = "HH:mm:ss ";
- }
- );
+ builder.Services.AddMmsCoreServices();
+ builder.Services.AddMmsInfrastructure(builder.Configuration, isDevelopment);
- builder.Services.AddSingleton();
- builder.Services.AddSingleton();
- builder.Services.AddHostedService();
- builder.Services.AddHostedService();
-
- builder.Services.Configure(options => {
- options.ForwardedHeaders =
- ForwardedHeaders.XForwardedFor |
- ForwardedHeaders.XForwardedHost |
- ForwardedHeaders.XForwardedProto;
- }
- );
-
- if (IsDevelopment) {
- builder.Services.AddHttpLogging(_ => { });
- } else {
- if (!ConfigureHttpsCertificate(builder)) {
- return;
- }
- }
-
- builder.Services.AddRateLimiter(options => {
- options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
- options.OnRejected = async (context, token) => {
- // The current client treats 429s as generic failures.
- // Keep this response stable until the client adds explicit rate-limit handling.
- await context.HttpContext.Response.WriteAsJsonAsync(
- new ErrorResponse("Too many requests. Please try again later."), cancellationToken: token
- );
- };
-
- options.AddPolicy(
- "create", context =>
- RateLimitPartition.GetFixedWindowLimiter(
- partitionKey: context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
- factory: _ => new FixedWindowRateLimiterOptions {
- PermitLimit = 5,
- Window = TimeSpan.FromSeconds(30),
- QueueLimit = 0
- }
- )
- );
-
- options.AddPolicy(
- "search", context =>
- RateLimitPartition.GetFixedWindowLimiter(
- partitionKey: context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
- factory: _ => new FixedWindowRateLimiterOptions {
- PermitLimit = 10,
- Window = TimeSpan.FromSeconds(10),
- QueueLimit = 0
- }
- )
- );
-
- options.AddPolicy(
- "join", context =>
- RateLimitPartition.GetFixedWindowLimiter(
- partitionKey: context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
- factory: _ => new FixedWindowRateLimiterOptions {
- PermitLimit = 5,
- Window = TimeSpan.FromSeconds(30),
- QueueLimit = 0
- }
- )
- );
+ if (!builder.TryConfigureMmsHttps(isDevelopment)) {
+ ProgramState.Logger.LogCritical("MMS HTTPS configuration failed, exiting");
+ return;
}
- );
var app = builder.Build();
+ ProgramState.Logger = app.Logger;
- Logger = app.Logger;
-
- if (IsDevelopment) {
- app.UseHttpLogging();
- } else {
- app.UseExceptionHandler("/error");
- }
-
- app.UseForwardedHeaders();
- app.UseRateLimiter();
- app.UseWebSockets();
- MapEndpoints(app);
- app.Urls.Add(IsDevelopment ? "http://0.0.0.0:5000" : "https://0.0.0.0:5000");
+ app.UseMmsPipeline(isDevelopment);
+ app.MapMmsEndpoints();
app.Run();
}
- #region Web Application Initialization
-
- ///
- /// Tries to configure HTTPS by reading an SSL certificate and enabling HTTPS when the web application is built.
- ///
- /// The web application builder.
- /// True if the certificate could be read, false otherwise.
- private static bool ConfigureHttpsCertificate(WebApplicationBuilder builder) {
- if (!File.Exists("cert.pem")) {
- Console.WriteLine("Certificate file 'cert.pem' does not exist");
- return false;
- }
-
- if (!File.Exists("key.pem")) {
- Console.WriteLine("Certificate key file 'key.pem' does not exist");
- return false;
- }
-
- string pem;
- string key;
- try {
- pem = File.ReadAllText("cert.pem");
- key = File.ReadAllText("key.pem");
- } catch (Exception e) {
- Console.WriteLine($"Could not read either 'cert.pem' or 'key.pem':\n{e}");
- return false;
- }
-
- X509Certificate2 x509;
- try {
- x509 = X509Certificate2.CreateFromPem(pem, key);
- } catch (CryptographicException e) {
- Console.WriteLine($"Could not create certificate object from pem files:\n{e}");
- return false;
- }
-
- builder.WebHost.ConfigureKestrel(s => {
- s.ListenAnyIP(
- 5000, options => { options.UseHttps(x509); }
- );
- }
- );
-
- return true;
- }
-
- #endregion
-
- #region Endpoint Registration
-
///
- /// Registers all API endpoints for the MatchMaking Server.
+ /// Creates the temporary logger factory used before the ASP.NET Core host logger is available.
///
- private static void MapEndpoints(WebApplication app) {
- var lobbyService = app.Services.GetRequiredService();
-
- // Health & Monitoring
- app.MapGet("/", () => Results.Ok(new { service = "MMS", version = "1.0", status = "healthy" }))
- .WithName("HealthCheck");
- app.MapGet("/lobbies", GetLobbies).WithName("ListLobbies").RequireRateLimiting("search");
-
- // Lobby Management
- app.MapPost("/lobby", CreateLobby).WithName("CreateLobby").RequireRateLimiting("create");
- app.MapDelete("/lobby/{token}", CloseLobby).WithName("CloseLobby");
-
- // Host Operations
- app.MapPost("/lobby/heartbeat/{token}", Heartbeat).WithName("Heartbeat");
- app.MapGet("/lobby/pending/{token}", GetPendingClients).WithName("GetPendingClients");
- app.MapPost("/lobby/discovery/verify/{token}", VerifyDiscovery).WithName("VerifyDiscovery");
-
- // WebSocket for host push notifications
- app.Map(
- "/ws/{token}", async (HttpContext context, string token) => {
- if (!context.WebSockets.IsWebSocketRequest) {
- context.Response.StatusCode = 400;
- return;
+ /// A simple console logger factory for early startup diagnostics.
+ private static ILoggerFactory CreateStartupLoggerFactory() {
+ return LoggerFactory.Create(logging => logging.AddSimpleConsole(options => {
+ options.SingleLine = true;
+ options.IncludeScopes = false;
+ options.TimestampFormat = "HH:mm:ss ";
}
-
- var lobby = lobbyService.GetLobbyByToken(token);
- if (lobby == null) {
- context.Response.StatusCode = 404;
- return;
- }
-
- using var webSocket = await context.WebSockets.AcceptWebSocketAsync();
- lobby.HostWebSocket = webSocket;
-
- Logger.LogInformation(
- "[WS] Host connected for lobby {LobbyIdentifier}",
- IsDevelopment ? lobby.ConnectionData : lobby.LobbyName
- );
-
- // Keep connection alive until closed
- var buffer = new byte[1024];
- try {
- while (webSocket.State == WebSocketState.Open) {
- var result = await webSocket.ReceiveAsync(buffer, CancellationToken.None);
- if (result.MessageType == WebSocketMessageType.Close) break;
- }
- } catch (WebSocketException) {
- // Host disconnected without proper close handshake (normal during game exit)
- } catch (Exception ex) when (ex.InnerException is System.Net.Sockets.SocketException) {
- // Connection forcibly reset (normal during game exit)
- } finally {
- lobby.HostWebSocket = null;
- Logger.LogInformation(
- "[WS] Host disconnected from lobby {LobbyIdentifier}",
- IsDevelopment ? lobby.ConnectionData : lobby.LobbyName
- );
- }
- }
- );
-
- // Client Operations
- app.MapPost("/lobby/{connectionData}/join", JoinLobby).WithName("JoinLobby").RequireRateLimiting("join");
- }
-
- #endregion
-
- #region Endpoint Handlers
-
- ///
- /// Returns all lobbies, optionally filtered by type.
- ///
- private static Ok> GetLobbies(LobbyService lobbyService, string? type = null) {
- var lobbies = lobbyService.GetLobbies(type)
- .Select(l => new LobbyResponse(
- l.AdvertisedConnectionData,
- l.LobbyName,
- l.LobbyType,
- l.LobbyCode
- )
- );
- return TypedResults.Ok(lobbies);
- }
-
- ///
- /// Creates a new lobby (Steam or Matchmaking).
- ///
- private static Results, BadRequest> CreateLobby(
- CreateLobbyRequest request,
- LobbyService lobbyService,
- LobbyNameService lobbyNameService,
- HttpContext context
- ) {
- var lobbyType = request.LobbyType ?? "matchmaking";
- string connectionData;
-
- if (lobbyType == "steam") {
- if (string.IsNullOrEmpty(request.ConnectionData)) {
- return TypedResults.BadRequest(new ErrorResponse("Steam lobby requires ConnectionData"));
- }
-
- connectionData = request.ConnectionData;
- } else {
- var rawHostIp = request.HostIp ?? context.Connection.RemoteIpAddress?.ToString();
- if (string.IsNullOrEmpty(rawHostIp) || !IPAddress.TryParse(rawHostIp, out var parsedHostIp)) {
- return TypedResults.BadRequest(new ErrorResponse("Invalid IP address"));
- }
-
- var hostIp = parsedHostIp.ToString();
- if (request.HostPort is null or <= 0 or > 65535) {
- return TypedResults.BadRequest(new ErrorResponse("Invalid port number"));
- }
-
- connectionData = $"{hostIp}:{request.HostPort}";
- }
-
- var lobbyName = lobbyNameService.GenerateLobbyName();
-
- var lobby = lobbyService.CreateLobby(
- connectionData,
- lobbyName,
- lobbyType,
- request.HostLanIp,
- request.IsPublic ?? true
- );
-
- var visibility = lobby.IsPublic ? "Public" : "Private";
- var connectionDataString = IsDevelopment ? lobby.AdvertisedConnectionData : "[Redacted]";
- Logger.LogInformation(
- "[LOBBY] Created: '{LobbyName}' [{LobbyType}] ({Visibility}) -> {ConnectionDataString} (Code: {LobbyCode})",
- lobby.LobbyName,
- lobby.LobbyType,
- visibility,
- connectionDataString,
- lobby.LobbyCode
- );
-
- return TypedResults.Created(
- $"/lobby/{lobby.LobbyCode}",
- new CreateLobbyResponse(
- lobby.AdvertisedConnectionData, lobby.HostToken, lobby.LobbyName, lobby.LobbyCode, lobby.HostDiscoveryToken
- )
- );
- }
-
- ///
- /// Returns the discovered external port when ready.
- /// Joining clients do not maintain a WebSocket to MMS, so they poll this endpoint
- /// until discovery completes.
- /// Once discovery succeeds, the host is notified via WebSocket if one is connected.
- ///
- private static async Task VerifyDiscovery(
- string token,
- LobbyService lobbyService,
- CancellationToken cancellationToken = default
- ) {
- var port = lobbyService.GetDiscoveredPort(token);
- if (port is null) return TypedResults.Ok(new StatusResponse("pending"));
-
- await TryNotifyHostAsync(token, port.Value, lobbyService, cancellationToken);
- lobbyService.ApplyHostPort(token, port.Value);
- lobbyService.RemoveDiscoveryToken(token);
- return TypedResults.Ok(new DiscoveryResponse(port.Value));
- }
-
- ///
- /// If the token belongs to a client, pushes their external endpoint to the host via WebSocket.
- /// Silently skips if the lobby or WebSocket is unavailable.
- ///
- private static async Task TryNotifyHostAsync(
- string token,
- int port,
- LobbyService lobbyService,
- CancellationToken cancellationToken
- ) {
- if (!lobbyService.TryGetClientInfo(token, out var lobbyCode, out var clientIp))
- return;
-
- var lobby = lobbyService.GetLobbyByCode(lobbyCode);
-
- if (lobby?.HostWebSocket is not { State: WebSocketState.Open } ws)
- return;
-
- var payload = JsonSerializer.SerializeToUtf8Bytes(
- new {
- clientIp,
- clientPort = port
- }
- );
-
- await ws.SendAsync(payload, WebSocketMessageType.Text, endOfMessage: true, cancellationToken);
-
- Logger.LogInformation(
- "Pushed client {ClientIp}:{ClientPort} to host via WebSocket after discovery",
- clientIp,
- port
- );
- }
-
- ///
- /// Closes a lobby by host token.
- ///
- private static Results> CloseLobby(string token, LobbyService lobbyService) {
- if (!lobbyService.RemoveLobbyByToken(token)) {
- return TypedResults.NotFound(new ErrorResponse("Lobby not found"));
- }
-
- Logger.LogInformation("[LOBBY] Closed by host");
- return TypedResults.NoContent();
- }
-
- ///
- /// Refreshes lobby heartbeat to prevent expiration.
- ///
- private static Results, NotFound> Heartbeat(
- string token,
- LobbyService lobbyService
- ) {
- return lobbyService.Heartbeat(token)
- ? TypedResults.Ok(new StatusResponse("alive"))
- : TypedResults.NotFound(new ErrorResponse("Lobby not found"));
- }
-
- ///
- /// Returns pending clients waiting for NAT hole-punch (clears the queue).
- ///
- private static Results>, NotFound> GetPendingClients(
- string token,
- LobbyService lobbyService
- ) {
- var lobby = lobbyService.GetLobbyByToken(token);
- if (lobby == null) {
- return TypedResults.NotFound(new ErrorResponse("Lobby not found"));
- }
-
- var pending = new List();
- var cutoff = DateTime.UtcNow.AddSeconds(-30);
-
- while (lobby.PendingClients.TryDequeue(out var client)) {
- if (client.RequestedAt >= cutoff) {
- pending.Add(new PendingClientResponse(client.ClientIp, client.ClientPort));
- }
- }
-
- return TypedResults.Ok(pending);
- }
-
- ///
- /// Notifies host of pending client and returns host connection info.
- /// Uses WebSocket push if available, otherwise queues for polling.
- ///
- private static Results, NotFound> JoinLobby(
- string connectionData,
- JoinLobbyRequest request,
- LobbyService lobbyService,
- HttpContext context
- ) {
- // Try as lobby code first, then as connectionData
- var lobby = lobbyService.GetLobbyByCode(connectionData) ?? lobbyService.GetLobby(connectionData);
- if (lobby == null) {
- return TypedResults.NotFound(new ErrorResponse("Lobby not found"));
- }
-
- var rawClientIp = request.ClientIp ?? context.Connection.RemoteIpAddress?.ToString();
- if (string.IsNullOrEmpty(rawClientIp) || !IPAddress.TryParse(rawClientIp, out var parsedIp)) {
- return TypedResults.NotFound(new ErrorResponse("Invalid IP address"));
- }
-
- var clientIp = parsedIp.ToString();
-
- if (request.ClientPort is <= 0 or > 65535) {
- return TypedResults.NotFound(new ErrorResponse("Invalid port"));
- }
-
- var clientDiscoveryToken = lobbyService.RegisterClientDiscoveryToken(lobby.LobbyCode, clientIp);
-
- Logger.LogInformation(
- "[JOIN] {ConnectionDetails}",
- IsDevelopment
- ? $"{clientIp}:{request.ClientPort} -> {lobby.AdvertisedConnectionData}"
- : $"[Redacted]:{request.ClientPort} -> [Redacted]"
- );
-
- /* Host notification is delayed until VerifyDisco very returns the NAT-mapped port. */
- /* The host is then notified over WebSocket with the externally visible endpoint. */
-
- // Fallback to queue for polling (legacy clients)
- lobby.PendingClients.Enqueue(
- new Models.PendingClient(clientIp, request.ClientPort, DateTime.UtcNow)
- );
-
- // Check if client is on the same network as the host
- var joinConnectionData = lobby.AdvertisedConnectionData;
- string? lanConnectionData = null;
-
- // We can only check IP equality if we have the host's IP (for matchmaking lobbies mainly)
- // NOTE: This assumes lobby.ConnectionData is in "IP:Port" format for matchmaking
- if (string.IsNullOrEmpty(lobby.HostLanIp)) {
- return TypedResults.Ok(
- new JoinResponse(
- joinConnectionData, lobby.LobbyType, clientIp, request.ClientPort, lanConnectionData,
- clientDiscoveryToken
- )
- );
- }
-
- // Parse Host Public IP from ConnectionData (format: "IP:Port")
- var hostPublicIp = lobby.ConnectionData.Split(':')[0];
-
- if (clientIp != hostPublicIp) {
- return TypedResults.Ok(
- new JoinResponse(
- joinConnectionData, lobby.LobbyType, clientIp, request.ClientPort, lanConnectionData,
- clientDiscoveryToken
- )
- );
- }
-
- Logger.LogInformation("[JOIN] Local Network Detected! Returning LAN IP: {HostLanIp}", lobby.HostLanIp);
- lanConnectionData = lobby.HostLanIp;
-
- return TypedResults.Ok(
- new JoinResponse(
- joinConnectionData, lobby.LobbyType, clientIp, request.ClientPort, lanConnectionData,
- clientDiscoveryToken
)
);
}
-
- #endregion
-
- #region DTOs
-
- /// Host IP (Matchmaking only, optional).
- /// Host port (Matchmaking only).
- /// Steam lobby ID (Steam only).
- /// "steam" or "matchmaking" (default: matchmaking).
- /// Host LAN IP for local network discovery.
- /// Whether lobby appears in browser (default: true).
- [UsedImplicitly]
- private record CreateLobbyRequest(
- string? HostIp,
- int? HostPort,
- string? ConnectionData,
- string? LobbyType,
- string? HostLanIp,
- bool? IsPublic
- );
-
- /// Connection identifier (IP:Port or Steam lobby ID).
- /// Secret token for host operations.
- /// Name for the lobby.
- /// Human-readable invite code.
- /// Discovery token used to confirm the host's external UDP port.
- [UsedImplicitly]
- internal record CreateLobbyResponse(
- string ConnectionData,
- string HostToken,
- string LobbyName,
- string LobbyCode,
- string? HostDiscoveryToken
- );
-
- /// Connection identifier (IP:Port or Steam lobby ID).
- /// Display name.
- /// "steam" or "matchmaking".
- /// Human-readable invite code.
- [UsedImplicitly]
- internal record LobbyResponse(
- string ConnectionData,
- string Name,
- string LobbyType,
- string LobbyCode
- );
-
- /// Client IP (optional - uses connection IP if null).
- /// Client's local port for hole-punching.
- [UsedImplicitly]
- internal record JoinLobbyRequest(string? ClientIp, int ClientPort);
-
- /// Host connection data (IP:Port or Steam lobby ID).
- /// "steam" or "matchmaking".
- /// Client's public IP as seen by MMS.
- /// Client's public port.
- /// Host's LAN connection data in case LAN is detected.
- [UsedImplicitly]
- internal record JoinResponse(
- string ConnectionData,
- string LobbyType,
- string ClientIp,
- int ClientPort,
- string? LanConnectionData,
- string? ClientDiscoveryToken
- );
-
- /// Discovered external port.
- [UsedImplicitly]
- internal record DiscoveryResponse(int ExternalPort);
-
- /// Pending client's IP.
- /// Pending client's port.
- [UsedImplicitly]
- internal record PendingClientResponse(string ClientIp, int ClientPort);
-
- /// Error message.
- [UsedImplicitly]
- internal record ErrorResponse(string Error);
-
- /// Status message.
- [UsedImplicitly]
- internal record StatusResponse(string Status);
-
- #endregion
}
diff --git a/MMS/Services/Lobbies/LobbyCleanupService.cs b/MMS/Services/Lobbies/LobbyCleanupService.cs
new file mode 100644
index 0000000..c5bfadd
--- /dev/null
+++ b/MMS/Services/Lobbies/LobbyCleanupService.cs
@@ -0,0 +1,36 @@
+namespace MMS.Services.Lobbies;
+
+using Matchmaking;
+
+/// Background service that removes expired lobbies and matchmaking sessions every 30 seconds.
+public class LobbyCleanupService(
+ LobbyService lobbyService,
+ JoinSessionService joinSessionService,
+ ILogger logger
+) : BackgroundService {
+ /// How often the cleanup pass runs.
+ private static readonly TimeSpan CleanupInterval = TimeSpan.FromSeconds(30);
+
+ ///
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken) {
+ while (!stoppingToken.IsCancellationRequested) {
+ try {
+ await Task.Delay(CleanupInterval, stoppingToken);
+
+ var removedLobbies = lobbyService.CleanupDeadLobbies(joinSessionService.CleanupSessionsForLobby);
+ var removedSessions = joinSessionService.CleanupExpiredSessions();
+
+ if (removedLobbies > 0 || removedSessions > 0) {
+ logger.LogInformation(
+ "Cleanup removed {Lobbies} expired lobbies and {Sessions} orphaned sessions", removedLobbies,
+ removedSessions
+ );
+ }
+ } catch (OperationCanceledException) {
+ return;
+ } catch (Exception ex) {
+ logger.LogError(ex, "Lobby cleanup iteration failed");
+ }
+ }
+ }
+}
diff --git a/MMS/Services/LobbyNameService.cs b/MMS/Services/Lobbies/LobbyNameService.cs
similarity index 84%
rename from MMS/Services/LobbyNameService.cs
rename to MMS/Services/Lobbies/LobbyNameService.cs
index fe9f576..adc26ed 100644
--- a/MMS/Services/LobbyNameService.cs
+++ b/MMS/Services/Lobbies/LobbyNameService.cs
@@ -4,7 +4,7 @@
using System.Runtime.Serialization;
using System.Text.Json;
-namespace MMS.Services;
+namespace MMS.Services.Lobbies;
///
/// Lobby name providing service that randomly generates lobby names from words in an embedded JSON.
@@ -32,21 +32,24 @@ public class LobbyNameService {
///
private readonly Random _random = new();
+ ///
+ /// Initializes a new instance of the class.
+ /// Loads the lobby name data from the embedded JSON resource.
+ ///
+ /// Thrown when the embedded JSON resource cannot be found.
+ /// Thrown when the JSON resource is malformed or empty.
public LobbyNameService() {
var resourceStream = Assembly.GetExecutingAssembly().GetManifestResourceStream(LobbyNameDataFilePath);
if (resourceStream == null) {
- throw new MissingManifestResourceException("Could not load lobby name data from embedded resource");
+ throw new MissingManifestResourceException($"Could not load lobby name data from embedded resource: {LobbyNameDataFilePath}");
}
using var streamReader = new StreamReader(resourceStream);
var fileString = streamReader.ReadToEnd();
var data = JsonSerializer.Deserialize(fileString);
- if (data == null) {
- throw new SerializationException("Could not deserialize lobby name data from embedded resource");
- }
- _lobbyNameData = data;
+ _lobbyNameData = data ?? throw new SerializationException("Could not deserialize lobby name data from embedded resource");
}
///
diff --git a/MMS/Services/Lobbies/LobbyService.cs b/MMS/Services/Lobbies/LobbyService.cs
new file mode 100644
index 0000000..0ae12e7
--- /dev/null
+++ b/MMS/Services/Lobbies/LobbyService.cs
@@ -0,0 +1,268 @@
+using System.Collections.Concurrent;
+using MMS.Bootstrap;
+using MMS.Models.Lobbies;
+using MMS.Services.Matchmaking;
+using MMS.Services.Utility;
+
+namespace MMS.Services.Lobbies;
+
+///
+/// Thread-safe in-memory lobby store.
+/// Handles lobby creation, lookup, heartbeat, and expiry sweeps.
+/// NAT hole-punch coordination and join session management are delegated to
+/// .
+///
+public class LobbyService {
+ ///
+ /// The backing store for all active lobbies, keyed by their unique connection data.
+ ///
+ private readonly ConcurrentDictionary _lobbies = new();
+
+ ///
+ /// Map for looking up lobbies by their host authentication token.
+ ///
+ private readonly ConcurrentDictionary _tokenToConnectionData = new();
+
+ ///
+ /// Map for looking up lobbies by their short player-facing join code.
+ ///
+ private readonly ConcurrentDictionary _codeToConnectionData = new();
+
+ ///
+ /// Thread synchronization primitive for atomic lobby creation and cleanup.
+ ///
+ private readonly Lock _createLobbyLock = new();
+
+ ///
+ /// Service used to generate random human-readable names for new lobbies.
+ ///
+ private readonly LobbyNameService _lobbyNameService;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Service used to generate random human-readable names for new lobbies.
+ public LobbyService(LobbyNameService lobbyNameService) {
+ _lobbyNameService = lobbyNameService;
+ }
+
+ ///
+ /// Creates and stores a new lobby.
+ ///
+ ///
+ /// The unique connection identifier for this lobby (e.g. ip:port for matchmaking,
+ /// Steam lobby ID for Steam lobbies).
+ ///
+ /// Human-readable display name assigned to this lobby.
+ ///
+ /// Lobby transport type. Accepted values: "matchmaking" (default), "steam".
+ ///
+ /// Optional LAN address of the host, used for same-network fast-path.
+ /// Whether the lobby appears in the public browser.
+ /// The newly created instance.
+ public Lobby CreateLobby(
+ string connectionData,
+ string lobbyName,
+ string lobbyType = "matchmaking",
+ string? hostLanIp = null,
+ bool isPublic = true
+ ) {
+ if (!IsMatchmakingLobbyType(lobbyType) && !IsSteamLobby(lobbyType))
+ throw new ArgumentOutOfRangeException(
+ nameof(lobbyType), lobbyType, "Lobby type must be 'matchmaking' or 'steam'."
+ );
+
+ var hostToken = TokenGenerator.GenerateToken(32);
+ var hostDiscoveryToken = IsMatchmakingLobbyType(lobbyType) ? TokenGenerator.GenerateToken(32) : null;
+ lock (_createLobbyLock) {
+ if (_lobbies.TryGetValue(connectionData, out var existingLobby)) {
+ RemoveLobbyIndexes(existingLobby);
+ if (!string.IsNullOrEmpty(existingLobby.LobbyName))
+ _lobbyNameService.FreeLobbyName(existingLobby.LobbyName);
+ }
+
+ var lobbyCode = IsSteamLobby(lobbyType)
+ ? ""
+ : ReserveLobbyCode(connectionData);
+
+ var lobby = new Lobby(
+ connectionData,
+ hostToken,
+ lobbyCode,
+ lobbyName,
+ lobbyType,
+ hostLanIp,
+ isPublic,
+ hostDiscoveryToken
+ );
+
+ _lobbies[connectionData] = lobby;
+ _tokenToConnectionData[hostToken] = connectionData;
+ return lobby;
+ }
+ }
+
+ ///
+ /// Returns the lobby for , or if absent or expired.
+ /// Expired lobbies are removed lazily on access.
+ ///
+ /// The connection identifier the lobby was registered under.
+ public Lobby? GetLobby(string connectionData) {
+ if (!_lobbies.TryGetValue(connectionData, out var lobby)) return null;
+ if (!lobby.IsDead) return lobby;
+
+ RemoveLobby(lobby);
+ return null;
+ }
+
+ ///
+ /// Returns the lobby owned by , or if absent or expired.
+ ///
+ /// The host authentication token issued at lobby creation.
+ public Lobby? GetLobbyByToken(string token) {
+ if (!_tokenToConnectionData.TryGetValue(token, out var connData))
+ return null;
+
+ var lobby = GetLobby(connData);
+ return lobby is not null && lobby.HostToken == token ? lobby : null;
+ }
+
+ ///
+ /// Returns the lobby identified by , or if absent or expired.
+ /// The lookup is case-insensitive.
+ ///
+ /// The player-facing lobby code (e.g. ABC123).
+ public Lobby? GetLobbyByCode(string code) {
+ var normalizedCode = code.ToUpperInvariant();
+ if (!_codeToConnectionData.TryGetValue(normalizedCode, out var connData)) return null;
+
+ var lobby = GetLobby(connData);
+ return (lobby is not null && string.Equals(lobby.LobbyCode, normalizedCode, StringComparison.Ordinal))
+ ? lobby
+ : null;
+ }
+
+ ///
+ /// Returns all active public lobbies, optionally filtered by .
+ ///
+ ///
+ /// Optional case-insensitive filter (e.g. "matchmaking" or "steam").
+ /// Pass to return all types.
+ ///
+ public IEnumerable GetLobbies(string? lobbyType = null) {
+ var active = _lobbies.Values.Where(l => l is { IsDead: false, IsPublic: true });
+ return string.IsNullOrEmpty(lobbyType)
+ ? active
+ : active.Where(l => l.LobbyType.Equals(lobbyType, StringComparison.OrdinalIgnoreCase));
+ }
+
+ ///
+ /// Records a heartbeat for the lobby owned by and updates the
+ /// connected-player count.
+ ///
+ ///
+ /// When drops to zero on a matchmaking lobby,
+ /// is cleared so that stale NAT mappings are not
+ /// reused for subsequent joins.
+ ///
+ /// The host authentication token.
+ /// Current number of remote players connected to the host.
+ /// if the lobby was found and updated; otherwise.
+ public bool Heartbeat(string token, int connectedPlayers) {
+ if (connectedPlayers < 0)
+ return false;
+
+ var lobby = GetLobbyByToken(token);
+ if (lobby == null) return false;
+
+ lobby.LastHeartbeat = DateTime.UtcNow;
+
+ if (IsMatchmakingLobby(lobby) && connectedPlayers == 0)
+ lobby.ExternalPort = null;
+
+ return true;
+ }
+
+ ///
+ /// Removes the lobby owned by from all indexes.
+ ///
+ /// The host authentication token issued at lobby creation.
+ /// Optional callback invoked with the lobby instance before it is removed from all indexes.
+ /// if the lobby was found and removed; otherwise.
+ public bool RemoveLobbyByToken(string token, Action? onRemoving = null) {
+ var lobby = GetLobbyByToken(token);
+ return lobby != null && RemoveLobby(lobby, onRemoving);
+ }
+
+ ///
+ /// Removes all lobbies whose flag is set.
+ ///
+ /// Optional callback invoked with each lobby instance before it is removed.
+ /// The number of lobbies removed.
+ public int CleanupDeadLobbies(Action? onRemoving = null) {
+ var dead = _lobbies.Values.Where(l => l.IsDead).ToList();
+ return dead.Count(lobby => RemoveLobby(lobby, onRemoving));
+ }
+
+ ///
+ /// Removes a lobby from all indexes and releases its name back to .
+ ///
+ /// The specific lobby instance to remove.
+ /// Optional callback invoked with the lobby instance before it is removed from all indexes.
+ /// if the lobby was not found (already removed).
+ private bool RemoveLobby(Lobby lobby, Action? onRemoving = null) {
+ if (!_lobbies.TryRemove(new KeyValuePair(lobby.ConnectionData, lobby)))
+ return false;
+
+ try {
+ onRemoving?.Invoke(lobby);
+ } catch (Exception ex) {
+ ProgramState.Logger.LogWarning(
+ ex, "Lobby removal callback failed for {ConnectionData}", PrivacyFormatter.Format(lobby.ConnectionData)
+ );
+ } finally {
+ _tokenToConnectionData.TryRemove(lobby.HostToken, out _);
+ _codeToConnectionData.TryRemove(lobby.LobbyCode, out _);
+ _lobbyNameService.FreeLobbyName(lobby.LobbyName);
+ }
+
+ return true;
+ }
+
+ /// Returns if is "steam".
+ private static bool IsSteamLobby(string lobbyType) =>
+ lobbyType.Equals("steam", StringComparison.OrdinalIgnoreCase);
+
+ ///
+ /// Reserves a unique lobby code and associates it with the given connection data.
+ /// Retries until a code is successfully inserted into the concurrent store.
+ ///
+ /// The connection data to associate with the reserved code.
+ /// The reserved lobby code.
+ private string ReserveLobbyCode(string connectionData) {
+ while (true) {
+ var code = TokenGenerator.GenerateUniqueLobbyCode(new HashSet(_codeToConnectionData.Keys));
+ if (_codeToConnectionData.TryAdd(code, connectionData))
+ return code;
+ }
+ }
+
+ ///
+ /// Removes all index entries associated with a lobby, including its host token
+ /// and lobby code if one was assigned.
+ ///
+ /// The lobby whose indexes should be removed.
+ private void RemoveLobbyIndexes(Lobby lobby) {
+ _tokenToConnectionData.TryRemove(lobby.HostToken, out _);
+ if (!string.IsNullOrEmpty(lobby.LobbyCode))
+ _codeToConnectionData.TryRemove(lobby.LobbyCode, out _);
+ }
+
+ /// Returns if is "matchmaking".
+ private static bool IsMatchmakingLobbyType(string lobbyType) =>
+ lobbyType.Equals("matchmaking", StringComparison.OrdinalIgnoreCase);
+
+ /// Returns if is a matchmaking lobby.
+ private static bool IsMatchmakingLobby(Lobby lobby) =>
+ IsMatchmakingLobbyType(lobby.LobbyType);
+}
diff --git a/MMS/Services/LobbyCleanupService.cs b/MMS/Services/LobbyCleanupService.cs
deleted file mode 100644
index 0fcc8d3..0000000
--- a/MMS/Services/LobbyCleanupService.cs
+++ /dev/null
@@ -1,17 +0,0 @@
-namespace MMS.Services;
-
-/// Background service that removes expired lobbies every 30 seconds.
-public class LobbyCleanupService(LobbyService lobbyService) : BackgroundService {
- protected override async Task ExecuteAsync(CancellationToken stoppingToken) {
- Console.WriteLine("[CLEANUP] Service started");
-
- while (!stoppingToken.IsCancellationRequested) {
- await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
-
- var removed = lobbyService.CleanupDeadLobbies();
- if (removed > 0) {
- Console.WriteLine($"[CLEANUP] Removed {removed} expired lobbies");
- }
- }
- }
-}
diff --git a/MMS/Services/LobbyService.cs b/MMS/Services/LobbyService.cs
deleted file mode 100644
index 08d8276..0000000
--- a/MMS/Services/LobbyService.cs
+++ /dev/null
@@ -1,264 +0,0 @@
-using System.Collections.Concurrent;
-using MMS.Models;
-
-namespace MMS.Services;
-
-///
-/// Thread-safe in-memory lobby storage with heartbeat-based expiration.
-/// Lobbies are keyed by ConnectionData (Steam ID or IP:Port).
-///
-public class LobbyService(LobbyNameService lobbyNameService) {
- /// Thread-safe dictionary of lobbies keyed by ConnectionData.
- private readonly ConcurrentDictionary _lobbies = new();
-
- /// Maps host tokens to ConnectionData for quick lookup.
- private readonly ConcurrentDictionary _tokenToConnectionData = new();
-
- /// Maps lobby codes to ConnectionData for quick lookup.
- private readonly ConcurrentDictionary _codeToConnectionData = new();
-
- /// Consolidated metadata for active discovery sessions (matchmaking only).
- private readonly ConcurrentDictionary _discoveryMetadata = new();
-
- /// Random number generator for token and code generation.
- private static readonly Random Random = new();
-
- /// Characters used for host authentication tokens (lowercase alphanumeric).
- private const string TokenChars = "abcdefghijklmnopqrstuvwxyz0123456789";
-
- /// Characters used for lobby codes (uppercase alphanumeric).
- private const string LobbyCodeChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
-
- /// Length of generated lobby codes.
- private const int LobbyCodeLength = 6;
-
- ///
- /// Creates a new lobby keyed by ConnectionData.
- ///
- public Lobby CreateLobby(
- string connectionData,
- string lobbyName,
- string lobbyType = "matchmaking",
- string? hostLanIp = null,
- bool isPublic = true
- ) {
- var hostToken = GenerateToken(32);
-
- // Only generate lobby codes for matchmaking lobbies
- // Steam lobbies use Steam's native join flow (no MMS invite codes)
- var lobbyCode = lobbyType == "steam" ? "" : GenerateLobbyCode();
- // Matchmaking lobbies use a discovery token for NAT traversal; Steam lobbies do not.
- string? hostDiscoveryToken = null;
- if (lobbyType == "matchmaking") {
- hostDiscoveryToken = GenerateToken(32);
- _discoveryMetadata[hostDiscoveryToken] = new DiscoveryTokenMetadata {
- HostConnectionData = connectionData
- };
- }
-
- var lobby = new Lobby(connectionData, hostToken, lobbyCode, lobbyName, lobbyType, hostLanIp, isPublic) {
- HostDiscoveryToken = hostDiscoveryToken
- };
-
- _lobbies[connectionData] = lobby;
- _tokenToConnectionData[hostToken] = connectionData;
-
- // Only register code if we generated one
- if (!string.IsNullOrEmpty(lobbyCode)) {
- _codeToConnectionData[lobbyCode] = connectionData;
- }
-
- return lobby;
- }
-
- ///
- /// Gets lobby by ConnectionData. Returns null if not found or expired.
- ///
- public Lobby? GetLobby(string connectionData) {
- if (!_lobbies.TryGetValue(connectionData, out var lobby)) return null;
- if (!lobby.IsDead) return lobby;
-
- RemoveLobby(connectionData);
- return null;
- }
-
- ///
- /// Gets lobby by host token. Returns null if not found or expired.
- ///
- public Lobby? GetLobbyByToken(string token) {
- return _tokenToConnectionData.TryGetValue(token, out var connData) ? GetLobby(connData) : null;
- }
-
- ///
- /// Gets lobby by lobby code. Returns null if not found or expired.
- ///
- public Lobby? GetLobbyByCode(string code) {
- // Normalize to uppercase for case-insensitive matching
- var normalizedCode = code.ToUpperInvariant();
- return _codeToConnectionData.TryGetValue(normalizedCode, out var connData) ? GetLobby(connData) : null;
- }
-
- ///
- /// Refreshes lobby heartbeat. Returns false if lobby not found.
- ///
- public bool Heartbeat(string token) {
- var lobby = GetLobbyByToken(token);
- if (lobby == null) return false;
-
- lobby.LastHeartbeat = DateTime.UtcNow;
- return true;
- }
-
- ///
- /// Removes lobby by host token. Returns false if not found.
- ///
- public bool RemoveLobbyByToken(string token) {
- var lobby = GetLobbyByToken(token);
- return lobby != null && RemoveLobby(lobby.ConnectionData);
- }
-
- ///
- /// Returns all active (non-expired) lobbies.
- ///
- public IEnumerable GetAllLobbies() => _lobbies.Values.Where(l => !l.IsDead);
-
- ///
- /// Returns active PUBLIC lobbies, optionally filtered by type ("steam" or "matchmaking").
- /// Private lobbies are excluded from browser listings.
- ///
- public IEnumerable GetLobbies(string? lobbyType = null) {
- var lobbies = _lobbies.Values.Where(l => l is { IsDead: false, IsPublic: true });
- return string.IsNullOrEmpty(lobbyType)
- ? lobbies
- : lobbies.Where(l => l.LobbyType.Equals(lobbyType, StringComparison.OrdinalIgnoreCase));
- }
-
- ///
- /// Removes all expired lobbies. Returns count removed.
- ///
- public int CleanupDeadLobbies() {
- var dead = _lobbies.Values.Where(l => l.IsDead).ToList();
- foreach (var lobby in dead) {
- RemoveLobby(lobby.ConnectionData);
- }
-
- // Cleanup expired discovery tokens (older than 2 minutes)
- var tokenCutoff = DateTime.UtcNow.AddMinutes(-2);
- var expiredTokens = _discoveryMetadata
- .Where(kvp => kvp.Value.CreatedAt < tokenCutoff)
- .Select(kvp => kvp.Key)
- .ToList();
-
- foreach (var token in expiredTokens) {
- _discoveryMetadata.TryRemove(token, out _);
- }
-
- return dead.Count;
- }
-
- ///
- /// Removes a lobby by its ConnectionData and cleans up token/code mappings.
- ///
- /// The ConnectionData of the lobby to remove.
- /// True if the lobby was found and removed; otherwise, false.
- private bool RemoveLobby(string connectionData) {
- if (!_lobbies.TryRemove(connectionData, out var lobby)) return false;
-
- _tokenToConnectionData.TryRemove(lobby.HostToken, out _);
- _codeToConnectionData.TryRemove(lobby.LobbyCode, out _);
-
- if (lobby.HostDiscoveryToken != null) {
- _discoveryMetadata.TryRemove(lobby.HostDiscoveryToken, out _);
- }
-
- lobbyNameService.FreeLobbyName(lobby.LobbyName);
-
- return true;
- }
-
- ///
- /// Registers a new discovery token for a client (matchmaking only).
- ///
- public string? RegisterClientDiscoveryToken(string lobbyCode, string clientIp) {
- var lobby = GetLobbyByCode(lobbyCode);
- if (lobby == null || lobby.LobbyType == "steam") return null;
-
- var token = GenerateToken(32);
- _discoveryMetadata[token] = new DiscoveryTokenMetadata {
- LobbyCode = lobbyCode,
- ClientIp = clientIp
- };
- return token;
- }
-
- ///
- /// Gets client info for a discovery token.
- ///
- public bool TryGetClientInfo(string token, out string lobbyCode, out string clientIp) {
- if (_discoveryMetadata.TryGetValue(token, out var metadata) && metadata.ClientIp != null) {
- lobbyCode = metadata.LobbyCode ?? "";
- clientIp = metadata.ClientIp;
- return true;
- }
- lobbyCode = "";
- clientIp = "";
- return false;
- }
-
- ///
- /// If the token belongs to a host, updates their lobby's external port.
- ///
- public void ApplyHostPort(string token, int port) {
- if (!_discoveryMetadata.TryGetValue(token, out var metadata) || metadata.HostConnectionData == null) return;
- var lobby = GetLobby(metadata.HostConnectionData);
- if (lobby != null) {
- lobby.ExternalPort = port;
- }
- }
-
- ///
- /// Updates the discovered port for a given token.
- ///
- public void SetDiscoveredPort(string token, int port) {
- if (_discoveryMetadata.TryGetValue(token, out var metadata)) {
- metadata.DiscoveredPort = port;
- }
- }
-
- ///
- /// Gets the discovered port for a given token, if any.
- ///
- public int? GetDiscoveredPort(string token) {
- return _discoveryMetadata.TryGetValue(token, out var metadata) ? metadata.DiscoveredPort : null;
- }
-
- ///
- /// Removes a discovery token and its associated metadata.
- ///
- public void RemoveDiscoveryToken(string token) {
- _discoveryMetadata.TryRemove(token, out _);
- }
-
- ///
- /// Generates a random token of the specified length.
- ///
- /// Length of the token to generate.
- /// A random alphanumeric token string.
- private static string GenerateToken(int length) {
- return new string(Enumerable.Range(0, length).Select(_ => TokenChars[Random.Next(TokenChars.Length)]).ToArray());
- }
-
- ///
- /// Generates a unique lobby code, retrying on collision.
- ///
- /// A unique 6-character uppercase alphanumeric code.
- private string GenerateLobbyCode() {
- // Generate unique code, retry if collision (extremely rare with 30^6 = 729M combinations)
- string code;
- do {
- code = new string(Enumerable.Range(0, LobbyCodeLength)
- .Select(_ => LobbyCodeChars[Random.Next(LobbyCodeChars.Length)]).ToArray());
- } while (_codeToConnectionData.ContainsKey(code));
- return code;
- }
-}
diff --git a/MMS/Services/Matchmaking/JoinSessionCoordinator.cs b/MMS/Services/Matchmaking/JoinSessionCoordinator.cs
new file mode 100644
index 0000000..88d6dce
--- /dev/null
+++ b/MMS/Services/Matchmaking/JoinSessionCoordinator.cs
@@ -0,0 +1,415 @@
+using System.Net.WebSockets;
+using MMS.Models;
+using MMS.Models.Lobbies;
+using MMS.Models.Matchmaking;
+using MMS.Services.Lobbies;
+using MMS.Services.Utility;
+
+namespace MMS.Services.Matchmaking;
+
+///
+/// Coordinates join-session lifecycle, discovery routing, and NAT punch orchestration.
+///
+public sealed class JoinSessionCoordinator {
+ ///
+ /// Thread-safe in-memory store for active join sessions and discovery tokens.
+ ///
+ private readonly JoinSessionStore _store;
+
+ ///
+ /// Service for sending WebSocket and HTTP messages to clients and hosts.
+ ///
+ private readonly JoinSessionMessenger _messenger;
+
+ ///
+ /// Service for managing lobby state and metadata.
+ ///
+ private readonly LobbyService _lobbyService;
+
+ ///
+ /// Logger instance for this coordinator.
+ ///
+ private readonly ILogger _logger;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Thread-safe in-memory store for active join sessions.
+ /// Service for sending session-related messages.
+ /// Service for managing lobby state.
+ /// Logger instance for this coordinator.
+ public JoinSessionCoordinator(
+ JoinSessionStore store,
+ JoinSessionMessenger messenger,
+ LobbyService lobbyService,
+ ILogger logger
+ ) {
+ _store = store;
+ _messenger = messenger;
+ _lobbyService = lobbyService;
+ _logger = logger;
+ }
+
+ ///
+ /// Allocates a new join session for a client attempting to connect to .
+ ///
+ /// The target lobby. Steam lobbies are rejected immediately.
+ /// The joining client's IP address, used later for punch coordination.
+ ///
+ /// The new , or for Steam lobbies
+ ///
+ public JoinSession? CreateJoinSession(Lobby lobby, string clientIp) {
+ if (lobby.LobbyType.Equals("steam", StringComparison.OrdinalIgnoreCase))
+ return null;
+
+ var session = new JoinSession {
+ JoinId = TokenGenerator.GenerateToken(32),
+ LobbyConnectionData = lobby.ConnectionData,
+ ClientIp = clientIp,
+ ClientDiscoveryToken = TokenGenerator.GenerateToken(32)
+ };
+
+ _store.Add(session);
+ _store.UpsertDiscoveryToken(
+ session.ClientDiscoveryToken,
+ new DiscoveryTokenMetadata { JoinId = session.JoinId }
+ );
+
+ RegisterHostDiscoveryTokenIfAbsent(lobby);
+
+ return session;
+ }
+
+ ///
+ /// Returns an active, non-expired session by its identifier.
+ /// Expired sessions are cleaned up upon access.
+ ///
+ /// The join session identifier.
+ /// The session, or if not found or expired.
+ public JoinSession? GetJoinSession(string joinId) {
+ if (!_store.TryGet(joinId, out var session) || session == null)
+ return null;
+
+ if (session.ExpiresAtUtc >= DateTime.UtcNow)
+ return session;
+
+ CleanupJoinSession(joinId);
+ return null;
+ }
+
+ ///
+ /// Associates a WebSocket with an existing session so the server can push events to the client.
+ ///
+ /// The join session identifier.
+ /// The client's WebSocket connection.
+ /// if the session was found and the socket was attached.
+ public bool AttachJoinWebSocket(string joinId, WebSocket webSocket) {
+ var session = GetJoinSession(joinId);
+ if (session == null) return false;
+
+ session.ClientWebSocket = webSocket;
+ return true;
+ }
+
+ ///
+ /// Records the externally observed UDP port for a discovery token and advances the punch flow.
+ ///
+ ///
+ /// The token determines whether this is a host or client port discovery event and
+ /// dispatches to the appropriate handler.
+ ///
+ /// The discovery token included in the UDP packet.
+ /// The external port observed by the server.
+ /// Propagates notification that the operation should be cancelled.
+ public async Task SetDiscoveredPortAsync(string token, int port, CancellationToken cancellationToken = default) {
+ if (!_store.TryGetDiscoveryMetadata(token, out var metadata) || metadata == null)
+ return;
+
+ if (!_store.TrySetDiscoveredPort(token, port))
+ return;
+
+ if (metadata.HostConnectionData != null) {
+ await HandleHostPortDiscoveredAsync(metadata.HostConnectionData, port, cancellationToken);
+ return;
+ }
+
+ if (metadata.JoinId != null)
+ await HandleClientPortDiscoveredAsync(metadata.JoinId, port, cancellationToken);
+ }
+
+ ///
+ /// Returns the externally observed UDP port for a discovery token, or
+ /// if the port has not yet been recorded.
+ ///
+ /// The discovery token to query.
+ public int? GetDiscoveredPort(string token) => _store.GetDiscoveredPort(token);
+
+ ///
+ /// Sends the begin_client_mapping message to the client identified by .
+ ///
+ /// The join session identifier.
+ /// Propagates notification that the operation should be cancelled.
+ public Task SendBeginClientMappingAsync(string joinId, CancellationToken cancellationToken) {
+ var session = GetJoinSession(joinId);
+ return session == null
+ ? Task.CompletedTask
+ : JoinSessionMessenger.SendBeginClientMappingAsync(session, cancellationToken);
+ }
+
+ ///
+ /// Asks the host to refresh its NAT mapping for the given join session.
+ ///
+ /// The join session identifier used to locate the lobby.
+ /// Propagates notification that the operation should be cancelled.
+ ///
+ /// if the refresh message was dispatched successfully.
+ ///
+ public async Task SendHostRefreshRequestAsync(string joinId, CancellationToken cancellationToken) {
+ var session = GetJoinSession(joinId);
+ return session != null &&
+ await _messenger.SendHostRefreshRequestAsync(joinId, session.LobbyConnectionData, cancellationToken);
+ }
+
+ ///
+ /// Fails a join session: notifies both the client and the host, then cleans up the session.
+ ///
+ /// The join session identifier.
+ /// A short machine-readable failure reason (e.g. "host_unreachable").
+ /// Propagates notification that the operation should be cancelled.
+ public async Task FailJoinSessionAsync(
+ string joinId,
+ string reason,
+ CancellationToken cancellationToken = default
+ ) {
+ var session = GetJoinSession(joinId);
+ if (session == null) return;
+
+ try {
+ await JoinSessionMessenger.SendJoinFailedToClientAsync(session, reason, cancellationToken);
+ } catch (Exception ex) when (IsSocketSendFailure(ex)) {
+ _logger.LogDebug(ex, "Failed to notify join client for {JoinId}", joinId);
+ }
+
+ try {
+ await _messenger.SendJoinFailedToHostAsync(session.LobbyConnectionData, joinId, reason, cancellationToken);
+ } catch (Exception ex) when (IsSocketSendFailure(ex)) {
+ _logger.LogDebug(ex, "Failed to notify host about join failure for {JoinId}", joinId);
+ }
+
+ CleanupJoinSession(joinId);
+ }
+
+ ///
+ /// Removes all sessions that have passed their expiry time and purges stale discovery tokens.
+ ///
+ ///
+ /// Discovery tokens are considered stale after 2 minutes, giving them a longer
+ /// grace period than sessions to handle timing edge cases.
+ ///
+ /// The number of expired sessions removed during this call.
+ public int CleanupExpiredSessions() {
+ var now = DateTime.UtcNow;
+ var removed = 0;
+
+ foreach (var joinId in _store.GetExpiredJoinIds(now)) {
+ CleanupJoinSession(joinId);
+ removed++;
+ }
+
+ foreach (var token in _store.GetExpiredDiscoveryTokens(now.AddMinutes(-2)))
+ _store.RemoveDiscoveryToken(token);
+
+ return removed;
+ }
+
+ ///
+ /// Removes all sessions belonging to and its host discovery token.
+ /// Called when a lobby is closed or evicted.
+ ///
+ /// The lobby being removed.
+ public void CleanupSessionsForLobby(Lobby lobby) {
+ foreach (var joinId in _store.GetJoinIdsForLobby(lobby.ConnectionData))
+ CleanupJoinSession(joinId);
+
+ if (!string.IsNullOrEmpty(lobby.HostDiscoveryToken))
+ _store.RemoveDiscoveryToken(lobby.HostDiscoveryToken);
+ }
+
+ ///
+ /// Registers the host discovery token for a lobby if it is not already tracked.
+ /// This ensures the server can correlate the host's UDP packet with the correct lobby.
+ ///
+ private void RegisterHostDiscoveryTokenIfAbsent(Lobby lobby) {
+ if (lobby.HostDiscoveryToken == null || _store.ContainsDiscoveryToken(lobby.HostDiscoveryToken))
+ return;
+
+ _store.UpsertDiscoveryToken(
+ lobby.HostDiscoveryToken,
+ new DiscoveryTokenMetadata { HostConnectionData = lobby.ConnectionData }
+ );
+ }
+
+ ///
+ /// Handles a UDP port discovery event originating from the host side.
+ /// Records the host's external port and attempts to advance any pending client sessions.
+ ///
+ private async Task HandleHostPortDiscoveredAsync(
+ string lobbyConnectionData,
+ int port,
+ CancellationToken cancellationToken
+ ) {
+ var lobby = _lobbyService.GetLobby(lobbyConnectionData);
+ if (lobby == null) return;
+
+ lobby.ExternalPort = port;
+ await JoinSessionMessenger.SendHostMappingReceivedAsync(lobby, port, cancellationToken);
+ await TryStartPendingJoinSessionsAsync(lobbyConnectionData, cancellationToken);
+ }
+
+ ///
+ /// Handles a UDP port discovery event originating from the client side.
+ /// Records the client's external port, requests a host refresh, and attempts to start punching.
+ ///
+ private async Task HandleClientPortDiscoveredAsync(
+ string joinId,
+ int port,
+ CancellationToken cancellationToken
+ ) {
+ var session = GetJoinSession(joinId);
+ if (session == null) return;
+
+ session.ClientExternalPort = port;
+ await JoinSessionMessenger.SendClientMappingReceivedAsync(session, port, cancellationToken);
+
+ var hostRefreshed = await _messenger.SendHostRefreshRequestAsync(
+ joinId,
+ session.LobbyConnectionData,
+ cancellationToken
+ );
+
+ if (!hostRefreshed) {
+ await FailJoinSessionAsync(joinId, "host_unreachable", cancellationToken);
+ return;
+ }
+
+ await TryStartJoinSessionAsync(joinId, cancellationToken);
+ }
+
+ ///
+ /// Attempts to start all client sessions waiting on the given lobby.
+ /// Called after the host's external port becomes known.
+ ///
+ private async Task TryStartPendingJoinSessionsAsync(
+ string lobbyConnectionData,
+ CancellationToken cancellationToken
+ ) {
+ foreach (var joinId in _store.GetJoinIdsForLobby(lobbyConnectionData))
+ await TryStartJoinSessionAsync(joinId, cancellationToken);
+ }
+
+ ///
+ /// Attempts to issue synchronized start_punch messages to both sides.
+ ///
+ ///
+ /// The host is notified first so a late host disconnect cannot leave the client punching alone.
+ /// Any socket failure while dispatching the pair is converted into a join failure.
+ ///
+ private async Task TryStartJoinSessionAsync(string joinId, CancellationToken cancellationToken) {
+ var session = GetJoinSession(joinId);
+ if (session?.ClientExternalPort == null) return;
+
+ var lobby = _lobbyService.GetLobby(session.LobbyConnectionData);
+ if (lobby == null) {
+ await FailJoinSessionAsync(joinId, "lobby_closed", cancellationToken);
+ return;
+ }
+
+ if (lobby.HostWebSocket is not { State: WebSocketState.Open }) {
+ await FailJoinSessionAsync(joinId, "host_unreachable", cancellationToken);
+ return;
+ }
+
+ if (session.ClientWebSocket is not { State: WebSocketState.Open }) {
+ await FailJoinSessionAsync(joinId, "client_disconnected", cancellationToken);
+ return;
+ }
+
+ // Host port is not yet known so we wait for the next host discovery event.
+ if (lobby.ExternalPort == null) return;
+
+ // Matchmaking lobbies store ConnectionData as "IP:Port".
+ // Steam lobbies are filtered out at session creation.
+ var hostIp = lobby.ConnectionData.Split(':')[0];
+ var startTimeMs = DateTimeOffset.UtcNow
+ .AddMilliseconds(MatchmakingProtocol.PunchTimingOffsetMs)
+ .ToUnixTimeMilliseconds();
+
+ try {
+ var hostSent = await JoinSessionMessenger.SendStartPunchToHostAsync(
+ lobby,
+ joinId,
+ session.ClientIp,
+ session.ClientExternalPort.Value,
+ lobby.ExternalPort.Value,
+ startTimeMs,
+ cancellationToken
+ );
+ if (!hostSent) {
+ await FailJoinSessionAsync(joinId, "host_unreachable", cancellationToken);
+ return;
+ }
+
+ var clientSent = await JoinSessionMessenger.SendStartPunchToClientAsync(
+ session,
+ lobby.ExternalPort.Value,
+ hostIp,
+ startTimeMs,
+ cancellationToken
+ );
+ if (!clientSent) {
+ await FailJoinSessionAsync(joinId, "client_disconnected", cancellationToken);
+ return;
+ }
+ } catch (Exception ex) when (IsSocketSendFailure(ex)) {
+ _logger.LogDebug(ex, "Failed to dispatch start_punch for join {JoinId}", joinId);
+ await FailJoinSessionAsync(joinId, "host_unreachable", cancellationToken);
+ return;
+ }
+
+ CleanupJoinSession(joinId);
+ }
+
+ ///
+ /// Removes a session and its client discovery token from the store,
+ /// then performs a best-effort close of the client WebSocket.
+ ///
+ private void CleanupJoinSession(string joinId) {
+ if (!_store.Remove(joinId, out var session) || session == null) return;
+
+ _store.RemoveDiscoveryToken(session.ClientDiscoveryToken);
+
+ if (session.ClientWebSocket is { State: WebSocketState.Open } ws)
+ _ = CloseJoinSocketAsync(ws, joinId);
+ }
+
+ ///
+ /// Attempts a graceful close of the client rendezvous socket using a bounded timeout.
+ ///
+ /// The client WebSocket to close.
+ /// Join identifier used for debug logging.
+ private async Task CloseJoinSocketAsync(WebSocket webSocket, string joinId) {
+ try {
+ using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
+ await webSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "join complete", timeoutCts.Token);
+ } catch (Exception ex) when (IsSocketSendFailure(ex) || ex is OperationCanceledException) {
+ _logger.LogDebug(ex, "Failed to close client join WebSocket for join {JoinId}", joinId);
+ }
+ }
+
+ ///
+ /// Returns for exceptions that commonly mean the peer disappeared during send/close.
+ ///
+ /// The exception raised by a socket operation.
+ private static bool IsSocketSendFailure(Exception ex) =>
+ ex is WebSocketException or ObjectDisposedException;
+}
diff --git a/MMS/Services/Matchmaking/JoinSessionMessenger.cs b/MMS/Services/Matchmaking/JoinSessionMessenger.cs
new file mode 100644
index 0000000..00e87f1
--- /dev/null
+++ b/MMS/Services/Matchmaking/JoinSessionMessenger.cs
@@ -0,0 +1,195 @@
+using System.Net.WebSockets;
+using MMS.Models.Lobbies;
+using MMS.Models.Matchmaking;
+using MMS.Services.Lobbies;
+using MMS.Services.Network;
+
+namespace MMS.Services.Matchmaking;
+
+///
+/// Sends matchmaking rendezvous messages over client and host WebSockets.
+/// Methods returning distinguish "target missing" from transport exceptions.
+///
+public sealed class JoinSessionMessenger(LobbyService lobbyService) {
+ /// Tells the joining client to begin its UDP mapping phase.
+ public static Task SendBeginClientMappingAsync(JoinSession session, CancellationToken cancellationToken) =>
+ SendToJoinClientAsync(
+ session,
+ new {
+ action = "begin_client_mapping",
+ joinId = session.JoinId,
+ clientDiscoveryToken = session.ClientDiscoveryToken,
+ serverTimeMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
+ },
+ cancellationToken
+ );
+
+ ///
+ /// Asks the host to refresh its NAT mapping by re-sending its UDP discovery packet.
+ /// Returns if the host is unavailable or missing a discovery token.
+ ///
+ public async Task SendHostRefreshRequestAsync(
+ string joinId,
+ string lobbyConnectionData,
+ CancellationToken cancellationToken
+ ) {
+ var lobby = lobbyService.GetLobby(lobbyConnectionData);
+ if (lobby?.HostWebSocket is not { State: WebSocketState.Open } hostWs ||
+ string.IsNullOrEmpty(lobby.HostDiscoveryToken)) {
+ return false;
+ }
+
+ await WebSocketMessenger.SendAsync(
+ hostWs,
+ new {
+ action = "refresh_host_mapping",
+ joinId,
+ hostDiscoveryToken = lobby.HostDiscoveryToken,
+ serverTimeMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
+ },
+ cancellationToken
+ );
+ return true;
+ }
+
+ /// Notifies the joining client that its external UDP port has been observed.
+ public static Task SendClientMappingReceivedAsync(
+ JoinSession session,
+ int port,
+ CancellationToken cancellationToken
+ ) =>
+ SendToJoinClientAsync(
+ session,
+ new {
+ action = "client_mapping_received",
+ clientPort = port,
+ serverTimeMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
+ },
+ cancellationToken
+ );
+
+ /// Notifies the host that its external UDP port has been observed.
+ public static Task SendHostMappingReceivedAsync(Lobby lobby, int port, CancellationToken cancellationToken) =>
+ SendToHostAsync(
+ lobby,
+ new {
+ action = "host_mapping_received",
+ hostPort = port,
+ serverTimeMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
+ },
+ cancellationToken
+ );
+
+ ///
+ /// Sends a synchronized NAT punch instruction to the joining client.
+ /// is the coordinated UTC timestamp (Unix ms) at which both sides punch.
+ /// Returns if the client socket is not open.
+ ///
+ public static async Task SendStartPunchToClientAsync(
+ JoinSession session,
+ int hostPort,
+ string hostIp,
+ long startTimeMs,
+ CancellationToken cancellationToken
+ ) {
+ if (session.ClientWebSocket is not { State: WebSocketState.Open } ws)
+ return false;
+
+ await WebSocketMessenger.SendAsync(
+ ws,
+ new {
+ action = "start_punch",
+ joinId = session.JoinId,
+ hostIp,
+ hostPort,
+ startTimeMs
+ },
+ cancellationToken
+ );
+ return true;
+ }
+
+ ///
+ /// Sends a synchronized NAT punch instruction to the lobby host.
+ /// is the coordinated UTC timestamp (Unix ms) at which both sides punch.
+ /// Returns if the host socket is not open.
+ ///
+ public static async Task SendStartPunchToHostAsync(
+ Lobby lobby,
+ string joinId,
+ string clientIp,
+ int clientPort,
+ int hostPort,
+ long startTimeMs,
+ CancellationToken cancellationToken
+ ) {
+ if (lobby.HostWebSocket is not { State: WebSocketState.Open } hostWs)
+ return false;
+
+ await WebSocketMessenger.SendAsync(
+ hostWs,
+ new {
+ action = "start_punch",
+ joinId,
+ clientIp,
+ clientPort,
+ hostPort,
+ startTimeMs
+ },
+ cancellationToken
+ );
+ return true;
+ }
+
+ /// Notifies the joining client that the join attempt has failed.
+ public static Task SendJoinFailedToClientAsync(
+ JoinSession session,
+ string reason,
+ CancellationToken cancellationToken
+ ) =>
+ SendToJoinClientAsync(
+ session,
+ new { action = "join_failed", joinId = session.JoinId, reason },
+ cancellationToken
+ );
+
+ /// Notifies the lobby host that a join attempt has failed.
+ public async Task SendJoinFailedToHostAsync(
+ string lobbyConnectionData,
+ string joinId,
+ string reason,
+ CancellationToken cancellationToken
+ ) {
+ var lobby = lobbyService.GetLobby(lobbyConnectionData);
+ if (lobby?.HostWebSocket is not { State: WebSocketState.Open } hostWs)
+ return;
+
+ await WebSocketMessenger.SendAsync(
+ hostWs,
+ new { action = "join_failed", joinId, reason },
+ cancellationToken
+ );
+ }
+
+ /// Sends to the session's client WebSocket, if open.
+ private static Task SendToJoinClientAsync(
+ JoinSession session,
+ object payload,
+ CancellationToken cancellationToken
+ ) {
+ return session.ClientWebSocket is not { State: WebSocketState.Open } ws
+ ? Task.CompletedTask
+ : WebSocketMessenger.SendAsync(ws, payload, cancellationToken);
+ }
+
+ /// Sends to the lobby's host WebSocket, if open.
+ private static Task SendToHostAsync(
+ Lobby lobby,
+ object payload,
+ CancellationToken cancellationToken
+ ) {
+ return lobby.HostWebSocket is not { State: WebSocketState.Open } ws
+ ? Task.CompletedTask
+ : WebSocketMessenger.SendAsync(ws, payload, cancellationToken);
+ }
+}
diff --git a/MMS/Services/Matchmaking/JoinSessionService.cs b/MMS/Services/Matchmaking/JoinSessionService.cs
new file mode 100644
index 0000000..595742d
--- /dev/null
+++ b/MMS/Services/Matchmaking/JoinSessionService.cs
@@ -0,0 +1,85 @@
+using System.Net.WebSockets;
+using MMS.Models.Lobbies;
+using MMS.Models.Matchmaking;
+
+namespace MMS.Services.Matchmaking;
+
+///
+/// Compatibility facade over for the join-session
+/// lifecycle and NAT hole-punch coordination.
+///
+///
+/// A join session represents a single client attempt to connect to a lobby host.
+/// Typical flow:
+///
+/// - POST /lobby/{id}/join -> allocates a session and client discovery token.
+/// - Client opens a WebSocket -> stores it for server-push events.
+/// - Client UDP packet arrives -> records the external port and sends refresh_host_mapping to the host.
+/// - Host UDP packet arrives -> records the host port and sends synchronized start_punch to both sides.
+///
+///
+public class JoinSessionService {
+ ///
+ /// The core coordinator that handles the cross-component logic for joining sessions.
+ ///
+ private readonly JoinSessionCoordinator _coordinator;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The core coordinator that handles join session lifecycle logic.
+ public JoinSessionService(JoinSessionCoordinator coordinator) {
+ _coordinator = coordinator;
+ }
+ ///
+ /// Allocates a new join session for a client attempting to connect to .
+ /// Returns for Steam lobbies.
+ ///
+ public JoinSession? CreateJoinSession(Lobby lobby, string clientIp) =>
+ _coordinator.CreateJoinSession(lobby, clientIp);
+
+ /// Returns an active, non-expired session by its identifier, or if not found or expired.
+ public JoinSession? GetJoinSession(string joinId) =>
+ _coordinator.GetJoinSession(joinId);
+
+ ///
+ /// Associates a WebSocket with an existing session so the server can push events to the client.
+ /// Returns if the session was not found.
+ ///
+ public bool AttachJoinWebSocket(string joinId, WebSocket webSocket) =>
+ _coordinator.AttachJoinWebSocket(joinId, webSocket);
+
+ /// Records the externally observed UDP port for a discovery token and advances the punch flow.
+ public Task SetDiscoveredPortAsync(string token, int port, CancellationToken cancellationToken = default) =>
+ _coordinator.SetDiscoveredPortAsync(token, port, cancellationToken);
+
+ /// Returns the externally observed UDP port for a discovery token, or if not yet recorded.
+ public int? GetDiscoveredPort(string token) =>
+ _coordinator.GetDiscoveredPort(token);
+
+ /// Sends the begin_client_mapping message to the client identified by .
+ public Task SendBeginClientMappingAsync(string joinId, CancellationToken cancellationToken) =>
+ _coordinator.SendBeginClientMappingAsync(joinId, cancellationToken);
+
+ ///
+ /// Asks the host to refresh its NAT mapping for the given join session.
+ /// Returns if the message could not be dispatched.
+ ///
+ public Task SendHostRefreshRequestAsync(string joinId, CancellationToken cancellationToken) =>
+ _coordinator.SendHostRefreshRequestAsync(joinId, cancellationToken);
+
+ /// Notifies both client and host of failure, then cleans up the session.
+ public Task FailJoinSessionAsync(string joinId, string reason, CancellationToken cancellationToken = default) =>
+ _coordinator.FailJoinSessionAsync(joinId, reason, cancellationToken);
+
+ /// Removes all expired sessions and purges stale discovery tokens.
+ public int CleanupExpiredSessions() =>
+ _coordinator.CleanupExpiredSessions();
+
+ ///
+ /// Removes all sessions belonging to and its host discovery token.
+ /// Called when a lobby is closed or evicted.
+ ///
+ internal void CleanupSessionsForLobby(Lobby lobby) =>
+ _coordinator.CleanupSessionsForLobby(lobby);
+}
diff --git a/MMS/Services/Matchmaking/JoinSessionStore.cs b/MMS/Services/Matchmaking/JoinSessionStore.cs
new file mode 100644
index 0000000..90bbd12
--- /dev/null
+++ b/MMS/Services/Matchmaking/JoinSessionStore.cs
@@ -0,0 +1,173 @@
+using System.Collections.Concurrent;
+using MMS.Models.Matchmaking;
+
+namespace MMS.Services.Matchmaking;
+
+///
+/// Thread-safe in-memory store for active join sessions and discovery tokens.
+/// Individual operations are atomic; callers own higher-level consistency
+/// (e.g. removing a session and its token together).
+///
+public sealed class JoinSessionStore {
+ ///
+ /// Thread-safe dictionary of active join sessions keyed by .
+ ///
+ private readonly ConcurrentDictionary _joinSessions = new();
+
+ ///
+ /// Metadata for active discovery tokens, used for correlating UDP discovery packets.
+ ///
+ private readonly ConcurrentDictionary _discoveryMetadata = new();
+
+ ///
+ /// Secondary index for efficient lookup of join sessions by lobby connection data.
+ /// Values are dummy bytes to use as a set.
+ ///
+ private readonly ConcurrentDictionary> _joinIdsByLobby = new();
+
+ ///
+ /// Secondary index for efficient lookup of expired sessions.
+ ///
+ private readonly SortedSet<(DateTime expiresAtUtc, string joinId)> _expiryIndex = [];
+
+ ///
+ /// Lock for thread-safe access to .
+ ///
+ private readonly Lock _indexLock = new();
+
+ /// Adds or replaces the session keyed by .
+ public void Add(JoinSession session) {
+ if (_joinSessions.TryGetValue(session.JoinId, out var previous))
+ RemoveIndexes(previous);
+
+ _joinSessions[session.JoinId] = session;
+ AddIndexes(session);
+ }
+
+ /// Attempts to retrieve a session by its join identifier.
+ public bool TryGet(string joinId, out JoinSession? session) =>
+ _joinSessions.TryGetValue(joinId, out session);
+
+ /// Removes a session and returns it. Returns if not found.
+ public bool Remove(string joinId, out JoinSession? session) {
+ if (!_joinSessions.TryRemove(joinId, out session))
+ return false;
+
+ RemoveIndexes(session);
+
+ return true;
+ }
+
+ /// Returns the join identifiers for all sessions belonging to the given lobby.
+ public IReadOnlyList GetJoinIdsForLobby(string lobbyConnectionData) =>
+ _joinIdsByLobby.TryGetValue(lobbyConnectionData, out var joinIds)
+ ? joinIds.Keys.ToList()
+ : [];
+
+ /// Returns the join identifiers of all sessions expired before .
+ public IReadOnlyList GetExpiredJoinIds(DateTime nowUtc) {
+ var expiredJoinIds = new List();
+
+ lock (_indexLock) {
+ expiredJoinIds.AddRange(
+ _expiryIndex
+ .TakeWhile(entry => entry.expiresAtUtc < nowUtc)
+ .Select(entry => entry.joinId)
+ );
+ }
+
+ return expiredJoinIds;
+ }
+
+ /// Inserts or replaces the metadata associated with a discovery token.
+ public void UpsertDiscoveryToken(string token, DiscoveryTokenMetadata metadata) =>
+ _discoveryMetadata[token] = CloneMetadata(metadata);
+
+ /// Returns if the discovery token is currently registered.
+ public bool ContainsDiscoveryToken(string token) => _discoveryMetadata.ContainsKey(token);
+
+ /// Attempts to retrieve the metadata for a discovery token.
+ public bool TryGetDiscoveryMetadata(string token, out DiscoveryTokenMetadata? metadata) {
+ if (!_discoveryMetadata.TryGetValue(token, out var stored)) {
+ metadata = null;
+ return false;
+ }
+
+ metadata = CloneMetadata(stored);
+ return true;
+ }
+
+ ///
+ /// Returns the discovered port for a token, or if the token
+ /// is unknown or its port has not yet been recorded.
+ ///
+ public int? GetDiscoveredPort(string token) =>
+ _discoveryMetadata.TryGetValue(token, out var metadata) ? metadata.DiscoveredPort : null;
+
+ /// Removes a discovery token and its metadata.
+ public void RemoveDiscoveryToken(string token) =>
+ _discoveryMetadata.TryRemove(token, out _);
+
+ /// Updates only the discovered port for an existing discovery token.
+ public bool TrySetDiscoveredPort(string token, int port) {
+ while (_discoveryMetadata.TryGetValue(token, out var metadata)) {
+ var updated = CloneMetadata(metadata);
+ updated.DiscoveredPort = port;
+
+ if (_discoveryMetadata.TryUpdate(token, updated, metadata))
+ return true;
+ }
+
+ return false;
+ }
+
+ ///
+ /// Returns tokens created before .
+ /// Used during periodic cleanup to evict stale tokens no longer tied to an active session.
+ ///
+ public IReadOnlyList GetExpiredDiscoveryTokens(DateTime cutoffUtc) =>
+ _discoveryMetadata.Where(kvp => kvp.Value.CreatedAt < cutoffUtc)
+ .Select(kvp => kvp.Key)
+ .ToList();
+
+ ///
+ /// Adds the session to the lobby and expiry indexes.
+ ///
+ /// The session to index.
+ private void AddIndexes(JoinSession session) {
+ var lobbyJoinIds = _joinIdsByLobby.GetOrAdd(
+ session.LobbyConnectionData, _ => new ConcurrentDictionary()
+ );
+ lobbyJoinIds[session.JoinId] = 0;
+
+ lock (_indexLock) {
+ _expiryIndex.Add((session.ExpiresAtUtc, session.JoinId));
+ }
+ }
+
+ ///
+ /// Removes the session from the lobby and expiry indexes.
+ ///
+ /// The session to de-index.
+ private void RemoveIndexes(JoinSession session) {
+ if (_joinIdsByLobby.TryGetValue(session.LobbyConnectionData, out var lobbyJoinIds))
+ lobbyJoinIds.TryRemove(session.JoinId, out _);
+
+ lock (_indexLock) {
+ _expiryIndex.Remove((session.ExpiresAtUtc, session.JoinId));
+ }
+ }
+
+ ///
+ /// Creates a deep copy of the given discovery metadata.
+ ///
+ /// The metadata to clone.
+ /// A new instance with identical values.
+ private static DiscoveryTokenMetadata CloneMetadata(DiscoveryTokenMetadata metadata) =>
+ new() {
+ JoinId = metadata.JoinId,
+ HostConnectionData = metadata.HostConnectionData,
+ DiscoveredPort = metadata.DiscoveredPort,
+ CreatedAt = metadata.CreatedAt
+ };
+}
diff --git a/MMS/Services/Network/UdpDiscoveryService.cs b/MMS/Services/Network/UdpDiscoveryService.cs
new file mode 100644
index 0000000..cfdf40a
--- /dev/null
+++ b/MMS/Services/Network/UdpDiscoveryService.cs
@@ -0,0 +1,112 @@
+using System.Net;
+using System.Net.Sockets;
+using System.Security.Cryptography;
+using System.Text;
+using MMS.Bootstrap;
+using MMS.Services.Matchmaking;
+using MMS.Services.Utility;
+
+namespace MMS.Services.Network;
+
+///
+/// Hosted background service that listens for incoming UDP packets on a fixed port
+/// as part of the NAT traversal discovery flow.
+///
+///
+/// Each valid packet carries a session token encoded as UTF-8. The sender's observed
+/// external endpoint is recorded in , advancing
+/// the hole-punch state machine for the corresponding host or client session.
+///
+public sealed class UdpDiscoveryService : BackgroundService {
+ ///
+ /// Service used to record discovered UDP ports and advance join session states.
+ ///
+ private readonly JoinSessionService _joinSessionService;
+
+ ///
+ /// Logger instance for this service.
+ ///
+ private readonly ILogger _logger;
+
+ ///
+ /// The fixed UDP port used for discovery packets.
+ ///
+ private static readonly int Port = ProgramState.DiscoveryPort;
+
+ ///
+ /// Valid discovery packets must be exactly this many bytes.
+ /// Packets of any other length are dropped before string decoding.
+ ///
+ private const int TokenByteLength = 32;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Service used to record discovered UDP ports and advance join session states.
+ /// Logger instance for this service.
+ public UdpDiscoveryService(JoinSessionService joinSessionService, ILogger logger) {
+ _joinSessionService = joinSessionService;
+ _logger = logger;
+ }
+
+ ///
+ /// Binds a to and enters a receive loop
+ /// until is canceled by the hosting infrastructure.
+ ///
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken) {
+ using var udpClient = new UdpClient(Port);
+ _logger.LogInformation("UDP Discovery Service listening on port {Port}", Port);
+
+ while (!stoppingToken.IsCancellationRequested) {
+ try {
+ var result = await udpClient.ReceiveAsync(stoppingToken);
+ await ProcessPacketAsync(result.Buffer, result.RemoteEndPoint, stoppingToken);
+ } catch (OperationCanceledException) {
+ break;
+ } catch (Exception ex) {
+ _logger.LogError(ex, "Error in UDP Discovery Service receive loop");
+ }
+ }
+
+ _logger.LogInformation("UDP Discovery Service stopped");
+ }
+
+ ///
+ /// Validates and processes a single UDP packet.
+ /// Byte-length is checked before string decoding to avoid allocations for packets
+ /// that would be rejected anyway (oversized probes, garbage data, etc.).
+ ///
+ private async Task ProcessPacketAsync(
+ byte[] buffer,
+ IPEndPoint remoteEndPoint,
+ CancellationToken cancellationToken
+ ) {
+ if (buffer.Length != TokenByteLength) {
+ _logger.LogWarning(
+ "Received malformed discovery packet from {EndPoint} (length: {Length})",
+ PrivacyFormatter.Format(remoteEndPoint),
+ buffer.Length
+ );
+ return;
+ }
+
+ var token = Encoding.UTF8.GetString(buffer);
+
+ _logger.LogDebug(
+ "Received discovery packet {TokenFingerprint} from {EndPoint}",
+ GetTokenFingerprint(token),
+ PrivacyFormatter.Format(remoteEndPoint)
+ );
+
+ await _joinSessionService.SetDiscoveredPortAsync(token, remoteEndPoint.Port, cancellationToken);
+ }
+
+ ///
+ /// Generates a short, non-reversible SHA-256 fingerprint for a session token.
+ /// Used for correlation in debug logs without exposing the full token.
+ ///
+ /// The token string whose fingerprint to generate.
+ /// A 12-character hex string representing the fingerprint.
+ private static string GetTokenFingerprint(string token) =>
+ Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token)))[..12];
+}
diff --git a/MMS/Services/Network/WebSocketManager.cs b/MMS/Services/Network/WebSocketManager.cs
new file mode 100644
index 0000000..281e4b2
--- /dev/null
+++ b/MMS/Services/Network/WebSocketManager.cs
@@ -0,0 +1,24 @@
+using System.Net.WebSockets;
+using System.Text.Json;
+using MMS.Services.Lobbies;
+using MMS.Services.Matchmaking;
+
+namespace MMS.Services.Network;
+
+///
+/// Serializes and sends JSON payloads over WebSocket connections.
+/// Shared by and so that
+/// both use the same serialization path without a common base class.
+///
+internal static class WebSocketMessenger {
+ ///
+ /// Serializes to UTF-8 JSON and sends it as a single text frame.
+ ///
+ /// The open WebSocket to send on.
+ /// The object to serialize. Must be JSON-serializable.
+ /// Token used to cancel the send operation.
+ public static async Task SendAsync(WebSocket webSocket, object payload, CancellationToken cancellationToken) {
+ var bytes = JsonSerializer.SerializeToUtf8Bytes(payload);
+ await webSocket.SendAsync(bytes, WebSocketMessageType.Text, endOfMessage: true, cancellationToken);
+ }
+}
diff --git a/MMS/Services/UdpDiscoveryService.cs b/MMS/Services/UdpDiscoveryService.cs
deleted file mode 100644
index aec4254..0000000
--- a/MMS/Services/UdpDiscoveryService.cs
+++ /dev/null
@@ -1,114 +0,0 @@
-using System.Net;
-using System.Net.Sockets;
-using System.Text;
-
-namespace MMS.Services;
-
-///
-/// A hosted background service that listens for incoming UDP packets on a fixed port,
-/// used as part of a NAT traversal / hole-punching discovery flow.
-///
-public sealed class UdpDiscoveryService : BackgroundService {
- private readonly LobbyService _lobbyService;
- private readonly ILogger _logger;
-
- ///
- /// The UDP port this service binds to at startup.
- ///
- private const int Port = 5001;
-
- ///
- /// The exact number of bytes a valid discovery packet must contain.
- /// Packets shorter or longer than this are considered malformed and are dropped.
- ///
- private const int TokenByteLength = 32;
-
- ///
- /// Creates a new instance of with the services
- /// it needs to store discovered port mappings and emit log output.
- ///
- ///
- /// The lobby service that maps session tokens to their discovered external ports.
- /// Called once per valid packet to record the NAT-mapped port for the sending client.
- ///
- ///
- /// Logger used to emit startup, shutdown, and per-packet diagnostic messages.
- ///
- public UdpDiscoveryService(LobbyService lobbyService, ILogger logger) {
- _lobbyService = lobbyService;
- _logger = logger;
- }
-
- ///
- /// Entry point called by the .NET host when the application starts.
- /// Binds a to and enters a receive loop
- /// that runs until the host requests shutdown via .
- ///
- ///
- /// Cancellation token provided by the .NET hosting infrastructure.
- /// Triggered automatically on application shutdown (e.g. Ctrl+C, SIGTERM, IIS stop).
- /// When fired, the current ReceiveAsync call is cancelled and the loop exits cleanly.
- ///
- /// A that completes when the service has fully stopped.
- protected override async Task ExecuteAsync(CancellationToken stoppingToken) {
- using var udpClient = new UdpClient(Port);
- _logger.LogInformation("UDP Discovery Service listening on port {Port}", Port);
-
- while (!stoppingToken.IsCancellationRequested) {
- try {
- var result = await udpClient.ReceiveAsync(stoppingToken);
- ProcessPacket(result.Buffer, result.RemoteEndPoint);
- } catch (OperationCanceledException) {
- break;
- } catch (Exception ex) {
- _logger.LogError(ex, "Error in UDP Discovery Service receive loop");
- }
- }
-
- _logger.LogInformation("UDP Discovery Service stopped");
- }
-
- ///
- /// Validates and processes a single incoming UDP packet.
- /// If the packet is well-formed, the session token is decoded and the sender's
- /// external port is recorded in .
- ///
- ///
- /// Validation is intentionally done on the raw byte count
- /// before decoding to a string. This avoids a heap allocation for packets
- /// that would be rejected anyway (e.g. oversized probes, garbage data).
- ///
- ///
- /// The raw bytes received from the UDP socket. Must be exactly
- /// bytes long to be considered valid.
- ///
- ///
- /// The IP address and port of the sender as seen by this server — i.e. the
- /// NAT-translated public endpoint, not the client's LAN address.
- /// The port component of this value is what gets stored as the discovered port.
- ///
- private void ProcessPacket(byte[] buffer, IPEndPoint remoteEndPoint) {
- // Validate byte length before decoding to avoid unnecessary string allocation
- if (buffer.Length != TokenByteLength) {
- _logger.LogWarning(
- "Received malformed discovery packet from {EndPoint} (length: {Length})",
- FormatEndPoint(remoteEndPoint),
- buffer.Length
- );
- return;
- }
-
- var token = Encoding.UTF8.GetString(buffer);
-
- _logger.LogInformation(
- "Received discovery token {Token} from {EndPoint}",
- token,
- FormatEndPoint(remoteEndPoint)
- );
-
- _lobbyService.SetDiscoveredPort(token, remoteEndPoint.Port);
- }
-
- private static string FormatEndPoint(IPEndPoint remoteEndPoint) =>
- Program.IsDevelopment ? remoteEndPoint.ToString() : "[Redacted]";
-}
diff --git a/MMS/Services/Utility/PrivacyFormatter.cs b/MMS/Services/Utility/PrivacyFormatter.cs
new file mode 100644
index 0000000..2682806
--- /dev/null
+++ b/MMS/Services/Utility/PrivacyFormatter.cs
@@ -0,0 +1,44 @@
+using MMS.Bootstrap;
+using System.Net;
+
+namespace MMS.Services.Utility;
+
+///
+/// Provides utilities for formatting network endpoints in a way that is
+/// safe for logging — full detail in development, redacted in all other environments.
+///
+public static class PrivacyFormatter {
+ ///
+ /// Gets a value indicating whether the application is running in the development environment.
+ ///
+ private static bool IsDevelopment => ProgramState.IsDevelopment;
+
+ ///
+ /// Gets a redaction placeholder string. Returns a random humorous variant with
+ /// 1% probability; otherwise returns [Redacted].
+ ///
+ private static string RedactedPlaceholder => "[Redacted]";
+
+ ///
+ /// Formats an for logging.
+ /// Returns the full address:port string in development;
+ /// otherwise returns [Redacted].
+ ///
+ /// The endpoint to format.
+ /// A log-safe string representation of the endpoint.
+ public static string Format(IPEndPoint? endPoint) =>
+ IsDevelopment
+ ? endPoint?.ToString() ?? ""
+ : RedactedPlaceholder;
+
+ ///
+ /// Formats a raw endpoint string (e.g. a hostname or connection string excerpt)
+ /// for logging.
+ ///
+ /// A string representation of the endpoint.
+ /// A log-safe string representation of the endpoint.
+ public static string Format(string? endPoint) =>
+ IsDevelopment
+ ? endPoint ?? ""
+ : RedactedPlaceholder;
+}
diff --git a/MMS/Services/Utility/TokenGenerator.cs b/MMS/Services/Utility/TokenGenerator.cs
new file mode 100644
index 0000000..0338ab0
--- /dev/null
+++ b/MMS/Services/Utility/TokenGenerator.cs
@@ -0,0 +1,59 @@
+using System.Security.Cryptography;
+
+namespace MMS.Services.Utility;
+
+///
+/// Generates random tokens and lobby codes using a cryptographically secure RNG.
+///
+internal static class TokenGenerator {
+
+ /// Characters used for host authentication tokens (lowercase alphanumeric).
+ private const string TokenChars = "abcdefghijklmnopqrstuvwxyz0123456789";
+
+ /// Characters used for lobby codes (uppercase alphanumeric).
+ private const string LobbyCodeChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
+
+ /// Fixed length of all generated lobby codes.
+ private const int LobbyCodeLength = 6;
+
+ ///
+ /// Generates a random URL-safe token of the requested length.
+ /// Characters are drawn from lowercase alphanumerics (a-z0-9).
+ ///
+ /// Number of characters in the returned token.
+ /// A random lowercase alphanumeric string of characters.
+ public static string GenerateToken(int length) =>
+ string.Create(
+ length, 0, (span, _) => {
+ for (var i = 0; i < span.Length; i++)
+ span[i] = TokenChars[RandomNumberGenerator.GetInt32(TokenChars.Length)];
+ }
+ );
+
+ ///
+ /// Generates a unique -character lobby code that does not
+ /// already exist in .
+ /// Characters are drawn from uppercase alphanumerics (A-Z0-9).
+ ///
+ /// The current set of live lobby codes used for collision detection.
+ /// A unique uppercase alphanumeric lobby code.
+ public static string GenerateUniqueLobbyCode(IReadOnlySet existingCodes) {
+ const int maxRetries = 100;
+
+ string code;
+ var retries = 0;
+ do {
+ if (retries++ >= maxRetries)
+ throw new InvalidOperationException("Failed to generate a unique lobby code after 100 attempts.");
+
+ code = string.Create(
+ LobbyCodeLength, 0, (span, _) => {
+ for (var i = 0; i < span.Length; i++)
+ span[i] = LobbyCodeChars[RandomNumberGenerator.GetInt32(LobbyCodeChars.Length)];
+ }
+ );
+ } while (existingCodes.Contains(code));
+
+ return code;
+ }
+}