Update .gitignore to exclude TypeScript build info and add dist directory. Expand README with project overview, technology stack, prerequisites, and instructions for running and testing the application.
ci / server (push) Failing after 4m10s
ci / client (push) Successful in 17s

This commit is contained in:
Leonid Pershin
2026-08-18 11:11:48 +03:00
parent 84aafb0b69
commit e6739e7912
84 changed files with 5698 additions and 2 deletions
+20
View File
@@ -0,0 +1,20 @@
using HSchool.Protocol;
namespace HSchool.Server.Game;
/// <summary>
/// Work item handed from a connection thread to the loop thread. The simulation is
/// single-threaded, so every mutation arrives as one of these.
/// </summary>
internal abstract record GameCommand
{
/// <summary>
/// Spawns an avatar for the connection. The loop completes <see cref="EntityId"/>
/// with the replication id so the handler can send a Welcome frame.
/// </summary>
internal sealed record Join(uint PlayerId, TaskCompletionSource<uint> EntityId) : GameCommand;
internal sealed record Leave(uint PlayerId) : GameCommand;
internal sealed record Input(uint PlayerId, InputButtons Buttons, uint Sequence) : GameCommand;
}
@@ -0,0 +1,13 @@
using System.Collections.Concurrent;
namespace HSchool.Server.Game;
/// <summary>Multi-producer, single-consumer inbox drained at the start of every tick.</summary>
internal sealed class GameCommandQueue
{
private readonly ConcurrentQueue<GameCommand> _commands = new();
public void Enqueue(GameCommand command) => _commands.Enqueue(command);
public bool TryDequeue(out GameCommand command) => _commands.TryDequeue(out command!);
}
+153
View File
@@ -0,0 +1,153 @@
using System.Diagnostics;
using HSchool.Protocol;
using HSchool.Server.Net;
using HSchool.Simulation;
using Microsoft.Extensions.Options;
namespace HSchool.Server.Game;
/// <summary>
/// Owns the authoritative <see cref="GameWorld"/> and drives it at a fixed rate:
/// drain commands, step the simulation, broadcast a full snapshot.
/// The world is touched from this thread only.
/// </summary>
internal sealed class GameLoopService(
IOptions<SimulationOptions> options,
GameCommandQueue commands,
ClientRegistry clients,
GameMetrics metrics,
ILogger<GameLoopService> logger) : BackgroundService
{
/// <summary>Upper bound on steps simulated in one wake-up; the rest of the backlog is dropped.</summary>
private const int MaxCatchUpSteps = 5;
private readonly SimulationOptions _options = options.Value;
private readonly List<EntitySnapshot> _snapshotBuffer = [];
private readonly GameWorld _world = new(options.Value);
private uint _currentTick;
private int _playerCount;
public uint CurrentTick => Volatile.Read(ref _currentTick);
public int PlayerCount => Volatile.Read(ref _playerCount);
public SimulationOptions Options => _options;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
logger.LogInformation(
"Game loop starting at {TickRate} Hz on a {Width}x{Height} field.",
_options.TickRate,
_options.WorldWidth,
_options.WorldHeight);
using var timer = new PeriodicTimer(_options.TickInterval);
var fixedDelta = _options.FixedDeltaTime;
var lastTimestamp = Stopwatch.GetTimestamp();
var accumulator = 0d;
try
{
while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false))
{
var now = Stopwatch.GetTimestamp();
accumulator += Stopwatch.GetElapsedTime(lastTimestamp, now).TotalSeconds;
lastTimestamp = now;
DrainCommands();
var steps = 0;
while (accumulator >= fixedDelta && steps < MaxCatchUpSteps)
{
var stepStarted = Stopwatch.GetTimestamp();
_world.Tick();
metrics.RecordTick(Stopwatch.GetElapsedTime(stepStarted, Stopwatch.GetTimestamp()).TotalMilliseconds);
accumulator -= fixedDelta;
steps++;
}
if (steps == MaxCatchUpSteps && accumulator >= fixedDelta)
{
logger.LogWarning("Game loop is behind by {Backlog:F0} ms; dropping the backlog.", accumulator * 1000);
accumulator = 0d;
}
if (steps > 0)
{
Volatile.Write(ref _currentTick, _world.CurrentTick);
BroadcastSnapshot();
}
}
}
catch (OperationCanceledException)
{
// Normal shutdown.
}
finally
{
_world.Dispose();
logger.LogInformation("Game loop stopped at tick {Tick}.", _world.CurrentTick);
}
}
private void DrainCommands()
{
while (commands.TryDequeue(out var command))
{
switch (command)
{
case GameCommand.Join join:
HandleJoin(join);
break;
case GameCommand.Leave leave:
_world.DespawnPlayer(leave.PlayerId);
Volatile.Write(ref _playerCount, _world.PlayerCount);
metrics.PlayerLeft();
logger.LogInformation("Player {PlayerId} left; {PlayerCount} remaining.", leave.PlayerId, _world.PlayerCount);
break;
case GameCommand.Input input:
_world.ApplyInput(input.PlayerId, input.Buttons, input.Sequence);
break;
}
}
}
private void HandleJoin(GameCommand.Join join)
{
try
{
var entityId = _world.SpawnPlayer(join.PlayerId);
Volatile.Write(ref _playerCount, _world.PlayerCount);
metrics.PlayerJoined();
join.EntityId.TrySetResult(entityId);
logger.LogInformation(
"Player {PlayerId} joined as entity {EntityId}; {PlayerCount} connected.",
join.PlayerId,
entityId,
_world.PlayerCount);
}
catch (Exception ex)
{
join.EntityId.TrySetException(ex);
}
}
private void BroadcastSnapshot()
{
_world.CaptureSnapshot(_snapshotBuffer);
// One immutable buffer is shared by every recipient, so nothing has to be copied per client.
var frame = new byte[ProtocolCodec.SnapshotSize(_snapshotBuffer.Count)];
var written = ProtocolCodec.WriteSnapshot(frame, _world.CurrentTick, System.Runtime.InteropServices.CollectionsMarshal.AsSpan(_snapshotBuffer));
var recipients = clients.Broadcast(frame.AsMemory(0, written));
if (recipients > 0)
{
metrics.SnapshotSent(written, recipients);
}
}
}
+38
View File
@@ -0,0 +1,38 @@
using System.Diagnostics.Metrics;
namespace HSchool.Server.Game;
/// <summary>Game-loop counters surfaced in the Aspire dashboard.</summary>
internal sealed class GameMetrics : IDisposable
{
public const string MeterName = "HSchool.Server.Game";
private readonly Meter _meter;
private readonly Counter<long> _ticks;
private readonly Histogram<double> _tickDuration;
private readonly UpDownCounter<long> _connectedPlayers;
private readonly Counter<long> _snapshotBytes;
public GameMetrics(IMeterFactory meterFactory)
{
_meter = meterFactory.Create(MeterName);
_ticks = _meter.CreateCounter<long>("hschool.game.ticks", "{tick}", "Simulation steps executed.");
_tickDuration = _meter.CreateHistogram<double>("hschool.game.tick.duration", "ms", "Wall time of one simulation step.");
_connectedPlayers = _meter.CreateUpDownCounter<long>("hschool.game.players", "{player}", "Currently connected players.");
_snapshotBytes = _meter.CreateCounter<long>("hschool.game.snapshot.bytes", "By", "Snapshot bytes pushed to clients.");
}
public void RecordTick(double durationMs)
{
_ticks.Add(1);
_tickDuration.Record(durationMs);
}
public void PlayerJoined() => _connectedPlayers.Add(1);
public void PlayerLeft() => _connectedPlayers.Add(-1);
public void SnapshotSent(int bytes, int recipients) => _snapshotBytes.Add((long)bytes * recipients);
public void Dispose() => _meter.Dispose();
}
+16
View File
@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<RootNamespace>HSchool.Server</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\HSchool.ServiceDefaults\HSchool.ServiceDefaults.csproj" />
<ProjectReference Include="..\HSchool.Simulation\HSchool.Simulation.csproj" />
</ItemGroup>
</Project>
+42
View File
@@ -0,0 +1,42 @@
using System.Collections.Concurrent;
using System.Net.WebSockets;
namespace HSchool.Server.Net;
/// <summary>Tracks live connections and hands out player ids.</summary>
internal sealed class ClientRegistry
{
private readonly ConcurrentDictionary<uint, GameClient> _clients = new();
private uint _nextPlayerId;
public int Count => _clients.Count;
public GameClient Add(WebSocket socket)
{
var playerId = Interlocked.Increment(ref _nextPlayerId);
var client = new GameClient(playerId, socket);
_clients[playerId] = client;
return client;
}
public void Remove(uint playerId) => _clients.TryRemove(playerId, out _);
/// <summary>
/// Queues the same frame for every client that finished its handshake; the buffer must not
/// be reused afterwards.
/// </summary>
public int Broadcast(ReadOnlyMemory<byte> frame)
{
var recipients = 0;
foreach (var client in _clients.Values)
{
if (client.IsReady && client.TrySend(frame))
{
recipients++;
}
}
return recipients;
}
}
+58
View File
@@ -0,0 +1,58 @@
using System.Net.WebSockets;
using System.Threading.Channels;
namespace HSchool.Server.Net;
/// <summary>
/// One connected browser. Frames are queued instead of written inline so a slow client
/// can never stall the game loop; when the outbox overflows the oldest snapshot is dropped,
/// which is exactly what you want for state that is resent 20 times a second.
/// </summary>
internal sealed class GameClient(uint playerId, WebSocket socket)
{
private const int OutboxCapacity = 32;
private readonly Channel<ReadOnlyMemory<byte>> _outbox =
Channel.CreateBounded<ReadOnlyMemory<byte>>(new BoundedChannelOptions(OutboxCapacity)
{
FullMode = BoundedChannelFullMode.DropOldest,
SingleReader = true,
SingleWriter = false,
});
private bool _ready;
public uint PlayerId { get; } = playerId;
public WebSocket Socket { get; } = socket;
public string Name { get; set; } = $"player-{playerId}";
/// <summary>
/// Set once the welcome frame is out. Snapshots are only queued for ready clients, so a
/// connection never sees world state before it knows its own entity id.
/// </summary>
public bool IsReady => Volatile.Read(ref _ready);
public void MarkReady() => Volatile.Write(ref _ready, true);
/// <summary>Queues a frame. Returns false once the connection is shutting down.</summary>
public bool TrySend(ReadOnlyMemory<byte> frame) => _outbox.Writer.TryWrite(frame);
/// <summary>Pumps queued frames to the socket until cancelled or the outbox completes.</summary>
public async Task RunSendLoopAsync(CancellationToken cancellationToken)
{
await foreach (var frame in _outbox.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false))
{
if (Socket.State != WebSocketState.Open)
{
break;
}
await Socket.SendAsync(frame, WebSocketMessageType.Binary, endOfMessage: true, cancellationToken)
.ConfigureAwait(false);
}
}
public void CompleteOutbox() => _outbox.Writer.TryComplete();
}
+257
View File
@@ -0,0 +1,257 @@
using System.Buffers;
using System.Net.WebSockets;
using System.Text;
using HSchool.Protocol;
using HSchool.Server.Game;
namespace HSchool.Server.Net;
/// <summary>
/// Drives one WebSocket connection: handshake, join, then the receive loop.
/// Everything it learns from the wire is untrusted, so frames are validated before
/// they reach the simulation.
/// </summary>
internal sealed class GameSocketHandler(
ClientRegistry clients,
GameCommandQueue commands,
GameLoopService loop,
ILogger<GameSocketHandler> logger)
{
private static readonly TimeSpan HandshakeTimeout = TimeSpan.FromSeconds(5);
public async Task HandleAsync(WebSocket socket, CancellationToken cancellationToken)
{
var client = clients.Add(socket);
var buffer = ArrayPool<byte>.Shared.Rent(ProtocolConstants.MaxMessageSize);
using var connectionCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
Task? sendLoop = null;
var joined = false;
try
{
using var handshakeCts = CancellationTokenSource.CreateLinkedTokenSource(connectionCts.Token);
handshakeCts.CancelAfter(HandshakeTimeout);
var helloLength = await ReceiveFrameAsync(socket, buffer, handshakeCts.Token).ConfigureAwait(false);
if (helloLength <= 0)
{
return;
}
var hello = ProtocolCodec.ReadHello(buffer.AsSpan(0, helloLength));
if (hello.ProtocolVersion != ProtocolConstants.Version)
{
logger.LogWarning(
"Rejecting client {PlayerId}: protocol v{ClientVersion}, server speaks v{ServerVersion}.",
client.PlayerId,
hello.ProtocolVersion,
ProtocolConstants.Version);
await CloseAsync(
socket,
WebSocketCloseStatus.ProtocolError,
$"Protocol v{ProtocolConstants.Version} required.",
cancellationToken).ConfigureAwait(false);
return;
}
client.Name = SanitizeName(hello.PlayerName, client.PlayerId);
var join = new GameCommand.Join(
client.PlayerId,
new TaskCompletionSource<uint>(TaskCreationOptions.RunContinuationsAsynchronously));
commands.Enqueue(join);
var entityId = await join.EntityId.Task
.WaitAsync(HandshakeTimeout, connectionCts.Token)
.ConfigureAwait(false);
joined = true;
await SendWelcomeAsync(socket, entityId, connectionCts.Token).ConfigureAwait(false);
client.MarkReady();
// From here on every outbound frame goes through the outbox, so there is
// exactly one writer on the socket.
sendLoop = client.RunSendLoopAsync(connectionCts.Token);
await ReceiveLoopAsync(client, buffer, connectionCts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
// Client went away or the host is shutting down.
}
catch (ProtocolException ex)
{
logger.LogWarning(ex, "Malformed frame from client {PlayerId}.", client.PlayerId);
await CloseAsync(socket, WebSocketCloseStatus.InvalidPayloadData, "Malformed frame.", CancellationToken.None)
.ConfigureAwait(false);
}
catch (WebSocketException ex)
{
logger.LogDebug(ex, "Connection {PlayerId} dropped.", client.PlayerId);
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
clients.Remove(client.PlayerId);
client.CompleteOutbox();
if (joined)
{
commands.Enqueue(new GameCommand.Leave(client.PlayerId));
}
if (sendLoop is not null)
{
try
{
await sendLoop.ConfigureAwait(false);
}
catch (Exception ex) when (ex is OperationCanceledException or WebSocketException)
{
// Expected while tearing the connection down.
}
}
await connectionCts.CancelAsync().ConfigureAwait(false);
}
}
private async Task ReceiveLoopAsync(GameClient client, byte[] buffer, CancellationToken cancellationToken)
{
while (!cancellationToken.IsCancellationRequested)
{
var length = await ReceiveFrameAsync(client.Socket, buffer, cancellationToken).ConfigureAwait(false);
if (length <= 0)
{
return;
}
var frame = buffer.AsSpan(0, length);
switch (ProtocolCodec.PeekMessageType(frame))
{
case MessageType.ClientInput:
var input = ProtocolCodec.ReadInput(frame);
commands.Enqueue(new GameCommand.Input(client.PlayerId, input.Buttons, input.Sequence));
break;
case MessageType.ClientPing:
var ping = ProtocolCodec.ReadPing(frame);
SendPong(client, ping.ClientTimeMs);
break;
default:
logger.LogDebug(
"Ignoring unexpected frame 0x{MessageType:X2} from client {PlayerId}.",
frame[0],
client.PlayerId);
break;
}
}
}
/// <summary>Reads one whole message. Returns 0 on close, -1 on an oversized or non-binary frame.</summary>
private async Task<int> ReceiveFrameAsync(WebSocket socket, byte[] buffer, CancellationToken cancellationToken)
{
var offset = 0;
while (true)
{
var result = await socket
.ReceiveAsync(new ArraySegment<byte>(buffer, offset, buffer.Length - offset), cancellationToken)
.ConfigureAwait(false);
if (result.MessageType == WebSocketMessageType.Close)
{
return 0;
}
if (result.MessageType != WebSocketMessageType.Binary)
{
logger.LogDebug("Dropping non-binary frame.");
return -1;
}
offset += result.Count;
if (result.EndOfMessage)
{
return offset;
}
if (offset >= buffer.Length)
{
logger.LogWarning("Frame exceeds {Limit} bytes; closing.", ProtocolConstants.MaxMessageSize);
await CloseAsync(socket, WebSocketCloseStatus.MessageTooBig, "Frame too large.", cancellationToken)
.ConfigureAwait(false);
return -1;
}
}
}
private async Task SendWelcomeAsync(WebSocket socket, uint entityId, CancellationToken cancellationToken)
{
var options = loop.Options;
var welcome = new ServerWelcomeMessage(
ProtocolConstants.Version,
entityId,
(byte)options.TickRate,
options.WorldWidth,
options.WorldHeight);
var frame = new byte[32];
var length = ProtocolCodec.WriteWelcome(frame, welcome);
await socket
.SendAsync(frame.AsMemory(0, length), WebSocketMessageType.Binary, endOfMessage: true, cancellationToken)
.ConfigureAwait(false);
}
private void SendPong(GameClient client, long clientTimeMs)
{
var frame = new byte[16];
var length = ProtocolCodec.WritePong(frame, new ServerPongMessage(clientTimeMs, loop.CurrentTick));
client.TrySend(frame.AsMemory(0, length));
}
private static async Task CloseAsync(
WebSocket socket,
WebSocketCloseStatus status,
string description,
CancellationToken cancellationToken)
{
if (socket.State is WebSocketState.Open or WebSocketState.CloseReceived)
{
try
{
await socket.CloseAsync(status, description, cancellationToken).ConfigureAwait(false);
}
catch (WebSocketException)
{
// The peer may already be gone; nothing left to do.
}
}
}
/// <summary>Names come from the wire: strip control characters and clamp the length.</summary>
private static string SanitizeName(string name, uint playerId)
{
var trimmed = name.Trim();
if (trimmed.Length == 0)
{
return $"player-{playerId}";
}
var builder = new StringBuilder(trimmed.Length);
foreach (var character in trimmed)
{
builder.Append(char.IsControl(character) ? ' ' : character);
}
var sanitized = builder.ToString();
return sanitized.Length <= ProtocolConstants.MaxPlayerNameBytes
? sanitized
: sanitized[..ProtocolConstants.MaxPlayerNameBytes];
}
}
+88
View File
@@ -0,0 +1,88 @@
using System.Net.WebSockets;
using HSchool.Server.Game;
using HSchool.Server.Net;
using HSchool.Simulation;
var builder = WebApplication.CreateBuilder(args);
builder.AddServiceDefaults();
builder.Services.AddProblemDetails();
builder.Services.AddOpenApi();
builder.Services
.AddOptions<SimulationOptions>()
.Bind(builder.Configuration.GetSection(SimulationOptions.SectionName))
.Validate(options => options.TickRate is > 0 and <= 120, "Simulation:TickRate must be between 1 and 120.")
.Validate(options => options.WorldWidth > 0 && options.WorldHeight > 0, "World size must be positive.")
.ValidateOnStart();
builder.Services.AddSingleton<GameCommandQueue>();
builder.Services.AddSingleton<ClientRegistry>();
builder.Services.AddSingleton<GameMetrics>();
builder.Services.AddSingleton<GameSocketHandler>();
builder.Services.AddSingleton<GameLoopService>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<GameLoopService>());
builder.Services.AddOpenTelemetry().WithMetrics(metrics => metrics.AddMeter(GameMetrics.MeterName));
var app = builder.Build();
app.UseExceptionHandler();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
app.UseWebSockets(new WebSocketOptions
{
KeepAliveInterval = TimeSpan.FromSeconds(30),
});
var api = app.MapGroup("/api");
api.MapGet("/status", (GameLoopService loop, ClientRegistry clients) =>
{
var options = loop.Options;
return new GameStatusResponse(
loop.CurrentTick,
options.TickRate,
loop.PlayerCount,
clients.Count,
options.WorldWidth,
options.WorldHeight);
})
.WithName("GetGameStatus");
// The realtime channel: one binary frame per protocol message, see docs/protocol.md.
app.Map("/ws/game", async (HttpContext context, GameSocketHandler handler) =>
{
if (!context.WebSockets.IsWebSocketRequest)
{
context.Response.StatusCode = StatusCodes.Status400BadRequest;
await context.Response.WriteAsync("This endpoint expects a WebSocket upgrade.");
return;
}
using WebSocket socket = await context.WebSockets.AcceptWebSocketAsync();
await handler.HandleAsync(socket, context.RequestAborted);
});
app.MapDefaultEndpoints();
// In a published container the built client lands in wwwroot next to the server.
app.UseFileServer();
app.Run();
/// <summary>Snapshot of loop health for dashboards and integration tests.</summary>
internal sealed record GameStatusResponse(
uint Tick,
int TickRate,
int Players,
int Connections,
float WorldWidth,
float WorldHeight);
/// <summary>Exposed so <c>WebApplicationFactory</c>-style tests can reference the entry point.</summary>
public partial class Program;
@@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5180",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://localhost:7180;http://localhost:5180",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"HSchool.Server.Game": "Information"
}
}
}
+16
View File
@@ -0,0 +1,16 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"Simulation": {
"TickRate": 20,
"WorldWidth": 1600,
"WorldHeight": 900,
"PlayerSpeed": 260,
"PlayerRadius": 18
}
}