using System; using System.Collections.Generic; using System.IO; using System.IO.Ports; using System.Linq; using System.Net; using System.Net.Sockets; using System.Net.WebSockets; using System.Reflection; using System.Security.Cryptography; using System.Security.Policy; using System.Text; using System.Threading; using System.Web; using System.Web.UI.WebControls; using System.Xml.Linq; using static System.Net.Mime.MediaTypeNames; // ============================================================ // Tic-Tac-Toe over WebSocket — Pure C# Console Application // No ASP.NET, No Kestrel, No SignalR, No Framework // Just a TcpListener, raw HTTP, WebSocket frame parsing, // and your code. // ============================================================ namespace console_simple_web_app_websocket_demo { internal class Program { // --- Configuration --- static int Port = 8080; // --- Game state --- static Dictionary Rooms = new Dictionary(); static int RoomCounter = 0; // --- Connected WebSocket clients --- static List AllClients = new List(); // --- Lock for thread safety --- static object Lock = new object(); static void Main(string[] args) { TcpListener listener = new TcpListener(IPAddress.Any, Port); listener.Start(); Console.WriteLine($"Tic-Tac-Toe WebSocket Server started on port {Port}"); Console.WriteLine($"Open browser: http://localhost:{Port}/"); Console.WriteLine(); while (true) { TcpClient client = listener.AcceptTcpClient(); System.Threading.Thread thread = new System.Threading.Thread(() => { try { HandleClient(client); } catch (IOException) { } catch (Exception ex) { Console.WriteLine("Error: " + ex.Message); } finally { client.Close(); } }); thread.Start(); } } // ============================================================ // Layer 1: HTTP — Read request, route, respond or upgrade // ============================================================ static void HandleClient(TcpClient client) { try { NetworkStream stream = client.GetStream(); stream.ReadTimeout = 5000; // Wait briefly for data to arrive if (!stream.DataAvailable) { // Give the client a moment to send data System.Threading.Thread.Sleep(500); if (!stream.DataAvailable) return; // nothing came, abandon } // --- Read headers byte-by-byte until \r\n\r\n --- StringBuilder headerBuilder = new StringBuilder(); int prev3 = 0, prev2 = 0, prev1 = 0, current = 0; while (true) { int b = stream.ReadByte(); if (b == -1) return; headerBuilder.Append((char)b); prev3 = prev2; prev2 = prev1; prev1 = current; current = b; if (prev3 == '\r' && prev2 == '\n' && prev1 == '\r' && current == '\n') break; } string headerSection = headerBuilder.ToString(); // --- Parse Content-Length --- int contentLength = 0; string[] headerLines = headerSection.Split(new[] { "\r\n" }, StringSplitOptions.None); foreach (string line in headerLines) { if (line.StartsWith("Content-Length:", StringComparison.OrdinalIgnoreCase)) { string val = line.Substring("Content-Length:".Length).Trim(); int.TryParse(val, out contentLength); break; } } // --- Read body --- string body = ""; if (contentLength > 0) { byte[] bodyBuffer = new byte[contentLength]; int totalRead = 0; while (totalRead < contentLength) { int read = stream.Read(bodyBuffer, totalRead, contentLength - totalRead); if (read == 0) break; totalRead += read; } body = Encoding.UTF8.GetString(bodyBuffer, 0, totalRead); } // --- Parse request --- HttpRequest request = ParseHttpRequest(headerSection, body); Console.WriteLine($"{request.Method} {request.Path}"); // --- Check for WebSocket upgrade --- string upgradeHeader = request.GetHeader("Upgrade"); if (upgradeHeader != null && upgradeHeader.Equals("websocket", StringComparison.OrdinalIgnoreCase)) { HandleWebSocketUpgrade(client, stream, request); return; // Connection is now WebSocket — don't close } // --- Normal HTTP routing --- string responseHtml; int statusCode = 200; switch (request.Path) { case "/": case "/lobby": responseHtml = PageLobby(request); break; case "/game": responseHtml = PageGame(request); break; case "/spectate": responseHtml = PageSpectate(request); break; default: responseHtml = PageNotFound(); statusCode = 404; break; } // --- Send HTTP response --- string statusText = statusCode == 200 ? "OK" : "Not Found"; string httpResponse = $"HTTP/1.1 {statusCode} {statusText}\r\n" + "Content-Type: text/html; charset=utf-8\r\n" + $"Content-Length: {Encoding.UTF8.GetByteCount(responseHtml)}\r\n" + "Connection: close\r\n" + "\r\n" + responseHtml; byte[] responseBytes = Encoding.UTF8.GetBytes(httpResponse); stream.Write(responseBytes, 0, responseBytes.Length); stream.Flush(); } catch (Exception ex) { Console.WriteLine("Error: " + ex.Message); } finally { // Don't close WebSocket connections here — // they returned early from HandleWebSocketUpgrade // Normal HTTP connections are closed by Connection: close header // and the client will close after reading the response. // TcpClient.Close is handled by the WebSocket loop for upgraded connections. } } // ============================================================ // WebSocket Handshake (RFC 6455) // ============================================================ static void HandleWebSocketUpgrade(TcpClient client, NetworkStream stream, HttpRequest request) { // The WebSocket handshake is an HTTP Upgrade. // The client sends: // Upgrade: websocket // Connection: Upgrade // Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ== // Sec-WebSocket-Version: 13 // // The server must: // 1. Take the Sec-WebSocket-Key value // 2. Append the magic GUID: "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" // 3. SHA-1 hash the result // 4. Base64 encode the hash // 5. Send it back as Sec-WebSocket-Accept string clientKey = request.GetHeader("Sec-WebSocket-Key"); if (clientKey == null) { try { client.Close(); } catch { } return; } // The magic string defined in RFC 6455 — never changes string magicString = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; string combined = clientKey + magicString; byte[] sha1Hash; using (SHA1 sha1 = SHA1.Create()) { sha1Hash = sha1.ComputeHash(Encoding.UTF8.GetBytes(combined)); } string acceptKey = Convert.ToBase64String(sha1Hash); // Send the upgrade response string response = "HTTP/1.1 101 Switching Protocols\r\n" + "Upgrade: websocket\r\n" + "Connection: Upgrade\r\n" + $"Sec-WebSocket-Accept: {acceptKey}\r\n" + "\r\n"; byte[] responseBytes = Encoding.UTF8.GetBytes(response); stream.Write(responseBytes, 0, responseBytes.Length); stream.Flush(); Console.WriteLine(" WebSocket connection established"); // --- Parse query parameters from original request --- string roomId = request.GetQuery("room"); string playerName = request.GetQuery("name"); string role = request.GetQuery("role"); // "player" or "spectator" // --- Register this WebSocket client --- WsClient wsClient = new WsClient { Client = client, Stream = stream, Name = playerName ?? "Anonymous", RoomId = roomId ?? "", Role = role ?? "player" }; lock (Lock) { AllClients.Add(wsClient); } // --- Join or create game room --- if (wsClient.Role == "player") { JoinAsPlayer(wsClient); } else { JoinAsSpectator(wsClient); } // --- Enter WebSocket message loop --- // Remove read timeout — WebSocket connections are long-lived stream.ReadTimeout = Timeout.Infinite; WebSocketMessageLoop(wsClient); } // ============================================================ // WebSocket Frame Parser (RFC 6455) // ============================================================ // // A WebSocket frame looks like this: // // 0 1 2 3 // 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 // +-+-+-+-+-------+-+-------------+-------------------------------+ // |F|R|R|R| opcode|M| Payload len | Extended payload length | // |I|S|S|S| (4) |A| (7) | (16/64) | // |N|V|V|V| |S| | (if payload len==126/127) | // | |1|2|3| |K| | | // +-+-+-+-+-------+-+-------------+-------------------------------+ // | Masking-key (if MASK == 1) | Payload Data | // +-------------------------------+-------------------------------+ // // Client-to-server frames are ALWAYS masked. // Server-to-client frames are NEVER masked. // // Opcodes: // 0x1 = text frame // 0x8 = connection close // 0x9 = ping // 0xA = pong static string ReadWebSocketFrame(NetworkStream stream, out int opcode) { opcode = -1; // Byte 1: FIN bit + opcode int byte1 = stream.ReadByte(); if (byte1 == -1) return null; opcode = byte1 & 0x0F; // Byte 2: MASK bit + payload length int byte2 = stream.ReadByte(); if (byte2 == -1) return null; bool masked = (byte2 & 0x80) != 0; long payloadLength = byte2 & 0x7F; // Extended payload length if (payloadLength == 126) { // Next 2 bytes are the actual length (big-endian) byte[] lenBytes = ReadExact(stream, 2); if (lenBytes == null) return null; payloadLength = (lenBytes[0] << 8) | lenBytes[1]; } else if (payloadLength == 127) { // Next 8 bytes are the actual length (big-endian) byte[] lenBytes = ReadExact(stream, 8); if (lenBytes == null) return null; payloadLength = 0; for (int i = 0; i < 8; i++) { payloadLength = (payloadLength << 8) | lenBytes[i]; } } // Read masking key (4 bytes, only if masked) byte[] maskKey = null; if (masked) { maskKey = ReadExact(stream, 4); if (maskKey == null) return null; } // Read payload byte[] payload = ReadExact(stream, (int)payloadLength); if (payload == null) return null; // Unmask the payload if (masked && maskKey != null) { for (int i = 0; i < payload.Length; i++) { payload[i] = (byte)(payload[i] ^ maskKey[i % 4]); } } return Encoding.UTF8.GetString(payload); } static void SendWebSocketFrame(NetworkStream stream, string message) { byte[] payload = Encoding.UTF8.GetBytes(message); // Build frame: server-to-client is NOT masked byte[] frame; if (payload.Length <= 125) { frame = new byte[2 + payload.Length]; frame[0] = 0x81; // FIN + text opcode frame[1] = (byte)payload.Length; Array.Copy(payload, 0, frame, 2, payload.Length); } else if (payload.Length <= 65535) { frame = new byte[4 + payload.Length]; frame[0] = 0x81; frame[1] = 126; frame[2] = (byte)((payload.Length >> 8) & 0xFF); frame[3] = (byte)(payload.Length & 0xFF); Array.Copy(payload, 0, frame, 4, payload.Length); } else { frame = new byte[10 + payload.Length]; frame[0] = 0x81; frame[1] = 127; long len = payload.Length; for (int i = 7; i >= 0; i--) { frame[2 + (7 - i)] = (byte)((len >> (i * 8)) & 0xFF); } Array.Copy(payload, 0, frame, 10, payload.Length); } stream.Write(frame, 0, frame.Length); stream.Flush(); } static void SendCloseFrame(NetworkStream stream) { // Close frame: opcode 0x8, no payload byte[] frame = new byte[] { 0x88, 0x00 }; try { stream.Write(frame, 0, frame.Length); stream.Flush(); } catch { } } static byte[] ReadExact(NetworkStream stream, int count) { byte[] buffer = new byte[count]; int totalRead = 0; while (totalRead < count) { int read = stream.Read(buffer, totalRead, count - totalRead); if (read == 0) return null; totalRead += read; } return buffer; } // ============================================================ // WebSocket Message Loop // ============================================================ static void WebSocketMessageLoop(WsClient wsClient) { try { while (true) { int opcode; string message = ReadWebSocketFrame(wsClient.Stream, out opcode); if (message == null || opcode == 0x8) { // Connection close Console.WriteLine($" WebSocket closed: {wsClient.Name}"); break; } if (opcode == 0x9) { // Ping — respond with pong byte[] pong = new byte[] { 0x8A, 0x00 }; wsClient.Stream.Write(pong, 0, pong.Length); wsClient.Stream.Flush(); continue; } if (opcode == 0x1) { // Text frame — process game message Console.WriteLine($" [{wsClient.Name}] {message}"); HandleGameMessage(wsClient, message); } } } catch (Exception ex) { Console.WriteLine($" WebSocket error ({wsClient.Name}): {ex.Message}"); } finally { HandleDisconnect(wsClient); lock (Lock) { AllClients.Remove(wsClient); } try { wsClient.Client.Close(); } catch { } } } // ============================================================ // Game Engine // ============================================================ static void JoinAsPlayer(WsClient wsClient) { lock (Lock) { GameRoom room; if (!string.IsNullOrEmpty(wsClient.RoomId) && Rooms.ContainsKey(wsClient.RoomId)) { room = Rooms[wsClient.RoomId]; } else { // Find a room waiting for a second player, or create a new one room = null; foreach (var r in Rooms.Values) { if (r.PlayerO == null && r.State == GameState.WaitingForPlayer) { room = r; break; } } if (room == null) { RoomCounter++; string newRoomId = "room-" + RoomCounter; room = new GameRoom { RoomId = newRoomId }; Rooms[newRoomId] = room; } } wsClient.RoomId = room.RoomId; if (room.PlayerX == null) { room.PlayerX = wsClient; wsClient.Symbol = "X"; // Tell this player they're X and waiting SendWebSocketFrame(wsClient.Stream, $"system|Joined room {room.RoomId}. You are X. Waiting for opponent..."); SendWebSocketFrame(wsClient.Stream, $"assign|X|{room.RoomId}"); } else if (room.PlayerO == null) { room.PlayerO = wsClient; wsClient.Symbol = "O"; room.State = GameState.Playing; // Tell this player they're O SendWebSocketFrame(wsClient.Stream, $"assign|O|{room.RoomId}"); // Notify both players the game has started string startMsg = $"start|{room.PlayerX.Name} (X) vs {room.PlayerO.Name} (O)"; SendWebSocketFrame(room.PlayerX.Stream, startMsg); SendWebSocketFrame(room.PlayerO.Stream, startMsg); // Send initial board state BroadcastGameState(room); } else { // Room is full — switch to spectator wsClient.Role = "spectator"; room.Spectators.Add(wsClient); SendWebSocketFrame(wsClient.Stream, "system|Room is full. You are now spectating."); SendGameStateTo(wsClient, room); } } } static void JoinAsSpectator(WsClient wsClient) { lock (Lock) { if (!string.IsNullOrEmpty(wsClient.RoomId) && Rooms.ContainsKey(wsClient.RoomId)) { GameRoom room = Rooms[wsClient.RoomId]; room.Spectators.Add(wsClient); SendWebSocketFrame(wsClient.Stream, $"system|Spectating room {room.RoomId}"); SendWebSocketFrame(wsClient.Stream, $"assign|spectator|{room.RoomId}"); SendGameStateTo(wsClient, room); } else { SendWebSocketFrame(wsClient.Stream, "system|Room not found."); } } } static void HandleGameMessage(WsClient wsClient, string message) { // Message format: "move|cellIndex" (0-8) string[] parts = message.Split('|'); if (parts[0] == "move" && parts.Length >= 2) { int cellIndex; if (!int.TryParse(parts[1], out cellIndex)) return; if (cellIndex < 0 || cellIndex > 8) return; lock (Lock) { if (!Rooms.ContainsKey(wsClient.RoomId)) return; GameRoom room = Rooms[wsClient.RoomId]; // Validate: game must be in progress if (room.State != GameState.Playing) return; // Validate: it must be this player's turn if (room.CurrentTurn != wsClient.Symbol) return; // Validate: cell must be empty if (room.Board[cellIndex] != "") return; // Make the move room.Board[cellIndex] = wsClient.Symbol; // Check for win or draw string winner = CheckWinner(room.Board); if (winner != null) { room.State = GameState.Finished; room.Winner = winner; } else if (IsBoardFull(room.Board)) { room.State = GameState.Finished; room.Winner = "draw"; } else { // Switch turns room.CurrentTurn = room.CurrentTurn == "X" ? "O" : "X"; } // Broadcast updated state to all connected clients in this room BroadcastGameState(room); } } else if (parts[0] == "rematch") { lock (Lock) { if (!Rooms.ContainsKey(wsClient.RoomId)) return; GameRoom room = Rooms[wsClient.RoomId]; if (room.State != GameState.Finished) return; // Reset the board room.Board = new string[9]; for (int i = 0; i < 9; i++) room.Board[i] = ""; room.CurrentTurn = "X"; room.State = GameState.Playing; room.Winner = null; BroadcastGameState(room); string msg = "system|New game started!"; if (room.PlayerX != null) SendSafe(room.PlayerX, msg); if (room.PlayerO != null) SendSafe(room.PlayerO, msg); foreach (var s in room.Spectators) SendSafe(s, msg); } } else if (parts[0] == "chat" && parts.Length >= 2) { lock (Lock) { if (!Rooms.ContainsKey(wsClient.RoomId)) return; GameRoom room = Rooms[wsClient.RoomId]; string chatMsg = $"chat|{wsClient.Name}|{parts[1]}"; if (room.PlayerX != null) SendSafe(room.PlayerX, chatMsg); if (room.PlayerO != null) SendSafe(room.PlayerO, chatMsg); foreach (var s in room.Spectators) SendSafe(s, chatMsg); } } } static void HandleDisconnect(WsClient wsClient) { lock (Lock) { if (!Rooms.ContainsKey(wsClient.RoomId)) return; GameRoom room = Rooms[wsClient.RoomId]; if (wsClient.Role == "spectator") { room.Spectators.Remove(wsClient); return; } // A player disconnected string msg = $"system|{wsClient.Name} disconnected."; if (room.PlayerX == wsClient) room.PlayerX = null; if (room.PlayerO == wsClient) room.PlayerO = null; // If game was in progress, the other player wins by forfeit if (room.State == GameState.Playing) { room.State = GameState.Finished; room.Winner = wsClient.Symbol == "X" ? "O" : "X"; msg = $"system|{wsClient.Name} disconnected. {room.Winner} wins by forfeit!"; } // Notify remaining player if (room.PlayerX != null) SendSafe(room.PlayerX, msg); if (room.PlayerO != null) SendSafe(room.PlayerO, msg); foreach (var s in room.Spectators) SendSafe(s, msg); if (room.State == GameState.Finished) { BroadcastGameState(room); } // Clean up empty rooms if (room.PlayerX == null && room.PlayerO == null && room.Spectators.Count == 0) { Rooms.Remove(room.RoomId); Console.WriteLine($" Room {room.RoomId} removed (empty)"); } } } // ============================================================ // Win Detection // ============================================================ static int[][] WinLines = new int[][] { new[] {0, 1, 2}, // top row new[] {3, 4, 5}, // middle row new[] {6, 7, 8}, // bottom row new[] {0, 3, 6}, // left column new[] {1, 4, 7}, // middle column new[] {2, 5, 8}, // right column new[] {0, 4, 8}, // diagonal top-left to bottom-right new[] {2, 4, 6} // diagonal top-right to bottom-left }; static string CheckWinner(string[] board) { foreach (var line in WinLines) { string a = board[line[0]]; string b = board[line[1]]; string c = board[line[2]]; if (a != "" && a == b && b == c) { return a; // "X" or "O" } } return null; } static bool IsBoardFull(string[] board) { for (int i = 0; i < 9; i++) { if (board[i] == "") return false; } return true; } // ============================================================ // State Broadcasting // ============================================================ static void BroadcastGameState(GameRoom room) { // Format: "state|board|currentTurn|gameState|winner|playerXName|playerOName" // board = comma-separated 9 cells (empty string for blank) string boardStr = string.Join(",", room.Board); string xName = room.PlayerX != null ? room.PlayerX.Name : ""; string oName = room.PlayerO != null ? room.PlayerO.Name : ""; string stateMsg = $"state|{boardStr}|{room.CurrentTurn}|{room.State}|{room.Winner ?? ""}|{xName}|{oName}"; if (room.PlayerX != null) SendSafe(room.PlayerX, stateMsg); if (room.PlayerO != null) SendSafe(room.PlayerO, stateMsg); foreach (var s in room.Spectators) SendSafe(s, stateMsg); } static void SendGameStateTo(WsClient wsClient, GameRoom room) { string boardStr = string.Join(",", room.Board); string xName = room.PlayerX != null ? room.PlayerX.Name : ""; string oName = room.PlayerO != null ? room.PlayerO.Name : ""; string stateMsg = $"state|{boardStr}|{room.CurrentTurn}|{room.State}|{room.Winner ?? ""}|{xName}|{oName}"; SendSafe(wsClient, stateMsg); } static void SendSafe(WsClient wsClient, string message) { try { SendWebSocketFrame(wsClient.Stream, message); } catch { } } // ============================================================ // Layer 2: Pages // ============================================================ // --- Lobby Page --- static string PageLobby(HttpRequest request) { // Build room list StringBuilder roomList = new StringBuilder(); lock (Lock) { if (Rooms.Count == 0) { roomList.Append("

