Refactor project structure and update documentation. Replace PixiJS with plain DOM for UI rendering, enhance README with game features, and revise protocol documentation for HTTP API. Remove unused files and streamline client code for better maintainability.
ci / server (push) Failing after 3m31s
ci / client (push) Successful in 17s

This commit is contained in:
Leonid Pershin
2026-08-18 12:27:30 +03:00
parent e6739e7912
commit b9ddc018d3
73 changed files with 4387 additions and 2930 deletions
-9
View File
@@ -1,9 +0,0 @@
namespace HSchool.Protocol;
/// <summary>Tells the renderer which visual to use for a snapshot entity.</summary>
public enum EntityKind : byte
{
Unknown = 0,
Player = 1,
Obstacle = 2,
}
-12
View File
@@ -1,12 +0,0 @@
namespace HSchool.Protocol;
/// <summary>Bitmask of movement intents sent by the client each input frame.</summary>
[Flags]
public enum InputButtons : byte
{
None = 0,
Up = 1 << 0,
Down = 1 << 1,
Left = 1 << 2,
Right = 1 << 3,
}
+22 -18
View File
@@ -1,18 +1,22 @@
namespace HSchool.Protocol;
/// <summary>
/// First byte of every frame. Client-to-server ids live in 0x00-0x7F,
/// server-to-client ids in 0x80-0xFF, so a misrouted frame is obvious.
/// </summary>
public enum MessageType : byte
{
None = 0x00,
ClientHello = 0x01,
ClientInput = 0x02,
ClientPing = 0x03,
ServerWelcome = 0x81,
ServerSnapshot = 0x82,
ServerPong = 0x83,
}
namespace HSchool.Protocol;
/// <summary>
/// First byte of every frame. Client-to-server ids live in 0x00-0x7F,
/// server-to-client ids in 0x80-0xFF, so a misrouted frame is obvious.
/// </summary>
public enum MessageType : byte
{
None = 0x00,
ClientHello = 0x01,
ClientPing = 0x02,
ClientOpenSchool = 0x03,
ClientCloseSchool = 0x04,
ClientSetRunning = 0x05,
ClientSetSpeed = 0x06,
ServerWelcome = 0x81,
ServerPong = 0x82,
ServerClock = 0x83,
ServerSchoolGone = 0x84,
}
+29 -27
View File
@@ -1,37 +1,39 @@
namespace HSchool.Protocol;
/// <summary>First frame from the client: protocol version handshake plus display name.</summary>
public readonly record struct ClientHelloMessage(byte ProtocolVersion, string PlayerName);
/// <summary>
/// Movement intent for one client frame. <paramref name="Sequence"/> is echoed back
/// in future snapshots once client-side prediction lands.
/// </summary>
public readonly record struct ClientInputMessage(uint Sequence, InputButtons Buttons);
/// <summary>First frame from the client; carries nothing but the version handshake.</summary>
public readonly record struct ClientHelloMessage(byte ProtocolVersion);
/// <summary>Round-trip probe; the server mirrors <paramref name="ClientTimeMs"/> back untouched.</summary>
public readonly record struct ClientPingMessage(long ClientTimeMs);
/// <summary>
/// Sent once per connection, before the first snapshot.
/// <paramref name="PlayerEntityId"/> is the replication id of this client's own avatar,
/// so the renderer can tell it apart from everyone else.
/// </summary>
public readonly record struct ServerWelcomeMessage(
byte ProtocolVersion,
uint PlayerEntityId,
byte TickRate,
float WorldWidth,
float WorldHeight);
/// <summary>Asks for clock updates of one school. Starts its calendar running.</summary>
public readonly record struct ClientOpenSchoolMessage(int SchoolId);
/// <summary>One entity inside a snapshot. Kept flat and blittable on purpose.</summary>
public readonly record struct EntitySnapshot(
uint Id,
EntityKind Kind,
float X,
float Y,
float Radius,
uint Color);
/// <summary>
/// Play or pause the open school. Running and speed are separate messages on purpose: a button
/// that also resent the other field would clobber it with whatever the client last saw.
/// </summary>
public readonly record struct ClientSetRunningMessage(bool Running);
/// <summary>Change the speed of the open school without touching whether it runs.</summary>
public readonly record struct ClientSetSpeedMessage(byte SpeedIndex);
/// <summary>Sent once per connection, before anything else.</summary>
public readonly record struct ServerWelcomeMessage(byte ProtocolVersion, byte TickRate, byte MaxSchools);
/// <summary>Answer to <see cref="ClientPingMessage"/>, carrying the current server tick.</summary>
public readonly record struct ServerPongMessage(long ClientTimeMs, uint ServerTick);
/// <summary>
/// State of the open school's calendar, sent every tick.
/// <paramref name="GameTimeUnixMs"/> is the in-game date as milliseconds since the Unix epoch,
/// interpreted as UTC — the game calendar has no time zone.
/// </summary>
public readonly record struct ServerClockMessage(
int SchoolId,
long GameTimeUnixMs,
bool Running,
byte SpeedIndex);
/// <summary>The open school no longer exists (deleted from another tab); the client returns to the menu.</summary>
public readonly record struct ServerSchoolGoneMessage(int SchoolId);
+57 -75
View File
@@ -1,75 +1,57 @@
using System.Buffers.Binary;
using System.Text;
namespace HSchool.Protocol;
/// <summary>Little-endian cursor over a received frame. Mirror of <see cref="PacketWriter"/>.</summary>
public ref struct PacketReader(ReadOnlySpan<byte> buffer)
{
private readonly ReadOnlySpan<byte> _buffer = buffer;
private int _position = 0;
public readonly int Position => _position;
public readonly int Remaining => _buffer.Length - _position;
public byte ReadByte()
{
EnsureAvailable(sizeof(byte));
var value = _buffer[_position];
_position += sizeof(byte);
return value;
}
public MessageType ReadMessageType() => (MessageType)ReadByte();
public ushort ReadUInt16()
{
EnsureAvailable(sizeof(ushort));
var value = BinaryPrimitives.ReadUInt16LittleEndian(_buffer[_position..]);
_position += sizeof(ushort);
return value;
}
public uint ReadUInt32()
{
EnsureAvailable(sizeof(uint));
var value = BinaryPrimitives.ReadUInt32LittleEndian(_buffer[_position..]);
_position += sizeof(uint);
return value;
}
public long ReadInt64()
{
EnsureAvailable(sizeof(long));
var value = BinaryPrimitives.ReadInt64LittleEndian(_buffer[_position..]);
_position += sizeof(long);
return value;
}
public float ReadSingle()
{
EnsureAvailable(sizeof(float));
var value = BinaryPrimitives.ReadSingleLittleEndian(_buffer[_position..]);
_position += sizeof(float);
return value;
}
public string ReadShortString()
{
var byteCount = ReadByte();
EnsureAvailable(byteCount);
var value = Encoding.UTF8.GetString(_buffer.Slice(_position, byteCount));
_position += byteCount;
return value;
}
private readonly void EnsureAvailable(int bytes)
{
if (_position + bytes > _buffer.Length)
{
throw new ProtocolException(
$"Truncated frame: need {bytes} bytes at offset {_position}, only {Remaining} available.");
}
}
}
using System.Buffers.Binary;
namespace HSchool.Protocol;
/// <summary>Little-endian cursor over a received frame. Mirror of <see cref="PacketWriter"/>.</summary>
public ref struct PacketReader(ReadOnlySpan<byte> buffer)
{
private readonly ReadOnlySpan<byte> _buffer = buffer;
private int _position = 0;
public readonly int Position => _position;
public readonly int Remaining => _buffer.Length - _position;
public byte ReadByte()
{
EnsureAvailable(sizeof(byte));
var value = _buffer[_position];
_position += sizeof(byte);
return value;
}
public MessageType ReadMessageType() => (MessageType)ReadByte();
public uint ReadUInt32()
{
EnsureAvailable(sizeof(uint));
var value = BinaryPrimitives.ReadUInt32LittleEndian(_buffer[_position..]);
_position += sizeof(uint);
return value;
}
public int ReadInt32()
{
EnsureAvailable(sizeof(int));
var value = BinaryPrimitives.ReadInt32LittleEndian(_buffer[_position..]);
_position += sizeof(int);
return value;
}
public long ReadInt64()
{
EnsureAvailable(sizeof(long));
var value = BinaryPrimitives.ReadInt64LittleEndian(_buffer[_position..]);
_position += sizeof(long);
return value;
}
private readonly void EnsureAvailable(int bytes)
{
if (_position + bytes > _buffer.Length)
{
throw new ProtocolException(
$"Truncated frame: need {bytes} bytes at offset {_position}, only {Remaining} available.");
}
}
}
+54 -80
View File
@@ -1,80 +1,54 @@
using System.Buffers.Binary;
using System.Text;
namespace HSchool.Protocol;
/// <summary>
/// Little-endian cursor over a caller-owned buffer. Little-endian matches the
/// browser's <c>DataView</c> calls in <c>src/HSchool.Client/src/net/protocol.ts</c>.
/// </summary>
public ref struct PacketWriter(Span<byte> buffer)
{
private readonly Span<byte> _buffer = buffer;
private int _position = 0;
public readonly int Position => _position;
public readonly ReadOnlySpan<byte> Written => _buffer[.._position];
public void WriteByte(byte value)
{
EnsureRoom(sizeof(byte));
_buffer[_position] = value;
_position += sizeof(byte);
}
public void WriteMessageType(MessageType value) => WriteByte((byte)value);
public void WriteUInt16(ushort value)
{
EnsureRoom(sizeof(ushort));
BinaryPrimitives.WriteUInt16LittleEndian(_buffer[_position..], value);
_position += sizeof(ushort);
}
public void WriteUInt32(uint value)
{
EnsureRoom(sizeof(uint));
BinaryPrimitives.WriteUInt32LittleEndian(_buffer[_position..], value);
_position += sizeof(uint);
}
public void WriteInt64(long value)
{
EnsureRoom(sizeof(long));
BinaryPrimitives.WriteInt64LittleEndian(_buffer[_position..], value);
_position += sizeof(long);
}
public void WriteSingle(float value)
{
EnsureRoom(sizeof(float));
BinaryPrimitives.WriteSingleLittleEndian(_buffer[_position..], value);
_position += sizeof(float);
}
/// <summary>Writes a UTF-8 string prefixed with a single length byte.</summary>
public void WriteShortString(string value)
{
var byteCount = Encoding.UTF8.GetByteCount(value);
if (byteCount > ProtocolConstants.MaxPlayerNameBytes)
{
throw new ProtocolException(
$"String is {byteCount} bytes, limit is {ProtocolConstants.MaxPlayerNameBytes}.");
}
WriteByte((byte)byteCount);
EnsureRoom(byteCount);
Encoding.UTF8.GetBytes(value, _buffer[_position..]);
_position += byteCount;
}
private readonly void EnsureRoom(int bytes)
{
if (_position + bytes > _buffer.Length)
{
throw new ProtocolException(
$"Buffer overflow: need {bytes} more bytes at offset {_position}, capacity is {_buffer.Length}.");
}
}
}
using System.Buffers.Binary;
namespace HSchool.Protocol;
/// <summary>
/// Little-endian cursor over a caller-owned buffer. Little-endian matches the
/// browser's <c>DataView</c> calls in <c>src/HSchool.Client/src/net/protocol.ts</c>.
/// </summary>
public ref struct PacketWriter(Span<byte> buffer)
{
private readonly Span<byte> _buffer = buffer;
private int _position = 0;
public readonly int Position => _position;
public void WriteByte(byte value)
{
EnsureRoom(sizeof(byte));
_buffer[_position] = value;
_position += sizeof(byte);
}
public void WriteMessageType(MessageType value) => WriteByte((byte)value);
public void WriteUInt32(uint value)
{
EnsureRoom(sizeof(uint));
BinaryPrimitives.WriteUInt32LittleEndian(_buffer[_position..], value);
_position += sizeof(uint);
}
public void WriteInt32(int value)
{
EnsureRoom(sizeof(int));
BinaryPrimitives.WriteInt32LittleEndian(_buffer[_position..], value);
_position += sizeof(int);
}
public void WriteInt64(long value)
{
EnsureRoom(sizeof(long));
BinaryPrimitives.WriteInt64LittleEndian(_buffer[_position..], value);
_position += sizeof(long);
}
private readonly void EnsureRoom(int bytes)
{
if (_position + bytes > _buffer.Length)
{
throw new ProtocolException(
$"Buffer overflow: need {bytes} more bytes at offset {_position}, capacity is {_buffer.Length}.");
}
}
}
+85 -75
View File
@@ -7,21 +7,14 @@ namespace HSchool.Protocol;
/// </summary>
public static class ProtocolCodec
{
/// <summary>Largest frame this codec produces; handlers can size their buffers from it.</summary>
public const int MaxFrameSize = 16;
public static int WriteHello(Span<byte> destination, in ClientHelloMessage message)
{
var writer = new PacketWriter(destination);
writer.WriteMessageType(MessageType.ClientHello);
writer.WriteByte(message.ProtocolVersion);
writer.WriteShortString(message.PlayerName);
return writer.Position;
}
public static int WriteInput(Span<byte> destination, in ClientInputMessage message)
{
var writer = new PacketWriter(destination);
writer.WriteMessageType(MessageType.ClientInput);
writer.WriteUInt32(message.Sequence);
writer.WriteByte((byte)message.Buttons);
return writer.Position;
}
@@ -33,15 +26,44 @@ public static class ProtocolCodec
return writer.Position;
}
public static int WriteOpenSchool(Span<byte> destination, in ClientOpenSchoolMessage message)
{
var writer = new PacketWriter(destination);
writer.WriteMessageType(MessageType.ClientOpenSchool);
writer.WriteInt32(message.SchoolId);
return writer.Position;
}
public static int WriteCloseSchool(Span<byte> destination)
{
var writer = new PacketWriter(destination);
writer.WriteMessageType(MessageType.ClientCloseSchool);
return writer.Position;
}
public static int WriteSetRunning(Span<byte> destination, in ClientSetRunningMessage message)
{
var writer = new PacketWriter(destination);
writer.WriteMessageType(MessageType.ClientSetRunning);
writer.WriteByte(message.Running ? (byte)1 : (byte)0);
return writer.Position;
}
public static int WriteSetSpeed(Span<byte> destination, in ClientSetSpeedMessage message)
{
var writer = new PacketWriter(destination);
writer.WriteMessageType(MessageType.ClientSetSpeed);
writer.WriteByte(message.SpeedIndex);
return writer.Position;
}
public static int WriteWelcome(Span<byte> destination, in ServerWelcomeMessage message)
{
var writer = new PacketWriter(destination);
writer.WriteMessageType(MessageType.ServerWelcome);
writer.WriteByte(message.ProtocolVersion);
writer.WriteUInt32(message.PlayerEntityId);
writer.WriteByte(message.TickRate);
writer.WriteSingle(message.WorldWidth);
writer.WriteSingle(message.WorldHeight);
writer.WriteByte(message.MaxSchools);
return writer.Position;
}
@@ -54,35 +76,24 @@ public static class ProtocolCodec
return writer.Position;
}
/// <summary>Writes a full-state snapshot; entities missing from it are despawned by the client.</summary>
public static int WriteSnapshot(Span<byte> destination, uint tick, ReadOnlySpan<EntitySnapshot> entities)
public static int WriteClock(Span<byte> destination, in ServerClockMessage message)
{
if (entities.Length > ushort.MaxValue)
{
throw new ProtocolException($"Snapshot holds {entities.Length} entities, limit is {ushort.MaxValue}.");
}
var writer = new PacketWriter(destination);
writer.WriteMessageType(MessageType.ServerSnapshot);
writer.WriteUInt32(tick);
writer.WriteUInt16((ushort)entities.Length);
foreach (var entity in entities)
{
writer.WriteUInt32(entity.Id);
writer.WriteByte((byte)entity.Kind);
writer.WriteSingle(entity.X);
writer.WriteSingle(entity.Y);
writer.WriteSingle(entity.Radius);
writer.WriteUInt32(entity.Color);
}
writer.WriteMessageType(MessageType.ServerClock);
writer.WriteInt32(message.SchoolId);
writer.WriteInt64(message.GameTimeUnixMs);
writer.WriteByte(message.Running ? (byte)1 : (byte)0);
writer.WriteByte(message.SpeedIndex);
return writer.Position;
}
/// <summary>Exact byte size of a snapshot frame for <paramref name="entityCount"/> entities.</summary>
public static int SnapshotSize(int entityCount) =>
ProtocolConstants.SnapshotHeaderSize + (entityCount * ProtocolConstants.EntitySnapshotSize);
public static int WriteSchoolGone(Span<byte> destination, in ServerSchoolGoneMessage message)
{
var writer = new PacketWriter(destination);
writer.WriteMessageType(MessageType.ServerSchoolGone);
writer.WriteInt32(message.SchoolId);
return writer.Position;
}
public static MessageType PeekMessageType(ReadOnlySpan<byte> source) =>
source.IsEmpty ? MessageType.None : (MessageType)source[0];
@@ -91,18 +102,7 @@ public static class ProtocolCodec
{
var reader = new PacketReader(source);
Expect(ref reader, MessageType.ClientHello);
var version = reader.ReadByte();
var name = reader.ReadShortString();
return new ClientHelloMessage(version, name);
}
public static ClientInputMessage ReadInput(ReadOnlySpan<byte> source)
{
var reader = new PacketReader(source);
Expect(ref reader, MessageType.ClientInput);
var sequence = reader.ReadUInt32();
var buttons = (InputButtons)reader.ReadByte();
return new ClientInputMessage(sequence, buttons);
return new ClientHelloMessage(reader.ReadByte());
}
public static ClientPingMessage ReadPing(ReadOnlySpan<byte> source)
@@ -112,16 +112,35 @@ public static class ProtocolCodec
return new ClientPingMessage(reader.ReadInt64());
}
public static ClientOpenSchoolMessage ReadOpenSchool(ReadOnlySpan<byte> source)
{
var reader = new PacketReader(source);
Expect(ref reader, MessageType.ClientOpenSchool);
return new ClientOpenSchoolMessage(reader.ReadInt32());
}
public static ClientSetRunningMessage ReadSetRunning(ReadOnlySpan<byte> source)
{
var reader = new PacketReader(source);
Expect(ref reader, MessageType.ClientSetRunning);
return new ClientSetRunningMessage(reader.ReadByte() != 0);
}
public static ClientSetSpeedMessage ReadSetSpeed(ReadOnlySpan<byte> source)
{
var reader = new PacketReader(source);
Expect(ref reader, MessageType.ClientSetSpeed);
return new ClientSetSpeedMessage(reader.ReadByte());
}
public static ServerWelcomeMessage ReadWelcome(ReadOnlySpan<byte> source)
{
var reader = new PacketReader(source);
Expect(ref reader, MessageType.ServerWelcome);
var version = reader.ReadByte();
var playerEntityId = reader.ReadUInt32();
var tickRate = reader.ReadByte();
var width = reader.ReadSingle();
var height = reader.ReadSingle();
return new ServerWelcomeMessage(version, playerEntityId, tickRate, width, height);
var maxSchools = reader.ReadByte();
return new ServerWelcomeMessage(version, tickRate, maxSchools);
}
public static ServerPongMessage ReadPong(ReadOnlySpan<byte> source)
@@ -133,31 +152,22 @@ public static class ProtocolCodec
return new ServerPongMessage(clientTime, serverTick);
}
/// <summary>Reads a snapshot into <paramref name="destination"/> and returns the entity count.</summary>
public static int ReadSnapshot(ReadOnlySpan<byte> source, Span<EntitySnapshot> destination, out uint tick)
public static ServerClockMessage ReadClock(ReadOnlySpan<byte> source)
{
var reader = new PacketReader(source);
Expect(ref reader, MessageType.ServerSnapshot);
tick = reader.ReadUInt32();
var count = reader.ReadUInt16();
Expect(ref reader, MessageType.ServerClock);
var schoolId = reader.ReadInt32();
var gameTime = reader.ReadInt64();
var running = reader.ReadByte() != 0;
var speedIndex = reader.ReadByte();
return new ServerClockMessage(schoolId, gameTime, running, speedIndex);
}
if (count > destination.Length)
{
throw new ProtocolException($"Snapshot holds {count} entities, destination fits {destination.Length}.");
}
for (var i = 0; i < count; i++)
{
destination[i] = new EntitySnapshot(
reader.ReadUInt32(),
(EntityKind)reader.ReadByte(),
reader.ReadSingle(),
reader.ReadSingle(),
reader.ReadSingle(),
reader.ReadUInt32());
}
return count;
public static ServerSchoolGoneMessage ReadSchoolGone(ReadOnlySpan<byte> source)
{
var reader = new PacketReader(source);
Expect(ref reader, MessageType.ServerSchoolGone);
return new ServerSchoolGoneMessage(reader.ReadInt32());
}
private static void Expect(ref PacketReader reader, MessageType expected)
+11 -20
View File
@@ -1,20 +1,11 @@
namespace HSchool.Protocol;
/// <summary>Wire-format constants shared by the server and the browser client.</summary>
public static class ProtocolConstants
{
/// <summary>Bumped on every breaking change to the binary layout.</summary>
public const byte Version = 1;
/// <summary>Upper bound for a single WebSocket frame accepted by the server.</summary>
public const int MaxMessageSize = 64 * 1024;
/// <summary>Bytes of a single entity inside a snapshot payload.</summary>
public const int EntitySnapshotSize = sizeof(uint) + sizeof(byte) + (sizeof(float) * 3) + sizeof(uint);
/// <summary>Bytes of the snapshot header: message type + tick + entity count.</summary>
public const int SnapshotHeaderSize = sizeof(byte) + sizeof(uint) + sizeof(ushort);
/// <summary>Maximum UTF-8 byte length of a player name.</summary>
public const int MaxPlayerNameBytes = 32;
}
namespace HSchool.Protocol;
/// <summary>Wire-format constants shared by the server and the browser client.</summary>
public static class ProtocolConstants
{
/// <summary>Bumped on every breaking change to the binary layout.</summary>
public const byte Version = 3;
/// <summary>Upper bound for a single WebSocket frame accepted by the server.</summary>
public const int MaxMessageSize = 8 * 1024;
}