diff --git a/GameCli.Tests/ProtocolTests.cs b/GameCli.Tests/ProtocolTests.cs
new file mode 100644
index 0000000..bc47a1e
--- /dev/null
+++ b/GameCli.Tests/ProtocolTests.cs
@@ -0,0 +1,81 @@
+using System.Text.Json;
+using GameCli;
+using Xunit;
+
+namespace GameCli.Tests;
+
+public class ProtocolTests
+{
+ [Fact]
+ public void StepInput_ParsesActionAndTarget()
+ {
+ var json = """{"action":2,"target":[0.35,0.60]}""";
+ var input = JsonSerializer.Deserialize(json, ProtocolJsonContext.Default.StepInput);
+ Assert.NotNull(input);
+ Assert.Equal(2, input!.Action);
+ Assert.NotNull(input.Target);
+ Assert.Equal(0.35f, input.Target![0]);
+ Assert.Equal(0.60f, input.Target![1]);
+ Assert.Null(input.Cmd);
+ }
+
+ [Fact]
+ public void StepInput_ParsesResetCommand()
+ {
+ var json = """{"cmd":"reset","seed":12345}""";
+ var input = JsonSerializer.Deserialize(json, ProtocolJsonContext.Default.StepInput);
+ Assert.NotNull(input);
+ Assert.Equal("reset", input!.Cmd);
+ Assert.Equal(12345, input.Seed);
+ }
+
+ [Fact]
+ public void StepInput_ParsesResetCommandWithoutSeed()
+ {
+ var json = """{"cmd":"reset"}""";
+ var input = JsonSerializer.Deserialize(json, ProtocolJsonContext.Default.StepInput);
+ Assert.NotNull(input);
+ Assert.Equal("reset", input!.Cmd);
+ Assert.Null(input.Seed);
+ }
+
+ [Fact]
+ public void StepOutput_SerializesAllFields()
+ {
+ var output = new StepOutput
+ {
+ Obs = new[] { 0.1f, 0.2f, 0.3f, 0.4f, 0.5f, 0.6f, 0.7f },
+ State = new ShipStateDto { X = 0.5f, Y = 0.6f, Angle = 0.1f, Engine = 2 },
+ Reward = -0.42f,
+ Done = false,
+ Step = 137,
+ };
+ var json = JsonSerializer.Serialize(output, ProtocolJsonContext.Default.StepOutput);
+ Assert.Contains("\"obs\":", json);
+ Assert.Contains("\"state\":", json);
+ Assert.Contains("\"reward\":", json);
+ Assert.Contains("\"done\":false", json);
+ Assert.Contains("\"step\":137", json);
+ Assert.Contains("\"engine\":2", json);
+ }
+
+ [Fact]
+ public void InitMessage_SerializesExpectedShape()
+ {
+ var init = new InitMessage
+ {
+ Init = new InitPayload
+ {
+ World = new[] { 1.0f, 1.0f },
+ Dt = 0.02f,
+ ObsDim = 7,
+ NActions = 4,
+ },
+ };
+ var json = JsonSerializer.Serialize(init, ProtocolJsonContext.Default.InitMessage);
+ Assert.Contains("\"world\":[1,1]", json);
+ Assert.Contains("\"dt\":0.02", json);
+ Assert.Contains("\"obs_dim\":7", json);
+ Assert.Contains("\"n_actions\":4", json);
+ }
+}
diff --git a/GameCli/GameCli.csproj b/GameCli/GameCli.csproj
index 206b89a..fb5fa1f 100644
--- a/GameCli/GameCli.csproj
+++ b/GameCli/GameCli.csproj
@@ -7,4 +7,8 @@
enable
+
+
+
+
diff --git a/GameCli/Protocol.cs b/GameCli/Protocol.cs
new file mode 100644
index 0000000..f3ede99
--- /dev/null
+++ b/GameCli/Protocol.cs
@@ -0,0 +1,56 @@
+using System.Text.Json.Serialization;
+
+namespace GameCli;
+
+/// Input line from the client (per step OR a reset command).
+public sealed class StepInput
+{
+ [JsonPropertyName("action")] public int? Action { get; set; }
+ [JsonPropertyName("target")] public float[]? Target { get; set; }
+ [JsonPropertyName("cmd")] public string? Cmd { get; set; }
+ [JsonPropertyName("seed")] public int? Seed { get; set; }
+}
+
+/// Output line from the CLI after each step or reset.
+public sealed class StepOutput
+{
+ [JsonPropertyName("obs")] public float[] Obs { get; set; } = System.Array.Empty();
+ [JsonPropertyName("state")] public ShipStateDto State { get; set; } = new();
+ [JsonPropertyName("reward")] public float Reward { get; set; }
+ [JsonPropertyName("done")] public bool Done { get; set; }
+ [JsonPropertyName("step")] public int Step { get; set; }
+}
+
+/// Renderable ship pose (subset of ShipState that the UI actually draws).
+public sealed class ShipStateDto
+{
+ [JsonPropertyName("x")] public float X { get; set; }
+ [JsonPropertyName("y")] public float Y { get; set; }
+ [JsonPropertyName("angle")] public float Angle { get; set; }
+ [JsonPropertyName("engine")] public int Engine { get; set; }
+}
+
+/// Startup handshake emitted as the very first stdout line.
+public sealed class InitMessage
+{
+ [JsonPropertyName("init")] public InitPayload Init { get; set; } = new();
+}
+
+public sealed class InitPayload
+{
+ [JsonPropertyName("world")] public float[] World { get; set; } = new[] { 1f, 1f };
+ [JsonPropertyName("dt")] public float Dt { get; set; }
+ [JsonPropertyName("obs_dim")] public int ObsDim { get; set; }
+ [JsonPropertyName("n_actions")] public int NActions { get; set; }
+}
+
+/// Source-generated JSON contracts for AOT-friendly, allocation-lean (de)serialization.
+[JsonSourceGenerationOptions(WriteIndented = false)]
+[JsonSerializable(typeof(StepInput))]
+[JsonSerializable(typeof(StepOutput))]
+[JsonSerializable(typeof(ShipStateDto))]
+[JsonSerializable(typeof(InitMessage))]
+[JsonSerializable(typeof(InitPayload))]
+internal partial class ProtocolJsonContext : JsonSerializerContext
+{
+}