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();
}