32 KiB
Game CLI 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 standalone GameCli .NET console application that owns lunar-lander physics and speaks the line-delimited JSON stdio protocol from the design spec (docs/superpowers/specs/2026-07-17-cursor-following-lander-design.md).
Architecture: Pure C# console app. Program.cs runs a read-line → step → write-line loop. All physics, reward, and observation code is in small pure functions that operate on immutable structs, so unit tests exercise them directly. JSON on the wire uses System.Text.Json with source-generated serializer contexts for AOT-friendliness (also fastest).
Tech Stack: .NET 8 SDK, C# 12, System.Text.Json, xUnit for tests.
File structure
Experiment_ReinforcementLearning/
├── GameCli.sln
├── GameCli/
│ ├── GameCli.csproj
│ ├── Program.cs # main read-line loop + startup handshake
│ ├── Ship.cs # ShipState struct
│ ├── Physics.cs # constants + pure Step() function
│ ├── Reward.cs # pure Compute() function
│ ├── Observation.cs # pure Build() function
│ └── Protocol.cs # DTOs (StepInput, StepOutput, InitMessage, ResetCommand) + JsonSerializerContext
└── GameCli.Tests/
├── GameCli.Tests.csproj
├── PhysicsTests.cs
├── RewardTests.cs
├── ObservationTests.cs
├── ProtocolTests.cs
└── ProgramSmokeTests.cs # end-to-end: launch the built exe, pipe stdin, assert stdout
Each file has one responsibility. Program.cs is the only file that touches stdin/stdout — everything else is pure and directly unit-testable.
Task 1: Scaffold the .NET solution
Files:
-
Create:
GameCli.sln -
Create:
GameCli/GameCli.csproj -
Create:
GameCli.Tests/GameCli.Tests.csproj -
Step 1: Verify .NET 8 SDK is installed
Run: dotnet --list-sdks
Expected: at least one 8.0.x line. If none, install .NET 8 SDK from https://dot.net.
- Step 2: Create the solution and both projects
Run from the repo root:
dotnet new sln -n GameCli
dotnet new console -n GameCli -o GameCli -f net8.0
dotnet new xunit -n GameCli.Tests -o GameCli.Tests -f net8.0
dotnet sln add GameCli/GameCli.csproj GameCli.Tests/GameCli.Tests.csproj
dotnet add GameCli.Tests/GameCli.Tests.csproj reference GameCli/GameCli.csproj
- Step 3: Delete the boilerplate
Program.csbodies
Overwrite GameCli/Program.cs with a stub so Task 8 can rewrite it cleanly:
// Placeholder. Rewritten in Task 8.
System.Console.Error.WriteLine("GameCli placeholder");
Delete GameCli.Tests/UnitTest1.cs if the xunit template created it:
rm -f GameCli.Tests/UnitTest1.cs
- Step 4: Verify it builds
Run: dotnet build
Expected: Build succeeded. 0 Warning(s). 0 Error(s).
- Step 5: Verify the empty test project runs
Run: dotnet test
Expected: Passed! - Failed: 0, Passed: 0, Skipped: 0 (0 tests, all pass).
- Step 6: Commit
git add GameCli.sln GameCli/ GameCli.Tests/
git commit -m "feat(gamecli): scaffold .NET solution and test project"
Task 2: ShipState struct
Files:
-
Create:
GameCli/Ship.cs -
Test:
GameCli.Tests/ObservationTests.cs(indirectly —ShipStateis used pervasively, no dedicated test file) -
Step 1: Write
Ship.cs
namespace GameCli;
/// <summary>
/// Immutable ship pose + velocity in world coordinates.
/// World is [0,1] × [0,1]. Y increases downward (screen-native).
/// Angle=0 means "ship pointing up" (main engine points down).
/// Positive angle rotates clockwise.
/// </summary>
public readonly record struct ShipState(
float X,
float Y,
float VX,
float VY,
float Angle,
float AngularVelocity)
{
public static ShipState AtCenter() => new(0.5f, 0.5f, 0f, 0f, 0f, 0f);
}
- Step 2: Verify it builds
Run: dotnet build
Expected: build succeeds.
- Step 3: Commit
git add GameCli/Ship.cs
git commit -m "feat(gamecli): add ShipState record struct"
Task 3: Physics — constants and pure Step function
Files:
-
Create:
GameCli/Physics.cs -
Create:
GameCli.Tests/PhysicsTests.cs -
Step 1: Write the failing tests
Create GameCli.Tests/PhysicsTests.cs:
using GameCli;
using Xunit;
namespace GameCli.Tests;
public class PhysicsTests
{
[Fact]
public void Noop_UnderGravity_ShipFallsDownward()
{
var start = new ShipState(0.5f, 0.5f, 0f, 0f, 0f, 0f);
var next = Physics.Step(start, action: 0);
Assert.True(next.VY > 0, $"expected VY > 0 (gravity down), got {next.VY}");
Assert.True(next.Y > start.Y, $"expected Y to increase (fall), got {next.Y}");
Assert.Equal(start.X, next.X, precision: 5);
Assert.Equal(0f, next.Angle);
}
[Fact]
public void MainEngine_WhenUpright_CancelsThenReversesGravity()
{
// Main engine thrust > gravity, so acceleration is net upward (VY becomes negative).
var start = new ShipState(0.5f, 0.5f, 0f, 0f, 0f, 0f);
var next = Physics.Step(start, action: 2);
Assert.True(next.VY < 0, $"expected VY < 0 (net upward), got {next.VY}");
}
[Fact]
public void LeftThruster_AppliesNegativeTorque()
{
var start = new ShipState(0.5f, 0.5f, 0f, 0f, 0f, 0f);
var next = Physics.Step(start, action: 1);
Assert.True(next.AngularVelocity < 0,
$"expected angular velocity < 0, got {next.AngularVelocity}");
}
[Fact]
public void RightThruster_AppliesPositiveTorque()
{
var start = new ShipState(0.5f, 0.5f, 0f, 0f, 0f, 0f);
var next = Physics.Step(start, action: 3);
Assert.True(next.AngularVelocity > 0,
$"expected angular velocity > 0, got {next.AngularVelocity}");
}
[Fact]
public void MainEngine_WhenRotated90Right_ThrustsRight()
{
// Angle = +pi/2 means ship points to the +x direction (right).
// Main engine pushes along body-up, which is now world +x.
var start = new ShipState(0.5f, 0.5f, 0f, 0f, MathF.PI / 2f, 0f);
var next = Physics.Step(start, action: 2);
Assert.True(next.VX > 0, $"expected VX > 0 (thrust right), got {next.VX}");
}
[Fact]
public void Step_IsDeterministic()
{
var start = new ShipState(0.3f, 0.4f, 0.1f, -0.05f, 0.2f, 0.3f);
var a = Physics.Step(start, action: 2);
var b = Physics.Step(start, action: 2);
Assert.Equal(a, b);
}
}
- Step 2: Run tests to verify they fail
Run: dotnet test
Expected: build error — Physics does not exist.
- Step 3: Write
Physics.cs
namespace GameCli;
/// <summary>
/// Pure physics stepper. All constants are public so training/tuning can inspect them.
/// Explicit Euler integration at fixed dt.
/// </summary>
public static class Physics
{
public const float Dt = 0.02f; // 50 Hz
public const float Gravity = 0.5f; // world units / s^2, +Y (downward)
public const float MainThrust = 1.2f; // world units / s^2 along body-up
public const float SideTorque = 4.0f; // rad / s^2
public const float SideLateralImpulse = 0.15f; // world units / s^2 along body-x
public const float LinearDrag = 0.10f; // per-second
public const float AngularDrag = 0.50f; // per-second
public const int ActionNoop = 0;
public const int ActionLeft = 1;
public const int ActionMain = 2;
public const int ActionRight = 3;
/// <summary>
/// Advance the ship state by one dt given a discrete action.
/// Gravity is always applied. Thrusters add to acceleration/torque.
/// </summary>
public static ShipState Step(ShipState s, int action)
{
// Start with gravity.
float ax = 0f;
float ay = Gravity;
float torque = 0f;
// Body-up direction in world coords, given angle:
// angle=0 → (0, -1) "up" on screen (Y is down)
// angle=+π/2 → (+1, 0) right
// angle=+π → (0, +1) down
float sinA = MathF.Sin(s.Angle);
float cosA = MathF.Cos(s.Angle);
float bodyUpX = sinA;
float bodyUpY = -cosA;
// Body-right direction (perpendicular, rotated +90°):
float bodyRightX = cosA;
float bodyRightY = sinA;
switch (action)
{
case ActionMain:
ax += bodyUpX * MainThrust;
ay += bodyUpY * MainThrust;
break;
case ActionLeft:
torque -= SideTorque;
ax += bodyRightX * SideLateralImpulse;
ay += bodyRightY * SideLateralImpulse;
break;
case ActionRight:
torque += SideTorque;
ax -= bodyRightX * SideLateralImpulse;
ay -= bodyRightY * SideLateralImpulse;
break;
case ActionNoop:
default:
break;
}
// Integrate velocity, apply linear drag, integrate position.
float vx = (s.VX + ax * Dt) * (1f - LinearDrag * Dt);
float vy = (s.VY + ay * Dt) * (1f - LinearDrag * Dt);
float x = s.X + vx * Dt;
float y = s.Y + vy * Dt;
// Angular: same pattern.
float w = (s.AngularVelocity + torque * Dt) * (1f - AngularDrag * Dt);
float angle = s.Angle + w * Dt;
return new ShipState(x, y, vx, vy, angle, w);
}
}
- Step 4: Run tests to verify they pass
Run: dotnet test --filter FullyQualifiedName~PhysicsTests
Expected: 6 passed, 0 failed.
- Step 5: Commit
git add GameCli/Physics.cs GameCli.Tests/PhysicsTests.cs
git commit -m "feat(gamecli): implement pure Physics.Step with unit tests"
Task 4: Reward — pure reward computation
Files:
-
Create:
GameCli/Reward.cs -
Create:
GameCli.Tests/RewardTests.cs -
Step 1: Write the failing tests
Create GameCli.Tests/RewardTests.cs:
using GameCli;
using Xunit;
namespace GameCli.Tests;
public class RewardTests
{
[Fact]
public void AtTarget_ZeroVel_ZeroAngle_Noop_HasZeroReward()
{
var ship = new ShipState(0.5f, 0.5f, 0f, 0f, 0f, 0f);
float r = Reward.Compute(ship, target: (0.5f, 0.5f), action: 0);
Assert.Equal(0f, r, precision: 5);
}
[Fact]
public void FarFromTarget_HasLargerNegativeThanNear()
{
var near = new ShipState(0.5f, 0.5f, 0f, 0f, 0f, 0f);
var far = new ShipState(0.9f, 0.9f, 0f, 0f, 0f, 0f);
float rNear = Reward.Compute(near, target: (0.5f, 0.5f), action: 0);
float rFar = Reward.Compute(far, target: (0.5f, 0.5f), action: 0);
Assert.True(rFar < rNear, $"far reward {rFar} should be < near reward {rNear}");
}
[Fact]
public void HighVelocity_PenalizesReward()
{
var stopped = new ShipState(0.5f, 0.5f, 0f, 0f, 0f, 0f);
var moving = new ShipState(0.5f, 0.5f, 1f, 1f, 0f, 0f);
float rStop = Reward.Compute(stopped, target: (0.5f, 0.5f), action: 0);
float rMove = Reward.Compute(moving, target: (0.5f, 0.5f), action: 0);
Assert.True(rMove < rStop, $"moving reward {rMove} should be < stopped reward {rStop}");
}
[Fact]
public void Tilted_PenalizesReward()
{
var upright = new ShipState(0.5f, 0.5f, 0f, 0f, 0f, 0f);
var tilted = new ShipState(0.5f, 0.5f, 0f, 0f, 0.8f, 0f);
float rUp = Reward.Compute(upright, target: (0.5f, 0.5f), action: 0);
float rTi = Reward.Compute(tilted, target: (0.5f, 0.5f), action: 0);
Assert.True(rTi < rUp, $"tilted reward {rTi} should be < upright reward {rUp}");
}
[Fact]
public void FiringEngine_PenalizesReward()
{
var ship = new ShipState(0.5f, 0.5f, 0f, 0f, 0f, 0f);
float rNoop = Reward.Compute(ship, target: (0.5f, 0.5f), action: 0);
float rMain = Reward.Compute(ship, target: (0.5f, 0.5f), action: 2);
float rLeft = Reward.Compute(ship, target: (0.5f, 0.5f), action: 1);
Assert.True(rMain < rNoop, "firing main should be worse than noop");
Assert.True(rLeft < rNoop, "firing left should be worse than noop");
}
}
- Step 2: Run tests to verify they fail
Run: dotnet test
Expected: build error — Reward does not exist.
- Step 3: Write
Reward.cs
namespace GameCli;
/// <summary>
/// Reward = -distance_to_target
/// - λ_v · speed
/// - λ_θ · |angle|
/// - λ_fuel · engine_on
/// Coefficients are public so training scripts can log/inspect them.
/// </summary>
public static class Reward
{
public const float LambdaVelocity = 0.10f;
public const float LambdaAngle = 0.10f;
public const float LambdaFuel = 0.03f;
public static float Compute(ShipState s, (float X, float Y) target, int action)
{
float dx = target.X - s.X;
float dy = target.Y - s.Y;
float distance = MathF.Sqrt(dx * dx + dy * dy);
float speed = MathF.Sqrt(s.VX * s.VX + s.VY * s.VY);
float engineOn = action == Physics.ActionNoop ? 0f : 1f;
return -distance
- LambdaVelocity * speed
- LambdaAngle * MathF.Abs(s.Angle)
- LambdaFuel * engineOn;
}
}
- Step 4: Run tests to verify they pass
Run: dotnet test --filter FullyQualifiedName~RewardTests
Expected: 5 passed, 0 failed.
- Step 5: Commit
git add GameCli/Reward.cs GameCli.Tests/RewardTests.cs
git commit -m "feat(gamecli): implement pure Reward.Compute with unit tests"
Task 5: Observation — pure observation vector builder
Files:
-
Create:
GameCli/Observation.cs -
Create:
GameCli.Tests/ObservationTests.cs -
Step 1: Write the failing tests
Create GameCli.Tests/ObservationTests.cs:
using GameCli;
using Xunit;
namespace GameCli.Tests;
public class ObservationTests
{
[Fact]
public void Build_HasSevenDimensions()
{
var ship = new ShipState(0.5f, 0.5f, 0f, 0f, 0f, 0f);
var obs = Observation.Build(ship, target: (0.5f, 0.5f));
Assert.Equal(7, obs.Length);
}
[Fact]
public void Build_DisplacementIsTargetMinusShip()
{
var ship = new ShipState(0.3f, 0.4f, 0f, 0f, 0f, 0f);
var obs = Observation.Build(ship, target: (0.7f, 0.9f));
// dx, dy at indices 0, 1
Assert.Equal(0.4f, obs[0], precision: 5);
Assert.Equal(0.5f, obs[1], precision: 5);
}
[Fact]
public void Build_EncodesVelocity()
{
var ship = new ShipState(0.5f, 0.5f, 0.1f, -0.2f, 0f, 0f);
var obs = Observation.Build(ship, target: (0.5f, 0.5f));
Assert.Equal(0.1f, obs[2], precision: 5);
Assert.Equal(-0.2f, obs[3], precision: 5);
}
[Fact]
public void Build_EncodesAngleAsSinCos()
{
var ship = new ShipState(0.5f, 0.5f, 0f, 0f, MathF.PI / 2f, 0f);
var obs = Observation.Build(ship, target: (0.5f, 0.5f));
// sin(π/2)=1, cos(π/2)=0
Assert.Equal(1f, obs[4], precision: 5);
Assert.Equal(0f, obs[5], precision: 5);
}
[Fact]
public void Build_LastDimIsAngularVelocity()
{
var ship = new ShipState(0.5f, 0.5f, 0f, 0f, 0f, 0.7f);
var obs = Observation.Build(ship, target: (0.5f, 0.5f));
Assert.Equal(0.7f, obs[6], precision: 5);
}
}
- Step 2: Run tests to verify they fail
Run: dotnet test
Expected: build error — Observation does not exist.
- Step 3: Write
Observation.cs
namespace GameCli;
/// <summary>
/// Builds the 7-dim observation vector fed to PPO:
/// [dx, dy, vx, vy, sin(angle), cos(angle), angular_velocity]
/// Angle is encoded as sin/cos to avoid the discontinuity at ±π.
/// </summary>
public static class Observation
{
public const int Dim = 7;
public static float[] Build(ShipState s, (float X, float Y) target)
{
return new float[Dim]
{
target.X - s.X,
target.Y - s.Y,
s.VX,
s.VY,
MathF.Sin(s.Angle),
MathF.Cos(s.Angle),
s.AngularVelocity,
};
}
}
- Step 4: Run tests to verify they pass
Run: dotnet test --filter FullyQualifiedName~ObservationTests
Expected: 5 passed, 0 failed.
- Step 5: Commit
git add GameCli/Observation.cs GameCli.Tests/ObservationTests.cs
git commit -m "feat(gamecli): implement Observation.Build with unit tests"
Task 6: Protocol — JSON DTOs and serializer context
Files:
-
Create:
GameCli/Protocol.cs -
Create:
GameCli.Tests/ProtocolTests.cs -
Step 1: Write the failing tests
Create GameCli.Tests/ProtocolTests.cs:
using System.Text.Json;
using GameCli;
using Xunit;
namespace GameCli.Tests;
public class ProtocolTests
{
[Fact]
public void StepInput_ParsesActionAndTarget()
{
var json = """{"action":2,"target":[0.35,0.60]}""";
var input = JsonSerializer.Deserialize(json, ProtocolJsonContext.Default.StepInput);
Assert.NotNull(input);
Assert.Equal(2, input!.Action);
Assert.NotNull(input.Target);
Assert.Equal(0.35f, input.Target![0]);
Assert.Equal(0.60f, input.Target![1]);
Assert.Null(input.Cmd);
}
[Fact]
public void StepInput_ParsesResetCommand()
{
var json = """{"cmd":"reset","seed":12345}""";
var input = JsonSerializer.Deserialize(json, ProtocolJsonContext.Default.StepInput);
Assert.NotNull(input);
Assert.Equal("reset", input!.Cmd);
Assert.Equal(12345, input.Seed);
}
[Fact]
public void StepInput_ParsesResetCommandWithoutSeed()
{
var json = """{"cmd":"reset"}""";
var input = JsonSerializer.Deserialize(json, ProtocolJsonContext.Default.StepInput);
Assert.NotNull(input);
Assert.Equal("reset", input!.Cmd);
Assert.Null(input.Seed);
}
[Fact]
public void StepOutput_SerializesAllFields()
{
var output = new StepOutput
{
Obs = new[] { 0.1f, 0.2f, 0.3f, 0.4f, 0.5f, 0.6f, 0.7f },
State = new ShipStateDto { X = 0.5f, Y = 0.6f, Angle = 0.1f, Engine = 2 },
Reward = -0.42f,
Done = false,
Step = 137,
};
var json = JsonSerializer.Serialize(output, ProtocolJsonContext.Default.StepOutput);
Assert.Contains("\"obs\":", json);
Assert.Contains("\"state\":", json);
Assert.Contains("\"reward\":", json);
Assert.Contains("\"done\":false", json);
Assert.Contains("\"step\":137", json);
Assert.Contains("\"engine\":2", json);
}
[Fact]
public void InitMessage_SerializesExpectedShape()
{
var init = new InitMessage
{
Init = new InitPayload
{
World = new[] { 1.0f, 1.0f },
Dt = 0.02f,
ObsDim = 7,
NActions = 4,
},
};
var json = JsonSerializer.Serialize(init, ProtocolJsonContext.Default.InitMessage);
Assert.Contains("\"world\":[1,1]", json);
Assert.Contains("\"dt\":0.02", json);
Assert.Contains("\"obs_dim\":7", json);
Assert.Contains("\"n_actions\":4", json);
}
}
- Step 2: Run tests to verify they fail
Run: dotnet test
Expected: build error — StepInput, StepOutput, etc. do not exist.
- Step 3: Write
Protocol.cs
using System.Text.Json.Serialization;
namespace GameCli;
/// <summary>Input line from the client (per step OR a reset command).</summary>
public sealed class StepInput
{
[JsonPropertyName("action")] public int? Action { get; set; }
[JsonPropertyName("target")] public float[]? Target { get; set; }
[JsonPropertyName("cmd")] public string? Cmd { get; set; }
[JsonPropertyName("seed")] public int? Seed { get; set; }
}
/// <summary>Output line from the CLI after each step or reset.</summary>
public sealed class StepOutput
{
[JsonPropertyName("obs")] public float[] Obs { get; set; } = System.Array.Empty<float>();
[JsonPropertyName("state")] public ShipStateDto State { get; set; } = new();
[JsonPropertyName("reward")] public float Reward { get; set; }
[JsonPropertyName("done")] public bool Done { get; set; }
[JsonPropertyName("step")] public int Step { get; set; }
}
/// <summary>Renderable ship pose (subset of ShipState that the UI actually draws).</summary>
public sealed class ShipStateDto
{
[JsonPropertyName("x")] public float X { get; set; }
[JsonPropertyName("y")] public float Y { get; set; }
[JsonPropertyName("angle")] public float Angle { get; set; }
[JsonPropertyName("engine")] public int Engine { get; set; }
}
/// <summary>Startup handshake emitted as the very first stdout line.</summary>
public sealed class InitMessage
{
[JsonPropertyName("init")] public InitPayload Init { get; set; } = new();
}
public sealed class InitPayload
{
[JsonPropertyName("world")] public float[] World { get; set; } = new[] { 1f, 1f };
[JsonPropertyName("dt")] public float Dt { get; set; }
[JsonPropertyName("obs_dim")] public int ObsDim { get; set; }
[JsonPropertyName("n_actions")] public int NActions { get; set; }
}
/// <summary>Source-generated JSON contracts for AOT-friendly, allocation-lean (de)serialization.</summary>
[JsonSourceGenerationOptions(WriteIndented = false)]
[JsonSerializable(typeof(StepInput))]
[JsonSerializable(typeof(StepOutput))]
[JsonSerializable(typeof(ShipStateDto))]
[JsonSerializable(typeof(InitMessage))]
[JsonSerializable(typeof(InitPayload))]
internal partial class ProtocolJsonContext : JsonSerializerContext
{
}
- Step 4: Make
ProtocolJsonContextvisible to tests
Add to GameCli/GameCli.csproj (before the closing </Project>), so the test project can see internal types:
<ItemGroup>
<InternalsVisibleTo Include="GameCli.Tests" />
</ItemGroup>
- Step 5: Run tests to verify they pass
Run: dotnet test --filter FullyQualifiedName~ProtocolTests
Expected: 5 passed, 0 failed.
- Step 6: Commit
git add GameCli/Protocol.cs GameCli/GameCli.csproj GameCli.Tests/ProtocolTests.cs
git commit -m "feat(gamecli): add JSON protocol DTOs with source-gen context"
Task 7: Program.cs — main loop wiring stdio to physics
Files:
-
Modify:
GameCli/Program.cs(overwrite the Task 1 stub) -
Step 1: Overwrite
Program.cs
using System.Text.Json;
using GameCli;
// Deterministic PRNG for start-pose randomization on reset.
// Seeded fresh on each reset if the caller supplies a seed.
var rng = new Random();
// Current world state and step counter. Reset on {"cmd":"reset"}.
var ship = ShipState.AtCenter();
int stepCount = 0;
int lastAction = 0;
// --- Startup handshake ---------------------------------------------------
var init = new InitMessage
{
Init = new InitPayload
{
World = new[] { 1f, 1f },
Dt = Physics.Dt,
ObsDim = Observation.Dim,
NActions = 4,
},
};
Console.WriteLine(JsonSerializer.Serialize(init, ProtocolJsonContext.Default.InitMessage));
Console.Out.Flush();
// --- Read-line loop ------------------------------------------------------
string? line;
while ((line = Console.In.ReadLine()) is not null)
{
if (line.Length == 0) continue;
StepInput? input;
try
{
input = JsonSerializer.Deserialize(line, ProtocolJsonContext.Default.StepInput);
}
catch (JsonException ex)
{
Console.Error.WriteLine($"invalid json: {ex.Message}");
continue;
}
if (input is null) continue;
// Reset command.
if (input.Cmd == "reset")
{
if (input.Seed is int seed) rng = new Random(seed);
ship = RandomStartPose(rng);
stepCount = 0;
lastAction = 0;
WriteObservation((0.5f, 0.5f), reward: 0f, done: false);
continue;
}
// Step command.
int action = input.Action ?? 0;
var target = (input.Target is { Length: >= 2 })
? (input.Target[0], input.Target[1])
: (0.5f, 0.5f);
ship = Physics.Step(ship, action);
lastAction = action;
stepCount++;
float reward = Reward.Compute(ship, target, action);
WriteObservation(target, reward, done: false);
}
// --- Helpers -------------------------------------------------------------
void WriteObservation((float X, float Y) target, float reward, bool done)
{
var output = new StepOutput
{
Obs = Observation.Build(ship, target),
State = new ShipStateDto
{
X = ship.X,
Y = ship.Y,
Angle = ship.Angle,
Engine = lastAction,
},
Reward = reward,
Done = done,
Step = stepCount,
};
Console.WriteLine(JsonSerializer.Serialize(output, ProtocolJsonContext.Default.StepOutput));
Console.Out.Flush();
}
static ShipState RandomStartPose(Random rng)
{
// Random position in a comfortable box; zero velocity, near-upright.
float x = 0.2f + (float)rng.NextDouble() * 0.6f;
float y = 0.2f + (float)rng.NextDouble() * 0.4f;
float angle = ((float)rng.NextDouble() - 0.5f) * 0.4f; // ±0.2 rad
return new ShipState(x, y, 0f, 0f, angle, 0f);
}
- Step 2: Verify it builds
Run: dotnet build
Expected: build succeeds. Warnings are OK; errors are not.
- Step 3: Smoke-run the binary by hand
Run:
echo '{"action":0,"target":[0.5,0.5]}' | dotnet run --project GameCli -c Release
Expected: two JSON lines on stdout — first the init handshake, second a step output.
- Step 4: Commit
git add GameCli/Program.cs
git commit -m "feat(gamecli): wire stdio read-line loop to physics + reward + observation"
Task 8: End-to-end smoke test — spawn the built binary
Files:
-
Create:
GameCli.Tests/ProgramSmokeTests.cs -
Step 1: Write the failing test
Create GameCli.Tests/ProgramSmokeTests.cs:
using System.Diagnostics;
using System.Text.Json;
using GameCli;
using Xunit;
namespace GameCli.Tests;
public class ProgramSmokeTests
{
private static Process StartGameCli()
{
// Locate the GameCli binary relative to the test binary's output dir.
// Tests run from GameCli.Tests/bin/<Config>/net8.0/, so ../../../.. gets to repo root.
var testDir = AppContext.BaseDirectory;
var repoRoot = Path.GetFullPath(Path.Combine(testDir, "..", "..", "..", ".."));
var projectPath = Path.Combine(repoRoot, "GameCli", "GameCli.csproj");
Assert.True(File.Exists(projectPath),
$"could not find GameCli.csproj at {projectPath}");
var psi = new ProcessStartInfo
{
FileName = "dotnet",
ArgumentList = { "run", "--project", projectPath, "-c", "Release", "--no-build" },
RedirectStandardInput = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
};
var p = Process.Start(psi)
?? throw new InvalidOperationException("failed to start GameCli");
return p;
}
[Fact]
public void FirstOutputLine_IsInitHandshake()
{
// Ensure release build exists so --no-build works reliably.
RunOrFail("dotnet", "build -c Release");
using var p = StartGameCli();
try
{
var line = p.StandardOutput.ReadLine();
Assert.NotNull(line);
var init = JsonSerializer.Deserialize(line!, ProtocolJsonContext.Default.InitMessage);
Assert.NotNull(init?.Init);
Assert.Equal(Physics.Dt, init!.Init.Dt);
Assert.Equal(Observation.Dim, init.Init.ObsDim);
Assert.Equal(4, init.Init.NActions);
}
finally
{
p.StandardInput.Close();
p.WaitForExit(3000);
}
}
[Fact]
public void StepRoundTrip_ReturnsSevenDimObservation()
{
RunOrFail("dotnet", "build -c Release");
using var p = StartGameCli();
try
{
_ = p.StandardOutput.ReadLine(); // skip init
p.StandardInput.WriteLine("""{"action":0,"target":[0.5,0.5]}""");
p.StandardInput.Flush();
var line = p.StandardOutput.ReadLine();
Assert.NotNull(line);
var output = JsonSerializer.Deserialize(line!, ProtocolJsonContext.Default.StepOutput);
Assert.NotNull(output);
Assert.Equal(7, output!.Obs.Length);
Assert.Equal(1, output.Step);
}
finally
{
p.StandardInput.Close();
p.WaitForExit(3000);
}
}
[Fact]
public void ResetCommand_ResetsStepCounter()
{
RunOrFail("dotnet", "build -c Release");
using var p = StartGameCli();
try
{
_ = p.StandardOutput.ReadLine(); // init
// Take three steps.
for (int i = 0; i < 3; i++)
{
p.StandardInput.WriteLine("""{"action":0,"target":[0.5,0.5]}""");
}
p.StandardInput.Flush();
string? last = null;
for (int i = 0; i < 3; i++) last = p.StandardOutput.ReadLine();
var afterThree = JsonSerializer.Deserialize(last!, ProtocolJsonContext.Default.StepOutput);
Assert.Equal(3, afterThree!.Step);
// Reset. Expect step=0 on the next output line.
p.StandardInput.WriteLine("""{"cmd":"reset","seed":42}""");
p.StandardInput.Flush();
var afterReset = JsonSerializer.Deserialize(
p.StandardOutput.ReadLine()!, ProtocolJsonContext.Default.StepOutput);
Assert.Equal(0, afterReset!.Step);
}
finally
{
p.StandardInput.Close();
p.WaitForExit(3000);
}
}
private static void RunOrFail(string file, string args)
{
var psi = new ProcessStartInfo(file, args)
{
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
};
using var p = Process.Start(psi)!;
p.WaitForExit(120_000);
if (p.ExitCode != 0)
throw new Exception($"{file} {args} exited {p.ExitCode}\nstderr:\n{p.StandardError.ReadToEnd()}");
}
}
- Step 2: Run tests to verify they pass
Run: dotnet test --filter FullyQualifiedName~ProgramSmokeTests
Expected: 3 passed, 0 failed.
If a smoke test hangs waiting on ReadLine, the CLI probably didn't flush stdout. Verify Console.Out.Flush() calls are in place.
- Step 3: Run the full test suite
Run: dotnet test
Expected: 24 total (6 physics + 5 reward + 5 observation + 5 protocol + 3 smoke), all passed.
- Step 4: Commit
git add GameCli.Tests/ProgramSmokeTests.cs
git commit -m "test(gamecli): end-to-end smoke tests exercising the built binary"
Task 9: Publish a self-contained binary and manual verification
Files: (no new files)
- Step 1: Publish a Release build
Run: dotnet publish GameCli/GameCli.csproj -c Release -o publish/GameCli
Expected: publish/GameCli/GameCli (or GameCli.exe on Windows) exists.
- Step 2: Manually drive a few steps
Run:
(echo '{"action":0,"target":[0.5,0.5]}'; \
echo '{"action":2,"target":[0.5,0.3]}'; \
echo '{"action":2,"target":[0.5,0.3]}'; \
echo '{"cmd":"reset","seed":1}'; \
echo '{"action":0,"target":[0.5,0.5]}') \
| ./publish/GameCli/GameCli
Expected output shape:
- Line 1 =
initmessage. - Lines 2-4 =
stepoutputs withstepcounter 1, 2, 3. VY should be positive on line 2 (gravity), and start decreasing on lines 3-4 (main engine fighting gravity). - Line 5 = reset output with
step: 0. - Line 6 = first step after reset,
step: 1.
- Step 3: Update
.gitignoreto excludepublish/
Append to .gitignore:
publish/
- Step 4: Commit
git add .gitignore
git commit -m "chore: gitignore publish/ directory"
Definition of done for Plan 1
dotnet testreports 24 passed, 0 failed.dotnet publish GameCli/GameCli.csproj -c Releaseproduces a runnable binary.- Manually feeding step JSON to the binary produces well-formed observation JSON.
- No file exceeds ~150 lines; every source file has a single clear responsibility.
- All work committed as small feat/test commits on
main.
The next plan (Plan 2) will wrap this CLI as a gym.Env in Python and train the PPO policy.