diff --git a/SVSim.BattleNode/Wire/EngineIoFrame.cs b/SVSim.BattleNode/Wire/EngineIoFrame.cs
new file mode 100644
index 0000000..6bf88e7
--- /dev/null
+++ b/SVSim.BattleNode/Wire/EngineIoFrame.cs
@@ -0,0 +1,21 @@
+namespace SVSim.BattleNode.Wire;
+
+///
+/// Engine.IO v3 packet in WebSocket transport mode. Wire form: <digit><payload>.
+///
+public sealed record EngineIoFrame(EngineIoPacketType Type, string Payload)
+{
+ public static EngineIoFrame Parse(string raw)
+ {
+ if (string.IsNullOrEmpty(raw))
+ throw new ArgumentException("Empty EIO frame", nameof(raw));
+ var typeChar = raw[0];
+ if (typeChar < '0' || typeChar > '6')
+ throw new ArgumentException($"Invalid EIO type char '{typeChar}'", nameof(raw));
+ var type = (EngineIoPacketType)(typeChar - '0');
+ var payload = raw.Length > 1 ? raw.Substring(1) : string.Empty;
+ return new EngineIoFrame(type, payload);
+ }
+
+ public string Encode() => $"{(int)Type}{Payload}";
+}
diff --git a/SVSim.BattleNode/Wire/EngineIoHandshake.cs b/SVSim.BattleNode/Wire/EngineIoHandshake.cs
new file mode 100644
index 0000000..8343728
--- /dev/null
+++ b/SVSim.BattleNode/Wire/EngineIoHandshake.cs
@@ -0,0 +1,22 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace SVSim.BattleNode.Wire;
+
+///
+/// Payload of an EIO3 Open packet. Sent by the server to the client immediately after the WS upgrade.
+///
+public sealed record EngineIoHandshake(
+ [property: JsonPropertyName("sid")] string Sid,
+ [property: JsonPropertyName("upgrades")] string[] Upgrades,
+ [property: JsonPropertyName("pingInterval")] int PingInterval,
+ [property: JsonPropertyName("pingTimeout")] int PingTimeout)
+{
+ // Wire-key casing here is bare camelCase — NOT EmulatedEntrypoint's snake_case policy.
+ private static readonly JsonSerializerOptions Options = new()
+ {
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ };
+
+ public string ToJson() => JsonSerializer.Serialize(this, Options);
+}
diff --git a/SVSim.BattleNode/Wire/EngineIoPacketType.cs b/SVSim.BattleNode/Wire/EngineIoPacketType.cs
new file mode 100644
index 0000000..5c64b7c
--- /dev/null
+++ b/SVSim.BattleNode/Wire/EngineIoPacketType.cs
@@ -0,0 +1,12 @@
+namespace SVSim.BattleNode.Wire;
+
+public enum EngineIoPacketType
+{
+ Open = 0,
+ Close = 1,
+ Ping = 2,
+ Pong = 3,
+ Message = 4,
+ Upgrade = 5,
+ Noop = 6,
+}
diff --git a/SVSim.UnitTests/BattleNode/Wire/EngineIoFrameTests.cs b/SVSim.UnitTests/BattleNode/Wire/EngineIoFrameTests.cs
new file mode 100644
index 0000000..4735857
--- /dev/null
+++ b/SVSim.UnitTests/BattleNode/Wire/EngineIoFrameTests.cs
@@ -0,0 +1,51 @@
+using NUnit.Framework;
+using SVSim.BattleNode.Wire;
+
+namespace SVSim.UnitTests.BattleNode.Wire;
+
+[TestFixture]
+public class EngineIoFrameTests
+{
+ [TestCase("0{\"sid\":\"abc\"}", EngineIoPacketType.Open, "{\"sid\":\"abc\"}")]
+ [TestCase("2", EngineIoPacketType.Ping, "")]
+ [TestCase("3", EngineIoPacketType.Pong, "")]
+ [TestCase("4hello", EngineIoPacketType.Message, "hello")]
+ public void Parse_RecognizesTypeAndPayload(string raw, EngineIoPacketType expectedType, string expectedPayload)
+ {
+ var frame = EngineIoFrame.Parse(raw);
+
+ Assert.That(frame.Type, Is.EqualTo(expectedType));
+ Assert.That(frame.Payload, Is.EqualTo(expectedPayload));
+ }
+
+ [TestCase(EngineIoPacketType.Open, "{\"sid\":\"abc\"}", "0{\"sid\":\"abc\"}")]
+ [TestCase(EngineIoPacketType.Pong, "", "3")]
+ [TestCase(EngineIoPacketType.Message, "2[\"msg\",{}]", "42[\"msg\",{}]")]
+ public void Encode_EmitsTypeDigitFollowedByPayload(EngineIoPacketType type, string payload, string expected)
+ {
+ var frame = new EngineIoFrame(type, payload);
+
+ Assert.That(frame.Encode(), Is.EqualTo(expected));
+ }
+
+ [Test]
+ public void Parse_EmptyInput_Throws()
+ {
+ Assert.Throws(() => EngineIoFrame.Parse(""));
+ }
+
+ [Test]
+ public void Parse_NonDigitFirstChar_Throws()
+ {
+ Assert.Throws(() => EngineIoFrame.Parse("xfoo"));
+ }
+
+ [Test]
+ public void EngineIoHandshake_SerializesWithCamelCaseKeys()
+ {
+ var hs = new EngineIoHandshake("abc123", Array.Empty(), 25000, 60000);
+
+ Assert.That(hs.ToJson(),
+ Is.EqualTo("{\"sid\":\"abc123\",\"upgrades\":[],\"pingInterval\":25000,\"pingTimeout\":60000}"));
+ }
+}