diff --git a/Backend.Tests/GameProcessTests.cs b/Backend.Tests/GameProcessTests.cs
new file mode 100644
index 0000000..fd0e2eb
--- /dev/null
+++ b/Backend.Tests/GameProcessTests.cs
@@ -0,0 +1,54 @@
+using System.IO;
+using Backend;
+using Xunit;
+
+namespace Backend.Tests;
+
+public class GameProcessTests
+{
+ private static string LocateCliBinary()
+ {
+ var dir = AppContext.BaseDirectory;
+ while (dir is not null)
+ {
+ var candidate = Path.Combine(dir, "publish", "GameCli", "GameCli");
+ if (File.Exists(candidate)) return candidate;
+ var parent = Directory.GetParent(dir);
+ if (parent is null) break;
+ dir = parent.FullName;
+ }
+ throw new FileNotFoundException(
+ "publish/GameCli/GameCli not found — run `dotnet publish GameCli -c Release -o publish/GameCli`");
+ }
+
+ [Fact]
+ public async Task StartsAndReadsInitHandshake()
+ {
+ await using var proc = new GameProcess(LocateCliBinary());
+ await proc.StartAsync();
+ Assert.Equal(0.02f, proc.Dt);
+ Assert.Equal(7, proc.ObsDim);
+ Assert.Equal(4, proc.NActions);
+ }
+
+ [Fact]
+ public async Task StepReturnsSevenDimObservation()
+ {
+ await using var proc = new GameProcess(LocateCliBinary());
+ await proc.StartAsync();
+ var obs = await proc.StepAsync(action: 0, target: (0.5f, 0.5f));
+ Assert.Equal(7, obs.Observation.Length);
+ Assert.Equal(1, obs.Step);
+ }
+
+ [Fact]
+ public async Task ResetClearsStepCounter()
+ {
+ await using var proc = new GameProcess(LocateCliBinary());
+ await proc.StartAsync();
+ for (int i = 0; i < 3; i++)
+ await proc.StepAsync(action: 0, target: (0.5f, 0.5f));
+ var afterReset = await proc.ResetAsync(seed: 42);
+ Assert.Equal(0, afterReset.Step);
+ }
+}
diff --git a/Backend/GameProcess.cs b/Backend/GameProcess.cs
new file mode 100644
index 0000000..f97254a
--- /dev/null
+++ b/Backend/GameProcess.cs
@@ -0,0 +1,166 @@
+using System.Diagnostics;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Backend;
+
+///
+/// One instance == one GameCli subprocess. Not thread-safe.
+/// Callers must sequence and .
+///
+public sealed class GameProcess : IAsyncDisposable
+{
+ private readonly string _binaryPath;
+ private Process? _proc;
+ private StreamReader? _stdout;
+ private StreamWriter? _stdin;
+
+ public float Dt { get; private set; }
+ public int ObsDim { get; private set; }
+ public int NActions { get; private set; }
+
+ public GameProcess(string binaryPath)
+ {
+ _binaryPath = binaryPath;
+ }
+
+ public async Task StartAsync()
+ {
+ if (_proc is not null) throw new InvalidOperationException("already started");
+ if (!File.Exists(_binaryPath))
+ throw new FileNotFoundException($"GameCli binary not found at {_binaryPath}");
+
+ var psi = new ProcessStartInfo
+ {
+ FileName = _binaryPath,
+ RedirectStandardInput = true,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false,
+ };
+ _proc = Process.Start(psi)
+ ?? throw new InvalidOperationException("failed to start GameCli");
+ _stdout = _proc.StandardOutput;
+ _stdin = _proc.StandardInput;
+
+ var line = await _stdout.ReadLineAsync()
+ ?? throw new IOException("GameCli died before init handshake");
+ var init = JsonSerializer.Deserialize(line, ProcessJsonContext.Default.InitEnvelope)
+ ?? throw new IOException($"malformed init handshake: {line}");
+ Dt = init.Init.Dt;
+ ObsDim = init.Init.ObsDim;
+ NActions = init.Init.NActions;
+ }
+
+ public async Task StepAsync(int action, (float X, float Y) target)
+ {
+ var input = new StepIn
+ {
+ Action = action,
+ Target = new[] { target.X, target.Y },
+ };
+ await WriteAsync(input, ProcessJsonContext.Default.StepIn);
+ return await ReadStepResultAsync();
+ }
+
+ public async Task ResetAsync(int? seed = null)
+ {
+ var input = new StepIn { Cmd = "reset", Seed = seed };
+ await WriteAsync(input, ProcessJsonContext.Default.StepIn);
+ return await ReadStepResultAsync();
+ }
+
+ private async Task WriteAsync(T value, System.Text.Json.Serialization.Metadata.JsonTypeInfo ctx)
+ {
+ if (_stdin is null) throw new InvalidOperationException("not started");
+ var json = JsonSerializer.Serialize(value, ctx);
+ await _stdin.WriteLineAsync(json);
+ await _stdin.FlushAsync();
+ }
+
+ private async Task ReadStepResultAsync()
+ {
+ if (_stdout is null) throw new InvalidOperationException("not started");
+ var line = await _stdout.ReadLineAsync()
+ ?? throw new IOException("GameCli closed stdout unexpectedly");
+ var msg = JsonSerializer.Deserialize(line, ProcessJsonContext.Default.StepOut)
+ ?? throw new IOException($"malformed step output: {line}");
+ return new StepResult(msg.Obs, msg.State, msg.Reward, msg.Done, msg.Step);
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ try { _stdin?.Close(); } catch { }
+ if (_proc is not null)
+ {
+ try
+ {
+ var exited = _proc.WaitForExit(2000);
+ if (!exited) _proc.Kill(entireProcessTree: true);
+ }
+ catch { }
+ _proc.Dispose();
+ _proc = null;
+ }
+ await ValueTask.CompletedTask;
+ }
+}
+
+// -------- Result --------
+
+public readonly record struct StepResult(
+ float[] Observation,
+ ShipStatePayload State,
+ float Reward,
+ bool Done,
+ int Step);
+
+// -------- Wire DTOs (local mirror of GameCli's Protocol.cs) --------
+
+public sealed class StepIn
+{
+ [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; }
+}
+
+public sealed class StepOut
+{
+ [JsonPropertyName("obs")] public float[] Obs { get; set; } = Array.Empty();
+ [JsonPropertyName("state")] public ShipStatePayload 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; }
+}
+
+public sealed class ShipStatePayload
+{
+ [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; }
+}
+
+public sealed class InitEnvelope
+{
+ [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; }
+}
+
+[JsonSourceGenerationOptions(WriteIndented = false)]
+[JsonSerializable(typeof(StepIn))]
+[JsonSerializable(typeof(StepOut))]
+[JsonSerializable(typeof(ShipStatePayload))]
+[JsonSerializable(typeof(InitEnvelope))]
+[JsonSerializable(typeof(InitPayload))]
+internal partial class ProcessJsonContext : JsonSerializerContext
+{
+}