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
GameClibinary must be publishable atpublish/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__CliPathandLANDER__ModelPathoverride (ASP.NET double-underscore convention; case-insensitive soLANDER__CLIPATHalso 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
InternalsVisibleTofor 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:
- Start the backend in-process.
- Open a WebSocket to
/ws/game. - Receive
initframe. - Send a cursor update.
- Receive a
stateframe within a reasonable timeout. - 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.Testsreports 8 passed, 0 failed.dotnet run --project Backendstarts, logs the ONNX model load, and responds toGET /withGameCli Backend.- Connecting a WebSocket client to
ws://localhost:5100/ws/gamereceives aninitframe followed by 50 Hzstateframes. - 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.