diff --git a/docs/superpowers/plans/2026-07-17-03-backend.md b/docs/superpowers/plans/2026-07-17-03-backend.md
new file mode 100644
index 0000000..a7e0afa
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-17-03-backend.md
@@ -0,0 +1,1095 @@
+# ASP.NET Backend Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Build the ASP.NET Core Web API that sits between the React frontend and the Game CLI. Per connected browser: accept a WebSocket at `/ws/game`, spawn one `GameCli` subprocess, run a 50 Hz tick loop that asks the trained PPO policy (loaded from ONNX) for an action and steps the CLI, and forward each new state to the browser. Reference spec at `docs/superpowers/specs/2026-07-17-cursor-following-lander-design.md` §3.
+
+**Architecture:** One `Backend/` .NET 8 web project. Each WebSocket connection owns a `GameSession` that owns a `GameProcess` (CLI subprocess). A single `PolicyRunner` (registered as a singleton) holds the loaded ONNX inference session and is called by all sessions. No SignalR — raw WebSocket at `/ws/game`. The frontend build will eventually be served as static files from the same host; that wiring is out of scope for this plan.
+
+**Tech Stack:** .NET 8 SDK, ASP.NET Core, `Microsoft.ML.OnnxRuntime`, `System.Threading.Channels`, xUnit for tests.
+
+---
+
+## Prerequisites
+
+- **Plan 1 complete.** The `GameCli` binary must be publishable at `publish/GameCli/GameCli`.
+- **Plan 2 complete.** A trained model must exist at `models/ppo_lander.onnx` (even a smoke-quality one is fine — the backend just needs a valid ONNX file to load).
+
+---
+
+## File structure
+
+```
+Experiment_ReinforcementLearning/
+├── Backend/
+│ ├── Backend.csproj
+│ ├── Program.cs # startup + WebSocket routing
+│ ├── LanderConfig.cs # strongly-typed config (paths)
+│ ├── GameProcess.cs # CLI subprocess wrapper
+│ ├── PolicyRunner.cs # ONNX inference wrapper (singleton)
+│ ├── GameSession.cs # per-connection tick loop
+│ ├── WireMessages.cs # WebSocket JSON DTOs (client↔server)
+│ └── appsettings.json # default Lander config
+└── Backend.Tests/
+ ├── Backend.Tests.csproj
+ ├── GameProcessTests.cs
+ ├── PolicyRunnerTests.cs
+ └── EndToEndSmokeTests.cs
+```
+
+`Program.cs` is the only file that touches ASP.NET plumbing. `GameProcess`/`PolicyRunner`/`GameSession` are testable in isolation.
+
+---
+
+## Configuration convention
+
+Both the CLI and ONNX paths are resolved at startup relative to a "repo root" anchor computed by walking up from `AppContext.BaseDirectory` until we find `GameCli.sln`. This keeps `dotnet run --project Backend` working from any CWD.
+
+- `Lander:CliPath` (default: `publish/GameCli/GameCli` — relative to repo root)
+- `Lander:ModelPath` (default: `models/ppo_lander.onnx` — relative to repo root)
+- Env vars `LANDER__CliPath` and `LANDER__ModelPath` override (ASP.NET double-underscore convention; case-insensitive so `LANDER__CLIPATH` also works).
+
+---
+
+## Task 1: Scaffold Backend project
+
+**Files:**
+- Create: `Backend/Backend.csproj`
+- Create: `Backend/Program.cs` (stub — replaced in Task 6)
+- Create: `Backend/appsettings.json`
+- Create: `Backend.Tests/Backend.Tests.csproj`
+- Modify: `GameCli.sln` (add both projects)
+
+- [ ] **Step 1: Create the projects and add to solution**
+
+Run from the repo root:
+
+```bash
+dotnet new webapi -n Backend -o Backend -f net8.0 --no-https --use-controllers false
+dotnet new xunit -n Backend.Tests -o Backend.Tests -f net8.0
+dotnet sln add Backend/Backend.csproj Backend.Tests/Backend.Tests.csproj
+dotnet add Backend.Tests/Backend.Tests.csproj reference Backend/Backend.csproj
+dotnet add Backend/Backend.csproj package Microsoft.ML.OnnxRuntime --version 1.19.*
+dotnet add Backend.Tests/Backend.Tests.csproj package Microsoft.AspNetCore.Mvc.Testing --version 8.0.*
+```
+
+- [ ] **Step 2: Delete boilerplate**
+
+The webapi template creates `Backend/WeatherForecast.cs` (or similar sample) and puts a full sample in `Program.cs`. Delete or replace:
+
+```bash
+rm -f Backend/WeatherForecast.cs
+rm -f Backend.Tests/UnitTest1.cs
+```
+
+Overwrite `Backend/Program.cs` with a stub (Task 6 rewrites it):
+
+```csharp
+// Placeholder. Rewritten in Task 6.
+var builder = WebApplication.CreateBuilder(args);
+var app = builder.Build();
+app.MapGet("/", () => "GameCli Backend placeholder");
+app.Run();
+```
+
+- [ ] **Step 3: Replace `Backend/appsettings.json`**
+
+Overwrite with:
+
+```json
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*",
+ "Lander": {
+ "CliPath": "publish/GameCli/GameCli",
+ "ModelPath": "models/ppo_lander.onnx"
+ }
+}
+```
+
+Delete `Backend/appsettings.Development.json` if it contains sample logging that duplicates the above.
+
+- [ ] **Step 4: Verify it builds and starts**
+
+```bash
+dotnet build
+```
+
+Expected: 0 errors.
+
+Quick manual check (optional but recommended):
+
+```bash
+dotnet run --project Backend --urls http://localhost:5100 &
+BACKEND_PID=$!
+sleep 3
+curl -sf http://localhost:5100/ && echo
+kill $BACKEND_PID
+wait $BACKEND_PID 2>/dev/null
+```
+
+Expected: `GameCli Backend placeholder`.
+
+- [ ] **Step 5: Verify empty test suite runs**
+
+```bash
+dotnet test --filter FullyQualifiedName~Backend.Tests
+```
+
+Expected: exit code 0 (either "Passed: 0" or "No test is available" — both fine).
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add Backend/ Backend.Tests/ GameCli.sln
+git -c user.email=meelstorm@gmail.com -c user.name=meelstorm commit -m "feat(backend): scaffold ASP.NET Core Web API + xunit test project"
+```
+
+---
+
+## Task 2: `WireMessages.cs` — WebSocket JSON DTOs
+
+**Files:**
+- Create: `Backend/WireMessages.cs`
+
+The backend talks JSON with the browser. Same source-gen pattern as GameCli's Protocol.cs.
+
+- [ ] **Step 1: Write `Backend/WireMessages.cs`**
+
+```csharp
+using System.Text.Json.Serialization;
+
+namespace Backend;
+
+// -------- Client → Server --------
+
+/// Cursor position update from the browser.
+public sealed class CursorMessage
+{
+ [JsonPropertyName("type")] public string Type { get; set; } = "cursor";
+ [JsonPropertyName("x")] public float X { get; set; }
+ [JsonPropertyName("y")] public float Y { get; set; }
+}
+
+// -------- Server → Client --------
+
+/// Sent once on WebSocket open. Tells the client the world dimensions.
+public sealed class InitFrame
+{
+ [JsonPropertyName("type")] public string Type { get; set; } = "init";
+ [JsonPropertyName("world")] public float[] World { get; set; } = new[] { 1f, 1f };
+}
+
+/// Sent every physics tick with the current ship pose and target.
+public sealed class StateFrame
+{
+ [JsonPropertyName("type")] public string Type { get; set; } = "state";
+ [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; }
+ [JsonPropertyName("target")] public float[] Target { get; set; } = new[] { 0.5f, 0.5f };
+ [JsonPropertyName("step")] public int Step { get; set; }
+}
+
+/// Source-gen JSON contracts.
+[JsonSourceGenerationOptions(WriteIndented = false)]
+[JsonSerializable(typeof(CursorMessage))]
+[JsonSerializable(typeof(InitFrame))]
+[JsonSerializable(typeof(StateFrame))]
+internal partial class WireJsonContext : System.Text.Json.Serialization.JsonSerializerContext
+{
+}
+```
+
+- [ ] **Step 2: Add `InternalsVisibleTo` for tests**
+
+Edit `Backend/Backend.csproj`, add before ``:
+
+```xml
+
+
+
+```
+
+- [ ] **Step 3: Verify build**
+
+```bash
+dotnet build
+```
+
+Expected: 0 errors.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add Backend/WireMessages.cs Backend/Backend.csproj
+git -c user.email=meelstorm@gmail.com -c user.name=meelstorm commit -m "feat(backend): add WebSocket wire message DTOs"
+```
+
+---
+
+## Task 3: `GameProcess` — CLI subprocess wrapper
+
+**Files:**
+- Create: `Backend/GameProcess.cs`
+- Create: `Backend.Tests/GameProcessTests.cs`
+
+`GameProcess` wraps `System.Diagnostics.Process` running the `GameCli` binary. It reads the init handshake once on start, exposes async `StepAsync` and `ResetAsync`, and disposes cleanly. It reuses the DTOs from the CLI's `Protocol.cs` — but those are `internal` to the `GameCli` assembly. Rather than take a project reference to the whole CLI (its `Program.cs` runs on load-only under top-level statements), we declare local DTOs in `GameProcess.cs` that mirror the wire format.
+
+- [ ] **Step 1: Write the failing tests**
+
+Create `Backend.Tests/GameProcessTests.cs`:
+
+```csharp
+using System.IO;
+using Backend;
+using Xunit;
+
+namespace Backend.Tests;
+
+public class GameProcessTests
+{
+ private static string LocateCliBinary()
+ {
+ // Walk up from the test assembly's location to find publish/GameCli/GameCli.
+ 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);
+ }
+}
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+```bash
+dotnet publish GameCli/GameCli.csproj -c Release -o publish/GameCli
+dotnet test --filter FullyQualifiedName~GameProcessTests
+```
+
+Expected: build error — `GameProcess` does not exist.
+
+- [ ] **Step 3: Write `Backend/GameProcess.cs`**
+
+```csharp
+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
+{
+}
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+```bash
+dotnet test --filter FullyQualifiedName~GameProcessTests
+```
+
+Expected: 3 passed, 0 failed.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add Backend/GameProcess.cs Backend.Tests/GameProcessTests.cs
+git -c user.email=meelstorm@gmail.com -c user.name=meelstorm commit -m "feat(backend): GameProcess subprocess wrapper with async step/reset"
+```
+
+---
+
+## Task 4: `PolicyRunner` — ONNX inference wrapper
+
+**Files:**
+- Create: `Backend/PolicyRunner.cs`
+- Create: `Backend.Tests/PolicyRunnerTests.cs`
+
+- [ ] **Step 1: Write the failing tests**
+
+Create `Backend.Tests/PolicyRunnerTests.cs`:
+
+```csharp
+using System.IO;
+using Backend;
+using Xunit;
+
+namespace Backend.Tests;
+
+public class PolicyRunnerTests
+{
+ private static string LocateOnnxModel()
+ {
+ var dir = AppContext.BaseDirectory;
+ while (dir is not null)
+ {
+ var candidate = Path.Combine(dir, "models", "ppo_lander.onnx");
+ if (File.Exists(candidate)) return candidate;
+ var parent = Directory.GetParent(dir);
+ if (parent is null) break;
+ dir = parent.FullName;
+ }
+ throw new FileNotFoundException(
+ "models/ppo_lander.onnx not found — run `python Training/export_onnx.py ...`");
+ }
+
+ [Fact]
+ public void LoadsWithoutError()
+ {
+ using var runner = new PolicyRunner(LocateOnnxModel());
+ }
+
+ [Fact]
+ public void SelectAction_ReturnsValidActionForZeroObs()
+ {
+ using var runner = new PolicyRunner(LocateOnnxModel());
+ var obs = new float[7]; // all zeros
+ int action = runner.SelectAction(obs);
+ Assert.InRange(action, 0, 3);
+ }
+
+ [Fact]
+ public void SelectAction_IsDeterministicForSameInput()
+ {
+ using var runner = new PolicyRunner(LocateOnnxModel());
+ var obs = new float[] { 0.1f, -0.2f, 0.05f, 0.0f, 0.0f, 1.0f, 0.0f };
+ int a1 = runner.SelectAction(obs);
+ int a2 = runner.SelectAction(obs);
+ Assert.Equal(a1, a2);
+ }
+
+ [Fact]
+ public void SelectAction_ThrowsIfObsLengthWrong()
+ {
+ using var runner = new PolicyRunner(LocateOnnxModel());
+ Assert.Throws(() => runner.SelectAction(new float[3]));
+ }
+}
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+```bash
+dotnet test --filter FullyQualifiedName~PolicyRunnerTests
+```
+
+Expected: build error — `PolicyRunner` does not exist.
+
+- [ ] **Step 3: Write `Backend/PolicyRunner.cs`**
+
+```csharp
+using Microsoft.ML.OnnxRuntime;
+using Microsoft.ML.OnnxRuntime.Tensors;
+
+namespace Backend;
+
+///
+/// Loads a PPO policy ONNX model once and provides deterministic action selection.
+/// Thread-safe: is safe for concurrent Run() calls.
+///
+public sealed class PolicyRunner : IDisposable
+{
+ public const int ObsDim = 7;
+ public const int NActions = 4;
+
+ private readonly InferenceSession _session;
+ private readonly string _inputName;
+
+ public PolicyRunner(string onnxPath)
+ {
+ if (!File.Exists(onnxPath))
+ throw new FileNotFoundException($"ONNX model not found at {onnxPath}");
+ _session = new InferenceSession(onnxPath);
+ _inputName = _session.InputMetadata.Keys.First();
+ }
+
+ /// Run one inference and return the argmax action index.
+ public int SelectAction(ReadOnlySpan obs)
+ {
+ if (obs.Length != ObsDim)
+ throw new ArgumentException($"expected obs of length {ObsDim}, got {obs.Length}", nameof(obs));
+
+ var tensor = new DenseTensor(new[] { 1, ObsDim });
+ for (int i = 0; i < ObsDim; i++) tensor[0, i] = obs[i];
+
+ using var results = _session.Run(new[]
+ {
+ NamedOnnxValue.CreateFromTensor(_inputName, tensor)
+ });
+
+ var logits = results.First().AsEnumerable().ToArray();
+ // argmax
+ int best = 0;
+ float bestVal = logits[0];
+ for (int i = 1; i < logits.Length; i++)
+ {
+ if (logits[i] > bestVal) { best = i; bestVal = logits[i]; }
+ }
+ return best;
+ }
+
+ public void Dispose() => _session.Dispose();
+}
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+```bash
+dotnet test --filter FullyQualifiedName~PolicyRunnerTests
+```
+
+Expected: 4 passed, 0 failed.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add Backend/PolicyRunner.cs Backend.Tests/PolicyRunnerTests.cs
+git -c user.email=meelstorm@gmail.com -c user.name=meelstorm commit -m "feat(backend): PolicyRunner ONNX inference wrapper"
+```
+
+---
+
+## Task 5: `GameSession` — per-connection tick loop
+
+**Files:**
+- Create: `Backend/GameSession.cs`
+
+`GameSession` owns one WebSocket + one `GameProcess`. It runs two concurrent loops (reader + ticker) both driven by a single `CancellationToken`. The ticker fires at 50 Hz with a `PeriodicTimer`.
+
+- [ ] **Step 1: Write `Backend/GameSession.cs`**
+
+```csharp
+using System.Net.WebSockets;
+using System.Text;
+using System.Text.Json;
+using System.Threading.Channels;
+
+namespace Backend;
+
+///
+/// One per connected browser. Runs the physics tick loop
+/// and the WebSocket reader loop concurrently until either side disconnects.
+///
+public sealed class GameSession : IAsyncDisposable
+{
+ private readonly WebSocket _socket;
+ private readonly GameProcess _game;
+ private readonly PolicyRunner _policy;
+ private readonly ILogger _logger;
+
+ // Mailbox: cursor updates from the browser. Bounded to 1 with drop-newest
+ // isn't quite right — we want drop-oldest so the ticker always reads the
+ // latest cursor. Channels' DropWrite behavior keeps the first, so we do
+ // it manually by draining the reader on each tick.
+ private readonly Channel<(float X, float Y)> _cursorInbox =
+ Channel.CreateUnbounded<(float, float)>();
+
+ private (float X, float Y) _currentCursor = (0.5f, 0.5f);
+ private float[] _lastObs = new float[PolicyRunner.ObsDim];
+
+ public GameSession(WebSocket socket, GameProcess game, PolicyRunner policy,
+ ILogger logger)
+ {
+ _socket = socket;
+ _game = game;
+ _policy = policy;
+ _logger = logger;
+ }
+
+ public async Task RunAsync(CancellationToken cancellation)
+ {
+ await _game.StartAsync();
+
+ // First step (noop) to get an initial observation for the policy.
+ var initial = await _game.ResetAsync();
+ _lastObs = initial.Observation;
+
+ // Send init frame to the browser.
+ await SendJsonAsync(new InitFrame { World = new[] { 1f, 1f } },
+ WireJsonContext.Default.InitFrame, cancellation);
+
+ var readerTask = ReaderLoopAsync(cancellation);
+ var tickerTask = TickerLoopAsync(cancellation);
+
+ // First loop to complete cancels the other.
+ var done = await Task.WhenAny(readerTask, tickerTask);
+ try { await done; }
+ catch (OperationCanceledException) { /* expected */ }
+ catch (Exception ex) { _logger.LogWarning(ex, "session loop ended"); }
+ }
+
+ private async Task ReaderLoopAsync(CancellationToken cancellation)
+ {
+ var buffer = new byte[4 * 1024];
+ while (!cancellation.IsCancellationRequested)
+ {
+ var result = await _socket.ReceiveAsync(buffer, cancellation);
+ if (result.MessageType == WebSocketMessageType.Close) break;
+ if (result.MessageType != WebSocketMessageType.Text) continue;
+
+ var text = Encoding.UTF8.GetString(buffer, 0, result.Count);
+ CursorMessage? msg;
+ try
+ {
+ msg = JsonSerializer.Deserialize(text, WireJsonContext.Default.CursorMessage);
+ }
+ catch (JsonException) { continue; }
+ if (msg is null || msg.Type != "cursor") continue;
+
+ await _cursorInbox.Writer.WriteAsync(
+ (Clamp01(msg.X), Clamp01(msg.Y)), cancellation);
+ }
+ }
+
+ private async Task TickerLoopAsync(CancellationToken cancellation)
+ {
+ using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(20)); // 50 Hz
+ while (await timer.WaitForNextTickAsync(cancellation))
+ {
+ // Drain the cursor mailbox — keep only the latest update.
+ while (_cursorInbox.Reader.TryRead(out var next)) _currentCursor = next;
+
+ int action = _policy.SelectAction(_lastObs);
+ var step = await _game.StepAsync(action, _currentCursor);
+ _lastObs = step.Observation;
+
+ var frame = new StateFrame
+ {
+ X = step.State.X,
+ Y = step.State.Y,
+ Angle = step.State.Angle,
+ Engine = step.State.Engine,
+ Target = new[] { _currentCursor.X, _currentCursor.Y },
+ Step = step.Step,
+ };
+ await SendJsonAsync(frame, WireJsonContext.Default.StateFrame, cancellation);
+ }
+ }
+
+ private async Task SendJsonAsync(
+ T value,
+ System.Text.Json.Serialization.Metadata.JsonTypeInfo ctx,
+ CancellationToken cancellation)
+ {
+ var bytes = JsonSerializer.SerializeToUtf8Bytes(value, ctx);
+ await _socket.SendAsync(bytes, WebSocketMessageType.Text,
+ endOfMessage: true, cancellation);
+ }
+
+ private static float Clamp01(float v) => v < 0f ? 0f : (v > 1f ? 1f : v);
+
+ public async ValueTask DisposeAsync()
+ {
+ try
+ {
+ if (_socket.State == WebSocketState.Open)
+ await _socket.CloseAsync(WebSocketCloseStatus.NormalClosure,
+ "session-end", CancellationToken.None);
+ }
+ catch { }
+ await _game.DisposeAsync();
+ }
+}
+```
+
+- [ ] **Step 2: Verify build**
+
+```bash
+dotnet build
+```
+
+Expected: 0 errors. If warnings appear about nullable-analysis on the `msg.Type` check, ignore them.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add Backend/GameSession.cs
+git -c user.email=meelstorm@gmail.com -c user.name=meelstorm commit -m "feat(backend): GameSession runs reader + ticker loops per WebSocket connection"
+```
+
+---
+
+## Task 6: `Program.cs` — startup wiring
+
+**Files:**
+- Modify: `Backend/Program.cs` (overwrite the Task 1 stub)
+- Create: `Backend/LanderConfig.cs`
+
+- [ ] **Step 1: Write `Backend/LanderConfig.cs`**
+
+```csharp
+namespace Backend;
+
+public sealed class LanderConfig
+{
+ public string CliPath { get; set; } = "publish/GameCli/GameCli";
+ public string ModelPath { get; set; } = "models/ppo_lander.onnx";
+}
+
+///
+/// Walks up from to find the repo root
+/// (marker file: GameCli.sln) and resolves the configured paths against it.
+///
+public static class PathResolver
+{
+ public static string RepoRoot()
+ {
+ var dir = AppContext.BaseDirectory;
+ while (dir is not null)
+ {
+ if (File.Exists(Path.Combine(dir, "GameCli.sln"))) return dir;
+ var parent = Directory.GetParent(dir);
+ if (parent is null) break;
+ dir = parent.FullName;
+ }
+ throw new InvalidOperationException(
+ "could not locate repo root (no GameCli.sln found in any ancestor)");
+ }
+
+ public static string Resolve(string relativeOrAbsolute)
+ {
+ if (Path.IsPathRooted(relativeOrAbsolute)) return relativeOrAbsolute;
+ return Path.GetFullPath(Path.Combine(RepoRoot(), relativeOrAbsolute));
+ }
+}
+```
+
+- [ ] **Step 2: Overwrite `Backend/Program.cs`**
+
+```csharp
+using System.Net.WebSockets;
+using Backend;
+
+var builder = WebApplication.CreateBuilder(args);
+
+// -------- Config --------
+builder.Services.Configure(builder.Configuration.GetSection("Lander"));
+
+// -------- Singletons --------
+builder.Services.AddSingleton(sp =>
+{
+ var cfg = sp.GetRequiredService>().Value;
+ var absolute = PathResolver.Resolve(cfg.ModelPath);
+ return new PolicyRunner(absolute);
+});
+
+builder.Services.AddLogging();
+
+var app = builder.Build();
+app.UseWebSockets();
+
+// -------- Health --------
+app.MapGet("/", () => "GameCli Backend");
+
+// -------- WebSocket endpoint --------
+app.Map("/ws/game", async (HttpContext ctx,
+ PolicyRunner policy,
+ Microsoft.Extensions.Options.IOptions cfg,
+ ILoggerFactory loggerFactory) =>
+{
+ if (!ctx.WebSockets.IsWebSocketRequest)
+ {
+ ctx.Response.StatusCode = 400;
+ return;
+ }
+ using var socket = await ctx.WebSockets.AcceptWebSocketAsync();
+ var cliPath = PathResolver.Resolve(cfg.Value.CliPath);
+ var proc = new GameProcess(cliPath);
+ var logger = loggerFactory.CreateLogger();
+ await using var session = new GameSession(socket, proc, policy, logger);
+ try
+ {
+ await session.RunAsync(ctx.RequestAborted);
+ }
+ catch (WebSocketException) { /* client disconnected */ }
+ catch (OperationCanceledException) { /* shutdown */ }
+});
+
+app.Run();
+
+// -------- Public program class so Backend.Tests can use WebApplicationFactory --------
+public partial class Program { }
+```
+
+- [ ] **Step 3: Verify it builds and starts**
+
+```bash
+dotnet build
+dotnet run --project Backend --urls http://localhost:5100 &
+BACKEND_PID=$!
+sleep 3
+curl -sf http://localhost:5100/ && echo
+kill $BACKEND_PID
+wait $BACKEND_PID 2>/dev/null
+```
+
+Expected:
+- Build succeeds.
+- The GET returns `GameCli Backend`.
+- Backend logs on startup that it loaded `models/ppo_lander.onnx` (visible in the console output before curl).
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add Backend/Program.cs Backend/LanderConfig.cs
+git -c user.email=meelstorm@gmail.com -c user.name=meelstorm commit -m "feat(backend): wire ASP.NET startup with WebSocket endpoint and DI"
+```
+
+---
+
+## Task 7: End-to-end smoke test — WebSocket round-trip
+
+**Files:**
+- Create: `Backend.Tests/EndToEndSmokeTests.cs`
+
+Uses `WebApplicationFactory` + `ClientWebSocket` to test the whole stack:
+1. Start the backend in-process.
+2. Open a WebSocket to `/ws/game`.
+3. Receive `init` frame.
+4. Send a cursor update.
+5. Receive a `state` frame within a reasonable timeout.
+6. Verify shape.
+
+- [ ] **Step 1: Write the failing test**
+
+Create `Backend.Tests/EndToEndSmokeTests.cs`:
+
+```csharp
+using System.IO;
+using System.Net.WebSockets;
+using System.Text;
+using System.Text.Json;
+using Backend;
+using Microsoft.AspNetCore.Mvc.Testing;
+using Xunit;
+
+namespace Backend.Tests;
+
+public class EndToEndSmokeTests : IClassFixture>
+{
+ private readonly WebApplicationFactory _factory;
+
+ public EndToEndSmokeTests(WebApplicationFactory factory)
+ {
+ _factory = factory.WithWebHostBuilder(b =>
+ {
+ // Prevent MVC picking up assemblies; force env vars if needed.
+ b.UseEnvironment("Development");
+ });
+ }
+
+ [Fact]
+ public async Task WebSocket_ReceivesInitAndStateFrames()
+ {
+ // Prerequisites: publish/GameCli/GameCli and models/ppo_lander.onnx must exist.
+ var repoRoot = FindRepoRoot();
+ Assert.True(File.Exists(Path.Combine(repoRoot, "publish", "GameCli", "GameCli")),
+ "run `dotnet publish GameCli -c Release -o publish/GameCli` before this test");
+ Assert.True(File.Exists(Path.Combine(repoRoot, "models", "ppo_lander.onnx")),
+ "run `python Training/export_onnx.py ...` before this test");
+
+ var client = _factory.Server.CreateWebSocketClient();
+ var baseUri = _factory.Server.BaseAddress;
+ var wsUri = new UriBuilder(baseUri) { Scheme = "ws", Path = "/ws/game" }.Uri;
+
+ using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
+ using var ws = await client.ConnectAsync(wsUri, cts.Token);
+
+ var initText = await ReceiveTextAsync(ws, cts.Token);
+ using var initDoc = JsonDocument.Parse(initText);
+ Assert.Equal("init", initDoc.RootElement.GetProperty("type").GetString());
+
+ // Send a cursor and receive at least one state frame.
+ var cursorPayload = """{"type":"cursor","x":0.3,"y":0.7}""";
+ await ws.SendAsync(Encoding.UTF8.GetBytes(cursorPayload),
+ WebSocketMessageType.Text, true, cts.Token);
+
+ // Read up to N frames looking for a state frame; the ticker runs at 50 Hz.
+ for (int i = 0; i < 100; i++)
+ {
+ var text = await ReceiveTextAsync(ws, cts.Token);
+ using var doc = JsonDocument.Parse(text);
+ if (doc.RootElement.GetProperty("type").GetString() == "state")
+ {
+ Assert.True(doc.RootElement.TryGetProperty("x", out _));
+ Assert.True(doc.RootElement.TryGetProperty("y", out _));
+ Assert.True(doc.RootElement.TryGetProperty("angle", out _));
+ Assert.True(doc.RootElement.TryGetProperty("engine", out _));
+ Assert.True(doc.RootElement.TryGetProperty("target", out _));
+ Assert.True(doc.RootElement.TryGetProperty("step", out _));
+ return;
+ }
+ }
+ Assert.Fail("did not receive a state frame in 100 messages");
+ }
+
+ private static async Task ReceiveTextAsync(WebSocket ws, CancellationToken ct)
+ {
+ var buffer = new byte[16 * 1024];
+ var sb = new StringBuilder();
+ WebSocketReceiveResult result;
+ do
+ {
+ result = await ws.ReceiveAsync(buffer, ct);
+ sb.Append(Encoding.UTF8.GetString(buffer, 0, result.Count));
+ } while (!result.EndOfMessage);
+ return sb.ToString();
+ }
+
+ private static string FindRepoRoot()
+ {
+ var dir = AppContext.BaseDirectory;
+ while (dir is not null)
+ {
+ if (File.Exists(Path.Combine(dir, "GameCli.sln"))) return dir;
+ var parent = Directory.GetParent(dir);
+ if (parent is null) break;
+ dir = parent.FullName;
+ }
+ throw new InvalidOperationException("repo root not found");
+ }
+}
+```
+
+- [ ] **Step 2: Publish CLI and confirm ONNX exists**
+
+```bash
+dotnet publish GameCli/GameCli.csproj -c Release -o publish/GameCli
+ls -la models/ppo_lander.onnx
+```
+
+- [ ] **Step 3: Run the smoke test**
+
+```bash
+dotnet test --filter FullyQualifiedName~EndToEndSmokeTests
+```
+
+Expected: 1 passed. The test should complete within ~5 seconds (init frame + ~1 tick).
+
+- [ ] **Step 4: Run the full Backend test suite**
+
+```bash
+dotnet test --filter FullyQualifiedName~Backend.Tests
+```
+
+Expected: 3 GameProcess + 4 PolicyRunner + 1 smoke = 8 passed, 0 failed.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add Backend.Tests/EndToEndSmokeTests.cs
+git -c user.email=meelstorm@gmail.com -c user.name=meelstorm commit -m "test(backend): end-to-end WebSocket smoke test via WebApplicationFactory"
+```
+
+---
+
+## Definition of done for Plan 3
+
+- `dotnet test --filter FullyQualifiedName~Backend.Tests` reports 8 passed, 0 failed.
+- `dotnet run --project Backend` starts, logs the ONNX model load, and responds to `GET /` with `GameCli Backend`.
+- Connecting a WebSocket client to `ws://localhost:5100/ws/game` receives an `init` frame followed by 50 Hz `state` frames.
+- No file exceeds ~250 lines; every source file has a single clear responsibility.
+
+The next plan (Plan 4) will build the React + Vite frontend that opens this WebSocket, streams the mouse cursor, and renders the ship on a canvas.