diff --git a/unity-client/Assets/Scripts/Services/GameWebSocketClient.cs b/unity-client/Assets/Scripts/Services/GameWebSocketClient.cs
new file mode 100644
index 0000000..7174db0
--- /dev/null
+++ b/unity-client/Assets/Scripts/Services/GameWebSocketClient.cs
@@ -0,0 +1,181 @@
+using System;
+using System.Collections;
+using System.Collections.Concurrent;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using UnityEngine;
+using Newtonsoft.Json;
+using CommanderAILab.UI;
+
+namespace CommanderAILab.Services
+{
+ ///
+ /// WebSocket client for the live game backend (ws://localhost:8080/ws/game).
+ /// - Connects on Start and auto-reconnects on disconnect.
+ /// - Sends a ping every 15 s to keep the connection alive.
+ /// - All GameStateManager.ApplyServerEvent calls are dispatched
+ /// on the Unity main thread via a thread-safe queue.
+ ///
+ public class GameWebSocketClient : MonoBehaviour
+ {
+ [SerializeField] private string serverUrl = "ws://localhost:8080/ws/game";
+ [SerializeField] private float reconnectDelay = 3f;
+ [SerializeField] private float pingIntervalSec = 15f;
+
+ private System.Net.WebSockets.ClientWebSocket _ws;
+ private CancellationTokenSource _cts;
+ private readonly ConcurrentQueue<(string type, string payload)> _mainThreadQueue = new();
+ private bool _running;
+ private float _pingTimer;
+
+ // ── Lifecycle ────────────────────────────────────────────────────────
+ private void Start()
+ {
+ // Allow override via PlayerPrefs so users can point at a remote server
+ string saved = PlayerPrefs.GetString("ServerUrl", "");
+ if (!string.IsNullOrEmpty(saved)) serverUrl = saved.TrimEnd('/') + "/ws/game";
+
+ _running = true;
+ StartCoroutine(ConnectLoop());
+ }
+
+ private void Update()
+ {
+ // Drain main-thread queue
+ while (_mainThreadQueue.TryDequeue(out var msg))
+ GameStateManager.Instance?.ApplyServerEvent(msg.type, msg.payload);
+
+ // Ping
+ _pingTimer += Time.deltaTime;
+ if (_pingTimer >= pingIntervalSec)
+ {
+ _pingTimer = 0f;
+ _ = SendRawAsync("{\"type\":\"ping\"}");
+ }
+ }
+
+ private void OnDestroy()
+ {
+ _running = false;
+ _cts?.Cancel();
+ _ws?.Dispose();
+ }
+
+ // ── Connection loop ──────────────────────────────────────────────────
+ private IEnumerator ConnectLoop()
+ {
+ while (_running)
+ {
+ _cts = new CancellationTokenSource();
+ _ws = new System.Net.WebSockets.ClientWebSocket();
+
+ bool connected = false;
+ var connectTask = _ws.ConnectAsync(new Uri(serverUrl), _cts.Token);
+
+ yield return new WaitUntil(() => connectTask.IsCompleted);
+
+ if (!connectTask.IsFaulted && !connectTask.IsCanceled)
+ {
+ connected = true;
+ Debug.Log("[GameWebSocketClient] Connected to " + serverUrl);
+ _ = ReceiveLoop();
+ }
+ else
+ {
+ Debug.LogWarning($"[GameWebSocketClient] Connect failed: {connectTask.Exception?.Message}");
+ }
+
+ if (!connected)
+ {
+ yield return new WaitForSeconds(reconnectDelay);
+ }
+ else
+ {
+ // Stay here until the WS closes
+ yield return new WaitUntil(() =>
+ _ws.State != System.Net.WebSockets.WebSocketState.Open);
+
+ Debug.Log("[GameWebSocketClient] Disconnected — reconnecting...");
+ yield return new WaitForSeconds(reconnectDelay);
+ }
+ }
+ }
+
+ // ── Receive loop (background thread) ────────────────────────────────
+ private async Task ReceiveLoop()
+ {
+ var buffer = new byte[8192];
+ try
+ {
+ while (_ws.State == System.Net.WebSockets.WebSocketState.Open)
+ {
+ var sb = new StringBuilder();
+ System.Net.WebSockets.WebSocketReceiveResult result;
+ do
+ {
+ result = await _ws.ReceiveAsync(
+ new ArraySegment(buffer), _cts.Token);
+ sb.Append(Encoding.UTF8.GetString(buffer, 0, result.Count));
+ } while (!result.EndOfMessage);
+
+ if (result.MessageType ==
+ System.Net.WebSockets.WebSocketMessageType.Close)
+ break;
+
+ string raw = sb.ToString();
+ ParseAndEnqueue(raw);
+ }
+ }
+ catch (OperationCanceledException) { }
+ catch (Exception ex)
+ {
+ Debug.LogWarning($"[GameWebSocketClient] ReceiveLoop error: {ex.Message}");
+ }
+ }
+
+ // ── Parse incoming JSON envelope ─────────────────────────────────────
+ private void ParseAndEnqueue(string raw)
+ {
+ try
+ {
+ var env = JsonConvert.DeserializeObject(raw);
+ if (env == null) return;
+
+ if (env.type == "pong") return; // ignore keepalive responses
+
+ _mainThreadQueue.Enqueue((env.type, env.payload ?? raw));
+ }
+ catch
+ {
+ Debug.LogWarning($"[GameWebSocketClient] Failed to parse: {raw}");
+ }
+ }
+
+ // ── Send (public for HumanActionBar fallback) ────────────────────────
+ public async Task SendRawAsync(string json)
+ {
+ if (_ws == null ||
+ _ws.State != System.Net.WebSockets.WebSocketState.Open) return;
+ try
+ {
+ var bytes = Encoding.UTF8.GetBytes(json);
+ await _ws.SendAsync(new ArraySegment(bytes),
+ System.Net.WebSockets.WebSocketMessageType.Text,
+ true, _cts?.Token ?? CancellationToken.None);
+ }
+ catch (Exception ex)
+ {
+ Debug.LogWarning($"[GameWebSocketClient] Send error: {ex.Message}");
+ }
+ }
+
+ // ── POCO ─────────────────────────────────────────────────────────────
+ [Serializable]
+ private class WsEnvelope
+ {
+ [JsonProperty("type")] public string type;
+ [JsonProperty("payload")] public string payload;
+ }
+ }
+}