No active rooms. Create one by joining!

"); } else { foreach (var room in Rooms.Values) { string status; string statusClass; if (room.State == GameState.WaitingForPlayer) { status = "Waiting for opponent"; statusClass = "waiting"; } else if (room.State == GameState.Playing) { status = "In progress"; statusClass = "playing"; } else { status = "Finished"; statusClass = "finished"; } string xName = room.PlayerX != null ? HtmlEncode(room.PlayerX.Name) : "—"; string oName = room.PlayerO != null ? HtmlEncode(room.PlayerO.Name) : "—"; roomList.Append($@"
{HtmlEncode(room.RoomId)} {status}
X: {xName} O: {oName} Spectators: {room.Spectators.Count}
"); } } } string htmlPage = @" Tic-Tac-Toe Lobby

Tic-Tac-Toe

WebSocket Multiplayer

Play


Active Rooms

{{roomList}}
"; htmlPage = htmlPage.Replace("{{roomList}}", roomList.ToString()); return htmlPage; } // --- Game Page --- static string PageGame(HttpRequest request) { string playerName = request.GetQuery("name"); string roomId = request.GetQuery("room"); if (string.IsNullOrEmpty(playerName)) { playerName = "Player"; } playerName = EscapeJs(playerName); roomId = EscapeJs(roomId); string htmlPage = @" Tic-Tac-Toe

