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
+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];
}
}