Networking Guide
The networking system provides authoritative server replication with client-side prediction and interpolation for responsive multiplayer games.
Architecture Overview
KeenEyes networking uses a server-authoritative model:
┌─────────────────────────────────────────────────────────────┐
│ SERVER │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
│ │ Game Logic │─▶│ Network │─▶│ NetworkServerPlugin │ │
│ │ (World) │ │ Systems │ │ (Transport) │ │
│ └─────────────┘ └─────────────┘ └─────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
│
┌───────────────┼───────────────┐
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ CLIENT 1 │ │ CLIENT 2 │ │ CLIENT N │
│ ┌─────────────┐ │ │ ┌─────────────┐ │ │ ┌─────────────┐ │
│ │ Prediction │ │ │ │Interpolation│ │ │ │Interpolation│ │
│ │ (local) │ │ │ │ (remote) │ │ │ │ (remote) │ │
│ └─────────────┘ │ │ └─────────────┘ │ │ └─────────────┘ │
└─────────────────┘ └─────────────────┘ └─────────────────┘
Key concepts:
- Server owns the game state and replicates to clients
- Clients receive state updates and render the game
- Prediction allows responsive local player controls
- Interpolation smooths remote entity movement
Packages
| Package | Purpose |
|---|---|
KeenEyes.Network.Abstractions |
Transport interface (INetworkTransport) and enums |
KeenEyes.Network |
Server/client plugins, LocalTransport for testing |
KeenEyes.Network.Transport.Tcp |
TCP transport - reliable ordered delivery |
KeenEyes.Network.Transport.Udp |
UDP transport - configurable reliability |
Install only what you need:
# Core networking (required)
dotnet add package KeenEyes.Network
# Pick a transport (or implement your own)
dotnet add package KeenEyes.Network.Transport.Tcp # For TCP
dotnet add package KeenEyes.Network.Transport.Udp # For UDP
Getting Started
1. Define Replicated Components
Mark components for network replication using [Replicated]:
using KeenEyes.Network;
[Replicated]
public partial struct Position
{
public float X;
public float Y;
public float Z;
}
[Replicated(GenerateInterpolation = true)]
public partial struct Velocity
{
public float X;
public float Y;
}
The source generator creates serialization code and optional interpolation helpers.
2. Create the Server
using KeenEyes;
using KeenEyes.Network;
using KeenEyes.Network.Transport;
// Create transport (use LocalTransport for testing, or implement your own)
var transport = new LocalTransport();
await transport.ListenAsync(7777);
// Configure server
var config = new ServerNetworkConfig
{
TickRate = 60, // Network ticks per second
MaxClients = 16, // Maximum concurrent connections
Serializer = new MySerializer() // Your component serializer
};
// Create world with network plugin
using var world = new World();
var serverPlugin = new NetworkServerPlugin(transport, config);
world.InstallPlugin(serverPlugin);
// Create a networked entity
var player = world.Spawn()
.With(new Position { X = 0, Y = 0, Z = 0 })
.With(new Velocity { X = 0, Y = 0 })
.Build();
// Register for replication (assigns network ID)
serverPlugin.RegisterNetworkedEntity(player, ownerId: 1);
3. Create the Client
var transport = new LocalTransport();
var config = new ClientNetworkConfig
{
ServerAddress = "127.0.0.1",
ServerPort = 7777,
EnablePrediction = true,
Serializer = new MySerializer(),
Interpolator = new MyInterpolator()
};
using var world = new World();
var clientPlugin = new NetworkClientPlugin(transport, config);
world.InstallPlugin(clientPlugin);
// Connect to server
await clientPlugin.ConnectAsync();
// Handle connection events
clientPlugin.Connected += () => Console.WriteLine("Connected!");
clientPlugin.Disconnected += () => Console.WriteLine("Disconnected!");
4. Game Loop
Both server and client run their game loops:
var stopwatch = Stopwatch.StartNew();
var lastTime = 0.0;
while (running)
{
var currentTime = stopwatch.Elapsed.TotalSeconds;
var deltaTime = (float)(currentTime - lastTime);
lastTime = currentTime;
// Process network transport
transport.Update();
// Update ECS world (runs network systems automatically)
world.Update(deltaTime);
}
Sync Strategies
Choose the appropriate sync strategy for each component type:
| Strategy | Use Case | Latency | Bandwidth |
|---|---|---|---|
Authoritative |
NPCs, world objects, game state | High | Low |
Interpolated |
Remote players, projectiles | Medium | Medium |
Predicted |
Local player | Low | Higher |
OwnerAuthoritative |
Cosmetics, non-critical data | Lowest | Low |
Authoritative (Default)
Server state is applied directly. Use for server-controlled entities:
[Replicated(Strategy = SyncStrategy.Authoritative)]
public partial struct Health
{
public int Current;
public int Max;
}
Interpolated
Smooth movement for remote entities by blending between snapshots:
[Replicated(
Strategy = SyncStrategy.Interpolated,
GenerateInterpolation = true)]
public partial struct Position
{
public float X;
public float Y;
}
The client renders slightly behind server time (typically 100ms) and interpolates between received states for smooth visuals.
Predicted
Local player runs ahead of the server for responsive controls:
[Replicated(
Strategy = SyncStrategy.Predicted,
GeneratePrediction = true)]
public partial struct PlayerPosition
{
public float X;
public float Y;
}
When server state arrives, the client:
- Compares server state to predicted state
- If mismatch: rolls back and re-simulates from the server state
- Applies any pending inputs to catch up to current tick
Owner Authoritative
Client owns the data; server only validates:
[Replicated(Strategy = SyncStrategy.OwnerAuthoritative)]
public partial struct Cosmetics
{
public int SkinId;
public int HatId;
}
Warning: Vulnerable to cheating. Only use for non-gameplay-critical data.
Delta Compression
For bandwidth efficiency, components can implement delta serialization:
[Replicated(SupportsDelta = true)]
public partial struct Transform : INetworkDeltaSerializable<Transform>
{
public float X;
public float Y;
public float Rotation;
public uint GetDirtyMask(in Transform baseline)
{
uint mask = 0;
if (MathF.Abs(X - baseline.X) > 0.001f) mask |= 1;
if (MathF.Abs(Y - baseline.Y) > 0.001f) mask |= 2;
if (MathF.Abs(Rotation - baseline.Rotation) > 0.001f) mask |= 4;
return mask;
}
public void NetworkSerializeDelta(ref BitWriter writer, in Transform baseline, uint mask)
{
if ((mask & 1) != 0) writer.WriteFloat(X);
if ((mask & 2) != 0) writer.WriteFloat(Y);
if ((mask & 4) != 0) writer.WriteFloat(Rotation);
}
public void NetworkDeserializeDelta(ref BitReader reader, ref Transform baseline, uint mask)
{
if ((mask & 1) != 0) baseline.X = reader.ReadFloat();
if ((mask & 2) != 0) baseline.Y = reader.ReadFloat();
if ((mask & 4) != 0) baseline.Rotation = reader.ReadFloat();
}
}
Only changed fields are sent, reducing bandwidth by 50-80% for typical game state.
Input Handling
For predicted entities, send inputs to the server:
// Define your input structure
public struct PlayerInput : INetworkInput
{
public uint Tick { get; set; }
public float MoveX;
public float MoveY;
public bool Jump;
}
// Record and send input each frame
var input = new PlayerInput
{
MoveX = GetHorizontalAxis(),
MoveY = GetVerticalAxis(),
Jump = IsJumpPressed()
};
clientPlugin.RecordInput(localPlayerEntity, input);
The server receives inputs via event:
serverPlugin.ClientInputReceived += (clientId, tick, inputData) =>
{
// Deserialize and apply input to the client's entity
var input = DeserializeInput(inputData);
ApplyInputToEntity(clientId, input);
};
Entity Ownership
Entities can be owned by the server or a specific client:
// Server-owned (default)
serverPlugin.RegisterNetworkedEntity(npcEntity, ownerId: 0);
// Client-owned (for players)
serverPlugin.RegisterNetworkedEntity(playerEntity, ownerId: clientId);
Transfer ownership dynamically:
// Server sends ownership transfer
Span<byte> buffer = stackalloc byte[16];
var writer = new NetworkMessageWriter(buffer);
writer.WriteHeader(MessageType.OwnershipTransfer, currentTick);
writer.WriteNetworkId(entityNetworkId);
writer.WriteSignedBits(newOwnerId, 16);
serverPlugin.SendToAll(writer.GetWrittenSpan(), DeliveryMode.ReliableOrdered);
Entity Hierarchy
Parent-child relationships are automatically replicated:
// On server
world.SetParent(weaponEntity, playerEntity);
// Send hierarchy change to clients
serverPlugin.SendHierarchyChange(weaponEntity, playerEntity);
Late Joiners
New clients automatically receive a full world snapshot:
// Server handles this automatically when client connects
// But you can manually trigger if needed:
serverPlugin.SendFullSnapshot(clientId);
Transport Layer
Implement INetworkTransport for your networking library:
public interface INetworkTransport : IDisposable
{
event Action<int>? ClientConnected;
event Action<int>? ClientDisconnected;
event Action<int, ReadOnlySpan<byte>>? DataReceived;
event Action<ConnectionState>? StateChanged;
Task ListenAsync(int port, CancellationToken cancellationToken = default);
Task ConnectAsync(string address, int port, CancellationToken cancellationToken = default);
void Disconnect(int clientId = 0);
void Send(int clientId, ReadOnlySpan<byte> data, DeliveryMode mode);
void SendToAll(ReadOnlySpan<byte> data, DeliveryMode mode);
void SendToAllExcept(int excludeClientId, ReadOnlySpan<byte> data, DeliveryMode mode);
void Update();
}
LocalTransport (Testing)
Use LocalTransport for testing without real networking:
var (serverTransport, clientTransport) = LocalTransport.CreatePair();
// Server uses serverTransport
// Client uses clientTransport
// Messages are delivered synchronously
TcpTransport
Package: KeenEyes.Network.Transport.Tcp
Use TcpTransport for reliable, ordered delivery over TCP:
using KeenEyes.Network.Transport.Tcp;
// Server
var serverTransport = new TcpTransport();
await serverTransport.ListenAsync(7777);
// Client
var clientTransport = new TcpTransport();
await clientTransport.ConnectAsync("192.168.1.100", 7777);
Characteristics:
- Reliable, ordered delivery (like
DeliveryMode.ReliableOrdered) - Higher latency due to TCP head-of-line blocking
- Good for turn-based games or low-action scenarios
- Easier to work with firewalls and NAT
UdpTransport
Package: KeenEyes.Network.Transport.Udp
Use UdpTransport for low-latency networking with configurable reliability:
using KeenEyes.Network.Transport.Udp;
// Server
var serverTransport = new UdpTransport();
await serverTransport.ListenAsync(7777);
// Client
var clientTransport = new UdpTransport();
await clientTransport.ConnectAsync("192.168.1.100", 7777);
Delivery Modes:
DeliveryMode.Unreliable- Fire and forget (lowest latency)DeliveryMode.UnreliableSequenced- Drops out-of-order packetsDeliveryMode.ReliableUnordered- ACK/resend, any orderDeliveryMode.ReliableOrdered- ACK/resend, ordered delivery
Characteristics:
- Lower latency than TCP
- Configurable reliability per-message
- Best for real-time action games
- Built-in connection management and keepalive
- Max message size: 1192 bytes (safe MTU)
Custom Transports
For specialized needs, implement INetworkTransport:
- WebSocket - For browser clients
- Steam Networking - Steam P2P and relay servers
- Epic Online Services - Epic Games integration
Message Types
The protocol uses these message types:
| Type | Direction | Purpose |
|---|---|---|
ConnectionAccepted |
S→C | Server accepts connection, assigns client ID |
ConnectionRejected |
S→C | Server rejects connection with reason |
EntitySpawn |
S→C | New entity created |
EntityDespawn |
S→C | Entity destroyed |
FullSnapshot |
S→C | Complete world state (for late joiners) |
DeltaSnapshot |
S→C | Incremental state update |
ComponentUpdate |
S→C | Full component value |
ComponentDelta |
S→C | Delta-compressed component |
HierarchyChange |
S→C | Parent-child relationship changed |
OwnershipTransfer |
S→C | Entity ownership changed |
ClientInput |
C→S | Player input |
ClientAck |
C→S | Acknowledge received tick |
Ping/Pong |
Both | RTT measurement |
Configuration Reference
ServerNetworkConfig
var config = new ServerNetworkConfig
{
TickRate = 60, // Network updates per second
MaxClients = 32, // Maximum connections
Serializer = serializer, // Component serializer
};
ClientNetworkConfig
var config = new ClientNetworkConfig
{
ServerAddress = "127.0.0.1",
ServerPort = 7777,
TickRate = 60,
EnablePrediction = true,
InputBufferSize = 64, // Frames of input to buffer
Serializer = serializer,
Interpolator = interpolator,
InputApplicator = inputApplicator
};
Best Practices
Bandwidth Optimization
- Use delta compression for frequently-updated components
- Prioritize entities - nearby entities update more frequently
- Quantize values - reduce precision for rotation (e.g., 16-bit angles)
- Skip unchanged entities - only send when state changes
Latency Hiding
- Predict local player movement client-side
- Interpolate remote players between snapshots
- Buffer inputs to handle packet loss
- Extrapolate when snapshots are late (with limits)
Security
- Validate all inputs on the server
- Never trust client state for gameplay-critical data
- Use
OwnerAuthoritativeonly for cosmetic data - Rate-limit inputs to prevent spam
Debugging
Measure RTT
// Client sends ping periodically
clientPlugin.SendPing();
// Check RTT
Console.WriteLine($"RTT: {clientPlugin.RoundTripTimeMs}ms");
Monitor Network State
// Check connection status
if (clientPlugin.IsConnected)
{
Console.WriteLine($"Connected as client {clientPlugin.LocalClientId}");
Console.WriteLine($"Last server tick: {clientPlugin.LastReceivedTick}");
}
// Server stats
Console.WriteLine($"Connected clients: {serverPlugin.ClientCount}");
foreach (var client in serverPlugin.GetConnectedClients())
{
Console.WriteLine($" Client {client.ClientId}: RTT={client.RoundTripTimeMs}ms");
}
See Also
- Plugin System - How plugins work
- Systems - System execution phases
- Serialization - Binary serialization