Tic-Tac-Toe

Connecting...
X
O
Lobby
Connecting...
"; htmlPage = htmlPage.Replace("{{playerName}}", playerName); htmlPage = htmlPage.Replace("{{roomId}}", roomId); return htmlPage; } // --- Spectate Page --- static string PageSpectate(HttpRequest request) { string roomId = request.GetQuery("room"); string excaped_html_roomId = HtmlEncode(roomId); string escaped_js_roomId = EscapeJs(roomId); string htmlPage = @""; if (string.IsNullOrEmpty(roomId)) { htmlPage = @" Spectate

No room specified

Go to Lobby

""; } return $@"" Spectating {{excaped_html_roomId}}

Tic-Tac-Toe

Spectating
{{excaped_html_roomId}}
X
O
Lobby
Connecting...
"; htmlPage = htmlPage.Replace("{{excaped_html_roomId}}", excaped_html_roomId); htmlPage = htmlPage.Replace("{{escaped_js_roomId}}", escaped_js_roomId); } return htmlPage; } // --- 404 Page --- static string PageNotFound() { return @" Not Found

404 — Page Not Found

Go to Lobby

"; } // ============================================================ // HTTP Parser (same pattern as previous articles) // ============================================================ static HttpRequest ParseHttpRequest(string headerSection, string body) { HttpRequest request = new HttpRequest(); string[] lines = headerSection.Split(new[] { "\r\n" }, StringSplitOptions.None); string[] requestLine = lines[0].Split(' '); request.Method = requestLine[0].ToUpper(); request.Path = requestLine.Length > 1 ? requestLine[1] : "/"; for (int i = 1; i < lines.Length; i++) { int colon = lines[i].IndexOf(':'); if (colon > 0) { string key = lines[i].Substring(0, colon).Trim(); string value = lines[i].Substring(colon + 1).Trim(); request.Headers[key] = value; } } int qIndex = request.Path.IndexOf('?'); if (qIndex >= 0) { string queryString = request.Path.Substring(qIndex + 1); request.Path = request.Path.Substring(0, qIndex); ParseFormData(queryString, request.Query); } if (request.Method == "POST" && body.Length > 0) { ParseFormData(body, request.Form); } request.Path = request.Path.ToLower().Trim().TrimEnd('/'); if (request.Path == "") request.Path = "/"; return request; } static void ParseFormData(string data, Dictionary target) { string[] pairs = data.Split('&'); foreach (string pair in pairs) { string[] kv = pair.Split(new[] { '=' }, 2); string key = HttpUtility.UrlDecode(kv[0]); string value = kv.Length > 1 ? HttpUtility.UrlDecode(kv[1]) : ""; target[key] = value; } } // ============================================================ // Utility // ============================================================ static string HtmlEncode(string text) { if (string.IsNullOrEmpty(text)) return ""; return text.Replace("&", "&") .Replace("<", "<") .Replace(">", ">") .Replace("\"", """) .Replace("'", "'"); } static string EscapeJs(string text) { if (string.IsNullOrEmpty(text)) return ""; return text.Replace("\\", "\\\\") .Replace("'", "\\'") .Replace("\"", "\\\"") .Replace("\n", "\\n") .Replace("\r", "\\r"); } } // ============================================================ // Models // ============================================================ enum GameState { WaitingForPlayer, Playing, Finished } class GameRoom { public string RoomId { get; set; } public WsClient PlayerX { get; set; } public WsClient PlayerO { get; set; } public List Spectators { get; set; } = new List(); public string[] Board { get; set; } = new string[] { "", "", "", "", "", "", "", "", "" }; public string CurrentTurn { get; set; } = "X"; public GameState State { get; set; } = GameState.WaitingForPlayer; public string Winner { get; set; } } class WsClient { public TcpClient Client { get; set; } public NetworkStream Stream { get; set; } public string Name { get; set; } public string RoomId { get; set; } public string Role { get; set; } // "player" or "spectator" public string Symbol { get; set; } // "X" or "O" } class HttpRequest { public string Method { get; set; } = "GET"; public string Path { get; set; } = "/"; public Dictionary Headers { get; set; } = new Dictionary(StringComparer.OrdinalIgnoreCase); public Dictionary Query { get; set; } = new Dictionary(StringComparer.OrdinalIgnoreCase); public Dictionary Form { get; set; } = new Dictionary(StringComparer.OrdinalIgnoreCase); public string GetHeader(string key) { return Headers.ContainsKey(key) ? Headers[key] : null; } public string GetForm(string key) { return Form.ContainsKey(key) ? Form[key] : ""; } public int GetFormInt(string key) { string val = GetForm(key); int result; int.TryParse(val, out result); return result; } public string GetQuery(string key) { return Query.ContainsKey(key) ? Query[key] : ""; } } }