Files
Experiment_ReinforcementLea…/docs/superpowers/plans/2026-07-17-03-backend.md
2026-07-17 18:23:16 +00:00

35 KiB

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:

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:

rm -f Backend/WeatherForecast.cs
rm -f Backend.Tests/UnitTest1.cs

Overwrite Backend/Program.cs with a stub (Task 6 rewrites it):

// 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:

{
  "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
dotnet build

Expected: 0 errors.

Quick manual check (optional but recommended):

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
dotnet test --filter FullyQualifiedName~Backend.Tests

Expected: exit code 0 (either "Passed: 0" or "No test is available" — both fine).

  • Step 6: Commit
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
using System.Text.Json.Serialization;

namespace Backend;

// -------- Client → Server --------

/// <summary>Cursor position update from the browser.</summary>
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 --------

/// <summary>Sent once on WebSocket open. Tells the client the world dimensions.</summary>
public sealed class InitFrame
{
    [JsonPropertyName("type")]  public string Type { get; set; } = "init";
    [JsonPropertyName("world")] public float[] World { get; set; } = new[] { 1f, 1f };
}

/// <summary>Sent every physics tick with the current ship pose and target.</summary>
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; }
}

/// <summary>Source-gen JSON contracts.</summary>
[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 </Project>:

  <ItemGroup>
    <InternalsVisibleTo Include="Backend.Tests" />
  </ItemGroup>
  • Step 3: Verify build
dotnet build

Expected: 0 errors.

  • Step 4: Commit
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:

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
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
using System.Diagnostics;
using System.Text.Json;
using System.Text.Json.Serialization;

namespace Backend;

/// <summary>
/// One instance == one <c>GameCli</c> subprocess. Not thread-safe.
/// Callers must sequence <see cref="StepAsync"/> and <see cref="ResetAsync"/>.
/// </summary>
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<StepResult> 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<StepResult> 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>(T value, System.Text.Json.Serialization.Metadata.JsonTypeInfo<T> 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<StepResult> 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<float>();
    [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
dotnet test --filter FullyQualifiedName~GameProcessTests

Expected: 3 passed, 0 failed.

  • Step 5: Commit
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:

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<ArgumentException>(() => runner.SelectAction(new float[3]));
    }
}
  • Step 2: Run tests to verify they fail
dotnet test --filter FullyQualifiedName~PolicyRunnerTests

Expected: build error — PolicyRunner does not exist.

  • Step 3: Write Backend/PolicyRunner.cs
using Microsoft.ML.OnnxRuntime;
using Microsoft.ML.OnnxRuntime.Tensors;

namespace Backend;

/// <summary>
/// Loads a PPO policy ONNX model once and provides deterministic action selection.
/// Thread-safe: <see cref="InferenceSession"/> is safe for concurrent Run() calls.
/// </summary>
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();
    }

    /// <summary>Run one inference and return the argmax action index.</summary>
    public int SelectAction(ReadOnlySpan<float> obs)
    {
        if (obs.Length != ObsDim)
            throw new ArgumentException($"expected obs of length {ObsDim}, got {obs.Length}", nameof(obs));

        var tensor = new DenseTensor<float>(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<float>().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
dotnet test --filter FullyQualifiedName~PolicyRunnerTests

Expected: 4 passed, 0 failed.

  • Step 5: Commit
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
using System.Net.WebSockets;
using System.Text;
using System.Text.Json;
using System.Threading.Channels;

namespace Backend;

/// <summary>
/// One <see cref="GameSession"/> per connected browser. Runs the physics tick loop
/// and the WebSocket reader loop concurrently until either side disconnects.
/// </summary>
public sealed class GameSession : IAsyncDisposable
{
    private readonly WebSocket _socket;
    private readonly GameProcess _game;
    private readonly PolicyRunner _policy;
    private readonly ILogger<GameSession> _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<GameSession> 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>(
        T value,
        System.Text.Json.Serialization.Metadata.JsonTypeInfo<T> 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
dotnet build

Expected: 0 errors. If warnings appear about nullable-analysis on the msg.Type check, ignore them.

  • Step 3: Commit
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

namespace Backend;

public sealed class LanderConfig
{
    public string CliPath  { get; set; } = "publish/GameCli/GameCli";
    public string ModelPath { get; set; } = "models/ppo_lander.onnx";
}

/// <summary>
/// Walks up from <see cref="AppContext.BaseDirectory"/> to find the repo root
/// (marker file: <c>GameCli.sln</c>) and resolves the configured paths against it.
/// </summary>
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
using System.Net.WebSockets;
using Backend;

var builder = WebApplication.CreateBuilder(args);

// -------- Config --------
builder.Services.Configure<LanderConfig>(builder.Configuration.GetSection("Lander"));

// -------- Singletons --------
builder.Services.AddSingleton<PolicyRunner>(sp =>
{
    var cfg = sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<LanderConfig>>().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<LanderConfig> 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<GameSession>();
    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
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

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:

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<WebApplicationFactory<Program>>
{
    private readonly WebApplicationFactory<Program> _factory;

    public EndToEndSmokeTests(WebApplicationFactory<Program> 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<string> 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
dotnet publish GameCli/GameCli.csproj -c Release -o publish/GameCli
ls -la models/ppo_lander.onnx
  • Step 3: Run the smoke test
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
dotnet test --filter FullyQualifiedName~Backend.Tests

Expected: 3 GameProcess + 4 PolicyRunner + 1 smoke = 8 passed, 0 failed.

  • Step 5: Commit
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.