diff --git a/SVSim.BattleNode/Sessions/BattleSession.cs b/SVSim.BattleNode/Sessions/BattleSession.cs
new file mode 100644
index 0000000..a7ef774
--- /dev/null
+++ b/SVSim.BattleNode/Sessions/BattleSession.cs
@@ -0,0 +1,119 @@
+using System.Net.WebSockets;
+using System.Text;
+using Microsoft.Extensions.Logging;
+using SVSim.BattleNode.Protocol;
+using SVSim.BattleNode.Reliability;
+using SVSim.BattleNode.Wire;
+
+namespace SVSim.BattleNode.Sessions;
+
+///
+/// One per connected client. Owns the WebSocket + reliability ledgers + lifecycle phase.
+///
+public sealed class BattleSession
+{
+ private readonly WebSocket _ws;
+ private readonly ILogger _log;
+
+ public string BattleId { get; }
+ public long ViewerId { get; }
+ public BattleSessionPhase Phase { get; internal set; } = BattleSessionPhase.AwaitingInitNetwork;
+ public InboundTracker Inbound { get; } = new();
+ public OutboundSequencer Outbound { get; } = new();
+
+ public BattleSession(WebSocket ws, string battleId, long viewerId, ILogger log)
+ {
+ _ws = ws;
+ _log = log;
+ BattleId = battleId;
+ ViewerId = viewerId;
+ }
+
+ ///
+ /// Send the EIO3 open handshake then enter the read loop. Returns when the WS closes.
+ /// Dispatch is a no-op in this task; Task 13 fills it in.
+ ///
+ public async Task RunAsync(CancellationToken cancellation)
+ {
+ await SendEioOpenAsync(cancellation);
+
+ var buffer = new byte[8192];
+ var pendingAttachments = new List();
+ SocketIoFrame? pendingFrame = null;
+
+ while (_ws.State == WebSocketState.Open && !cancellation.IsCancellationRequested)
+ {
+ var msg = await ReadCompleteMessageAsync(buffer, cancellation);
+ if (msg is null) break;
+
+ if (msg.Value.IsText)
+ {
+ var text = Encoding.UTF8.GetString(msg.Value.Bytes);
+ if (text.Length == 0) continue;
+ var eio = EngineIoFrame.Parse(text);
+ if (eio.Type == EngineIoPacketType.Ping)
+ {
+ await SendTextAsync("3", cancellation); // EIO3 pong
+ continue;
+ }
+ if (eio.Type != EngineIoPacketType.Message) continue;
+
+ // SIO inside the message payload.
+ var sio = SocketIoFrame.Parse(eio.Payload);
+ if (sio.AttachmentCount > 0)
+ {
+ pendingFrame = sio;
+ pendingAttachments.Clear();
+ continue;
+ }
+ DispatchSocketIo(sio);
+ }
+ else
+ {
+ // Binary frame — an attachment for a pending binary event.
+ pendingAttachments.Add(msg.Value.Bytes);
+ if (pendingFrame is not null && pendingAttachments.Count == pendingFrame.AttachmentCount)
+ {
+ var assembled = pendingFrame.WithAttachments(pendingAttachments.ToArray());
+ pendingFrame = null;
+ DispatchSocketIo(assembled);
+ }
+ }
+ }
+ }
+
+ private void DispatchSocketIo(SocketIoFrame frame)
+ {
+ // v1 task 11 stub: log and drop. Task 13 wires the lifecycle dispatch in.
+ _log.LogDebug("BattleSession {Bid}: received SIO {Type} event={Event}",
+ BattleId, frame.Type, frame.EventName);
+ }
+
+ private async Task SendEioOpenAsync(CancellationToken ct)
+ {
+ var sid = Guid.NewGuid().ToString("N").Substring(0, 16);
+ var handshake = new EngineIoHandshake(sid, Array.Empty(), 25000, 60000).ToJson();
+ await SendTextAsync($"0{handshake}", ct);
+ }
+
+ private Task SendTextAsync(string text, CancellationToken ct)
+ {
+ var bytes = Encoding.UTF8.GetBytes(text);
+ return _ws.SendAsync(bytes, WebSocketMessageType.Text, endOfMessage: true, ct);
+ }
+
+ private async Task<(byte[] Bytes, bool IsText)?> ReadCompleteMessageAsync(byte[] buffer, CancellationToken ct)
+ {
+ using var ms = new MemoryStream();
+ WebSocketReceiveResult result;
+ do
+ {
+ try { result = await _ws.ReceiveAsync(buffer, ct); }
+ catch (OperationCanceledException) { return null; }
+ catch (WebSocketException) { return null; }
+ if (result.MessageType == WebSocketMessageType.Close) return null;
+ ms.Write(buffer, 0, result.Count);
+ } while (!result.EndOfMessage);
+ return (ms.ToArray(), result.MessageType == WebSocketMessageType.Text);
+ }
+}
diff --git a/SVSim.BattleNode/Sessions/BattleSessionPhase.cs b/SVSim.BattleNode/Sessions/BattleSessionPhase.cs
new file mode 100644
index 0000000..8e4abe1
--- /dev/null
+++ b/SVSim.BattleNode/Sessions/BattleSessionPhase.cs
@@ -0,0 +1,14 @@
+namespace SVSim.BattleNode.Sessions;
+
+///
+/// Where we are in the v1 scripted lifecycle. Drives which scripted frames the session pushes
+/// in response to inbound emits.
+///
+public enum BattleSessionPhase
+{
+ AwaitingInitNetwork,
+ AwaitingLoaded,
+ AwaitingSwap,
+ AfterReady,
+ Terminal,
+}