Add MrGameEng.Net: WebSocket transport + delta component replication
CI / build-test (push) Successful in 1m16s

Browsers can't speak UDP, so WebSocket is the engine's one transport.
The server side is a dependency-free RFC 6455 implementation over
TcpListener (handshake, frame codec with masking and fragmentation,
ping/pong — unit-tested against the RFC example vectors); the client
wraps ClientWebSocket, which works on desktop and maps to the browser
WebSocket in Blazor WASM. Both sit behind the poll-based INetConnection
so simulation systems drain messages from their own thread; client
sends are chained fire-and-forget (no blocking — wasm-safe).

Replication is server-authoritative: games register unmanaged component
types in a ReplicationSchema (same order both sides, up to 32 types),
ReplicationServer snapshots entities carrying NetId once per send and
ships each connection only the components that changed since its last
snapshot — a reliable ordered transport needs no acks for deltas. New
connections receive the full state through the same path; despawns are
tracked by set difference. ReplicationClient applies snapshots to a
local EntityStore and raises EntitySpawned so the game can decorate
replicated entities with presentation components.

Covered by 16 tests including a real loopback exchange between
WebSocketClient and WebSocketServer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Leonid Pershin
2026-06-12 23:35:57 +03:00
co-authored by Claude Fable 5
parent 10898b08a0
commit 3438ed77f6
16 changed files with 1492 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
using Friflo.Engine.ECS;
namespace MrGameEng.Net;
/// <summary>
/// Marks an entity as replicated and identifies it across the network. The server assigns
/// values (see <see cref="ReplicationServer.NextNetId"/>); the client creates a local
/// entity with the same <see cref="Value"/> when the first snapshot arrives.
/// </summary>
public struct NetId : IComponent
{
/// <summary>Network-wide entity id, unique per server world.</summary>
public int Value;
}
@@ -0,0 +1,92 @@
using Friflo.Engine.ECS;
namespace MrGameEng.Net;
/// <summary>
/// Client side of replication: applies snapshot messages from a
/// <see cref="ReplicationServer"/> to a local <see cref="EntityStore"/>. Unknown net ids
/// spawn local entities (carrying <see cref="NetId"/>), known ones get their changed
/// components overwritten, despawns delete. The game decorates replicated entities with
/// presentation components (sprites etc.) on top — replication never touches types outside
/// its <see cref="ReplicationSchema"/>.
/// </summary>
public sealed class ReplicationClient
{
/// <summary>Number of replicated entities currently alive locally.</summary>
public int EntityCount => _entities.Count;
/// <summary>Raised after an entity is created from a snapshot. Hook presentation setup here.</summary>
public event Action<Entity>? EntitySpawned;
private readonly ReplicationSchema _schema;
private readonly EntityStore _store;
private readonly Dictionary<int, Entity> _entities = [];
/// <summary>Creates a replication client writing into <paramref name="store"/>.</summary>
public ReplicationClient(ReplicationSchema schema, EntityStore store)
{
_schema = schema;
_store = store;
}
/// <summary>Applies every message queued on <paramref name="connection"/>.</summary>
public void Pump(INetConnection connection)
{
while (connection.TryReceive(out var message))
{
Apply(message);
}
}
/// <summary>Applies one snapshot message to the local store.</summary>
public void Apply(byte[] message)
{
using var reader = new BinaryReader(new MemoryStream(message));
if (reader.ReadByte() != ReplicationMessage.Snapshot)
{
return; // незнакомый тип сообщения — пропускаем, это не снапшот
}
var slots = _schema.Slots;
var count = reader.ReadInt32();
for (var record = 0; record < count; record++)
{
var netId = reader.ReadInt32();
var op = reader.ReadByte();
if (op == ReplicationMessage.OpDespawn)
{
if (_entities.Remove(netId, out var dead))
{
dead.DeleteEntity();
}
continue;
}
var mask = reader.ReadUInt32();
var spawned = false;
if (!_entities.TryGetValue(netId, out var entity))
{
entity = _store.CreateEntity(new NetId { Value = netId });
_entities[netId] = entity;
spawned = true;
}
foreach (var slot in slots)
{
if ((mask & (1u << slot.Bit)) == 0)
{
continue;
}
var data = reader.ReadBytes(slot.Size);
slot.Apply(entity, data, 0);
}
if (spawned)
{
EntitySpawned?.Invoke(entity);
}
}
}
}
@@ -0,0 +1,64 @@
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using Friflo.Engine.ECS;
namespace MrGameEng.Net;
/// <summary>
/// The set of component types a game replicates, registered in the same order on the
/// server and every client (the order defines the wire ids). Components must be
/// unmanaged structs — they are blitted to the wire as raw bytes, so server and client
/// must run on the same engine version. Up to 32 types.
/// </summary>
public sealed class ReplicationSchema
{
internal sealed class ComponentSlot
{
public required int Bit;
public required int Size;
public required Func<Entity, byte[], bool> TryWrite;
public required Action<Entity, byte[], int> Apply;
}
internal readonly List<ComponentSlot> Slots = [];
/// <summary>
/// Registers component type <typeparamref name="T"/> for replication. Returns this
/// schema for fluent chaining.
/// </summary>
public ReplicationSchema Register<T>()
where T : unmanaged, IComponent
{
if (Slots.Count == 32)
{
throw new InvalidOperationException(
"ReplicationSchema supports at most 32 component types."
);
}
var size = Unsafe.SizeOf<T>();
Slots.Add(
new ComponentSlot
{
Bit = Slots.Count,
Size = size,
TryWrite = (entity, buffer) =>
{
if (!entity.HasComponent<T>())
{
return false;
}
MemoryMarshal.Write(buffer, in entity.GetComponent<T>());
return true;
},
Apply = (entity, data, offset) =>
{
var value = MemoryMarshal.Read<T>(data.AsSpan(offset, size));
entity.AddComponent(value);
},
}
);
return this;
}
}
@@ -0,0 +1,174 @@
using Friflo.Engine.ECS;
namespace MrGameEng.Net;
/// <summary>
/// Server-authoritative component replication. Each call to <see cref="Send"/> snapshots
/// every entity carrying <see cref="NetId"/> and sends each connection only what changed
/// since that connection's previous snapshot (per-component deltas; a new connection gets
/// the full state the same way). Deltas need no acknowledgements because the transport is
/// reliable and ordered. Call at the desired send rate (e.g. every Nth simulation tick),
/// from the simulation thread.
/// </summary>
public sealed class ReplicationServer
{
private sealed class ConnectionState
{
// По netId: последний отправленный блоб каждого зарегистрированного компонента.
public readonly Dictionary<int, byte[]?[]> LastSent = [];
}
private readonly ReplicationSchema _schema;
private readonly ArchetypeQuery<NetId> _query;
private readonly Dictionary<INetConnection, ConnectionState> _states = [];
// Снапшот текущего тика, переиспользуется между соединениями.
private readonly List<(int NetId, byte[]?[] Components)> _current = [];
private readonly HashSet<int> _currentIds = [];
private int _nextNetId;
/// <summary>Creates a replication server over <paramref name="store"/>.</summary>
public ReplicationServer(ReplicationSchema schema, EntityStore store)
{
_schema = schema;
_query = store.Query<NetId>();
}
/// <summary>Allocates the next free network id for a newly spawned replicated entity.</summary>
public int NextNetId() => ++_nextNetId;
/// <summary>
/// Snapshots the world once and sends per-connection deltas. Closed connections are
/// forgotten; brand-new ones receive the full state.
/// </summary>
public void Send(IReadOnlyList<INetConnection> connections)
{
CaptureCurrentState();
foreach (var connection in connections)
{
if (!connection.IsOpen)
{
continue;
}
if (!_states.TryGetValue(connection, out var state))
{
state = new ConnectionState();
_states[connection] = state;
}
var message = BuildDelta(state);
if (message is not null)
{
connection.Send(message);
}
}
// Забываем состояние умерших соединений, чтобы не копить мусор.
foreach (var dead in _states.Keys.Where(c => !c.IsOpen).ToList())
{
_states.Remove(dead);
}
}
private void CaptureCurrentState()
{
_current.Clear();
_currentIds.Clear();
var slots = _schema.Slots;
_query.ForEachEntity(
(ref NetId netId, Entity entity) =>
{
var components = new byte[]?[slots.Count];
foreach (var slot in slots)
{
var buffer = new byte[slot.Size];
components[slot.Bit] = slot.TryWrite(entity, buffer) ? buffer : null;
}
_current.Add((netId.Value, components));
_currentIds.Add(netId.Value);
}
);
}
private byte[]? BuildDelta(ConnectionState state)
{
using var stream = new MemoryStream();
using var writer = new BinaryWriter(stream);
writer.Write(ReplicationMessage.Snapshot);
var countPosition = stream.Position;
writer.Write(0); // количество записей, допишем в конце
var records = 0;
foreach (var (netId, components) in _current)
{
if (!state.LastSent.TryGetValue(netId, out var lastSent))
{
lastSent = new byte[]?[components.Length];
state.LastSent[netId] = lastSent;
}
uint mask = 0;
for (var bit = 0; bit < components.Length; bit++)
{
var current = components[bit];
if (current is null)
{
continue;
}
if (lastSent[bit] is null || !current.AsSpan().SequenceEqual(lastSent[bit]))
{
mask |= 1u << bit;
lastSent[bit] = current;
}
}
if (mask == 0)
{
continue;
}
writer.Write(netId);
writer.Write(ReplicationMessage.OpUpsert);
writer.Write(mask);
for (var bit = 0; bit < components.Length; bit++)
{
if ((mask & (1u << bit)) != 0)
{
writer.Write(components[bit]!);
}
}
records++;
}
// Сущности, которые соединение знает, а в мире их больше нет.
foreach (var known in state.LastSent.Keys.Where(id => !_currentIds.Contains(id)).ToList())
{
state.LastSent.Remove(known);
writer.Write(known);
writer.Write(ReplicationMessage.OpDespawn);
records++;
}
if (records == 0)
{
return null;
}
stream.Position = countPosition;
writer.Write(records);
return stream.ToArray();
}
}
/// <summary>Wire constants shared by <see cref="ReplicationServer"/> and <see cref="ReplicationClient"/>.</summary>
internal static class ReplicationMessage
{
internal const byte Snapshot = 1;
internal const byte OpUpsert = 0;
internal const byte OpDespawn = 1;
}