SetNet — detailed user guide
A complete guide to using the library: from "hello world" to a production configuration. A short overview is in the README; performance and scaling limits are in PERFORMANCE.md.
Table of contents
- Requirements and installation
- Core concepts
- Quick start
- Messages and handlers
- Transports: TCP / UDP / Both
- Delivery and reliable channels
- Disconnects, reconnect, heartbeat
- Performance and processing order
- Production hardening
- Metrics
- Utilities: GameLoopScheduler, EventManager
- Full Configuration reference
- Production checklist
- Common mistakes
1. Requirements and installation
- Library: .NET Standard 2.1 (consumed by .NET Core 3.0+/.NET 5-8, Unity, Mono, Xamarin/MAUI — not .NET Framework).
- Consumers/tests/examples: .NET 8.
dotnet add package SetNet
# serializer (the core does not include one) — e.g. the MessagePack adapter:
dotnet add package SetNet.MessagePack
ℹ️ The
SetNetcore does not include a built-in serializer. AddSetNet.MessagePack(or your ownISerializer) and register it at startup — see section 4.
2. Core concepts
| Type | Role |
|---|---|
BaseServer |
Listens for connections, creates a BasePeer for each client. You subclass it and implement OnNewClient. |
BasePeer |
The server-side representation of a single client: receives its messages and replies. |
BaseClient |
The client: connects, manages the lifecycle (connect/heartbeat/reconnect), receives messages. |
Configuration |
All settings (host, port, transport, limits, TLS…). |
[MessageHandler(type)] |
An attribute on a handler class; registered via reflection. |
Message flow: SendAsync<T> → serialization (your ISerializer; e.g. MessagePack) → framing → transport → reassembly → deserialization → handler.
⚠️ Processing order is not guaranteed by default, even over TCP (handlers are fire-and-forget). See section 8.
3. Quick start
Step 1. Message types
public enum MessageTypes : ushort
{
PlayerMove = 1,
ChatMessage = 2,
}
[MessagePackObject]
public class PlayerMoveMessage
{
[Key(0)] public float X { get; set; }
[Key(1)] public float Y { get; set; }
}
When using the MessagePack serializer, DTOs must be
[MessagePackObject]with[Key(n)]on every field (or[MessagePackObject(true)]for key-as-name). For a different serializer, its own requirements apply — see section 4.
Step 2. Server
using SetNet.Core;
using SetNet.Config;
public class GamePeer : BasePeer
{
public GamePeer(PeerInfo info) : base(info) { }
protected override void OnDisconnected() => Console.WriteLine($"{CurrentPeerInfo.Id} left");
protected override void OnError(string e) => Console.WriteLine(e);
public Task PushAsync<T>(ushort type, T msg) => SendAsync(type, msg); // public wrapper over the protected SendAsync
}
public class GameServer : BaseServer
{
public GameServer(Configuration config) : base(config) { }
protected override BasePeer OnNewClient(PeerInfo peerInfo) => new GamePeer(peerInfo);
// StartReceive() is called by the framework itself — not needed manually (but calling it is safe and idempotent)
}
var config = new Configuration { Host = "0.0.0.0", Port = 5682 };
var server = new GameServer(config);
await server.StartAsync(); // long-running accept loop
Step 3. Client
public class GameClient : BaseClient
{
public GameClient(Configuration config) : base(config) { }
protected override void OnConnected() => Console.WriteLine("Connected");
protected override void OnDisconnected() => Console.WriteLine("Disconnected");
protected override void OnError(string e)=> Console.WriteLine($"Error: {e}");
public Task MoveAsync(float x, float y)
=> SendAsync((ushort)MessageTypes.PlayerMove, new PlayerMoveMessage { X = x, Y = y });
}
var client = new GameClient(new Configuration { Host = "127.0.0.1", Port = 5682 });
await client.ConnectAsync();
await client.MoveAsync(10, 20);
4. Messages and handlers
Handlers are discovered via reflection at startup — a class with [MessageHandler] that implements IServerMessageHandler<T> or IClientMessageHandler<T>. Handlers are strongly typed: the library deserializes the payload and hands you the ready T — no manual deserialization.
Server-side handler
using SetNet.Core;
using SetNet.Data;
using SetNet.Data.Attributes;
[MessageHandler((ushort)MessageTypes.PlayerMove)]
public class PlayerMoveHandler : IServerMessageHandler<PlayerMoveMessage>
{
public async Task HandleAsync(BasePeer peer, PlayerMoveMessage msg)
{
// process; reply if needed:
await ((GamePeer)peer).PushAsync((ushort)MessageTypes.PlayerMove, msg);
}
}
Client-side handler
[MessageHandler((ushort)MessageTypes.ChatMessage)]
public class ChatHandler : IClientMessageHandler<ChatMessage>
{
public Task HandleAsync(ChatMessage msg)
{
Console.WriteLine(msg.Text);
return Task.CompletedTask;
}
}
If a handler isn't being called — check: (1) it implements IServerMessageHandler<T>/IClientMessageHandler<T>; (2) it has [MessageHandler] with the correct ushort; (3) both T and the ushort match what is being sent; (4) the class is in a loaded assembly.
ℹ️ Handlers are created via
Activator.CreateInstance(a public parameterless constructor is required) and reused as a singleton for all messages of that type. There is no constructor DI — resolve services through a static service locator or your own mechanism.
Serialization — choose the format yourself (MessagePack, JSON, …)
The SetNet core does not include a built-in serializer — you choose the format through the ISerializer interface (SetNet.Messaging):
public interface ISerializer
{
byte[] Serialize<T>(T value);
T Deserialize<T>(byte[] data);
}
Until a serializer is assigned, SetNetSerializer.Serialize/Deserialize throw an InvalidOperationException with a hint. Assign it once at startup, before connecting.
Option 1 — MessagePack (recommended) via the separate SetNet.MessagePack package. It provides MessagePackNetSerializer, hardened with the UntrustedData security profile (protection against DoS during deserialization):
using SetNet.Messaging;
using SetNet.MessagePack;
SetNetSerializer.Use(new MessagePackNetSerializer()); // globally, at startup
Option 2 — your own format (e.g. System.Text.Json), with no dependencies:
using SetNet.Messaging;
using System.Text.Json;
public sealed class MyJsonSerializer : ISerializer
{
public byte[] Serialize<T>(T value) => JsonSerializer.SerializeToUtf8Bytes(value);
public T Deserialize<T>(byte[] data) => JsonSerializer.Deserialize<T>(data)!;
}
SetNetSerializer.Use(new MyJsonSerializer());
Rules:
- The serializer is one per application — registered once via
SetNetSerializer.Use(...). Everything goes through it: both the send path and the deserialization of incoming messages before the handler is called. There is no per-connection setting — a single place. - Handlers are strongly typed — they receive the ready
T; no manual deserialization (the library does it).SetNetSerializer.Serialize/Deserializeremain available for ad-hoc needs. - Both ends of a connection must use the same format.
- DTO requirements are dictated by the chosen serializer: for MessagePack —
[MessagePackObject]/[Key](see above); System.Text.Json works with ordinary public properties.
Raw frame access — relay/proxy (OnRawFrame + SendRawAsync)
Sometimes a frame should be forwarded without looking inside — e.g. an Among Us-style relay server that shuttles game traffic between players in a lobby. Deserializing then re-serializing there is pure waste. Two primitives on BaseClient/BasePeer cover this:
// override on BaseSocket: called for EVERY application frame (system Ping/Pong/BindToken are excluded),
// BEFORE typed dispatch. Return true = "consumed", the typed handler is skipped.
protected virtual bool OnRawFrame(ushort type, byte[] data);
// send already-serialized bytes WITHOUT serializing
protected Task SendRawAsync(ushort type, byte[] payload, DeliveryMethod? delivery = null);
A relay peer forwards the raw bytes and consumes the frame (zero deserialization):
public class RelayPeer : BasePeer
{
private readonly RelayServer _server;
public RelayPeer(PeerInfo info, RelayServer server) : base(info) { _server = server; }
// public wrapper so the server's broadcast loop can forward into this peer
public Task ForwardAsync(ushort type, byte[] data) => SendRawAsync(type, data, DeliveryMethod.Unreliable);
protected override bool OnRawFrame(ushort type, byte[] data)
{
_server.BroadcastRawToLobby(LobbyId, type, data, except: CurrentPeerInfo.Id); // your policy
return true; // do not pass to a typed handler
}
}
// BroadcastRawToLobby iterates the lobby's peers and calls peer.ForwardAsync(type, data)
Rules:
return false(the default) → the frame continues to its typed handler. Normal code doesn't overrideOnRawFrameand pays nothing (an empty virtual call).return true→ typed dispatch is skipped. No deserialization happens at all.- Hybrid is fine: control messages (join/ready/kick) via typed handlers, game traffic via
OnRawFrame+SendRawAsync. Branch ontypeinside. OnRawFrameruns synchronously on the receive path — forward fire-and-forget (_ = SendRawAsync(...)) or batch; don't block.
5. Transports: TCP / UDP / Both
Selected via Configuration.TransportType (default Tcp — existing TCP code works unchanged).
var config = new Configuration
{
Host = "127.0.0.1", Port = 5682,
TransportType = TransportType.Both, // Tcp | Udp | Both
UdpReliabilityEnabled = true,
DefaultDelivery = DeliveryMethod.Reliable
};
Routing by (TransportType, DeliveryMethod):
| TransportType | DeliveryMethod | Channel |
|---|---|---|
| Tcp | any | TCP |
| Udp | Reliable | UDP reliability layer (requires UdpReliabilityEnabled, otherwise Validate() throws) |
| Udp | Unreliable | Raw UDP datagram |
| Both | Reliable | TCP |
| Both | Unreliable | UDP (falls back to TCP until the UDP channel attaches) |
Notes:
- UDP is an emulated connection: a handshake assigns identity, a heartbeat detects liveness, so
OnConnected/OnDisconnected/BasePeerwork just like over TCP. - Both: TCP first, the server hands a UDP token over TCP, and the UDP handshake binds to the same peer. If UDP is unavailable — a graceful fallback to TCP-only.
- MTU: datagrams >
UdpMaxDatagramPayload(1200 B) are rejected; there is no fragmentation.
Try it locally:
dotnet run --project tests/SetNet.Tests -- <frag|tcp|udp|loss|both|idle|deadlock>
6. Delivery and reliable channels
SendAsync has overloads:
await SendAsync(type, msg); // DefaultDelivery
await SendAsync(type, msg, DeliveryMethod.Unreliable); // explicit channel
await SendAsync(type, msg, DeliveryMethod.Reliable, channel: 1); // reliable UDP channel 1
Independent reliable UDP channels (UdpReliableChannels)
Each channel has its own sequence/ACK/ordering, so a loss on one does not block another:
var config = new Configuration
{
TransportType = TransportType.Udp, UdpReliabilityEnabled = true,
UdpReliableChannels = 2 // channels 0 and 1 — independent ordered streams
};
await SendAsync(type, movement, DeliveryMethod.Reliable, channel: 0);
await SendAsync(type, chat, DeliveryMethod.Reliable, channel: 1);
Reliable UDP has a receive window and back-pressure: the sender cannot get ahead of the "oldest gap" by more than
UdpReliableWindowSizesequences.
7. Disconnects, reconnect, heartbeat
BaseClient distinguishes an intentional Disconnect() from an unexpected loss. OnDisconnected fires exactly once per connection.
public class GameClient : BaseClient
{
public GameClient(Configuration config) : base(config) { }
protected override void OnConnected() { }
protected override void OnDisconnected() { } // closed (any reason)
protected override void OnError(string e) { } // only an unexpected error
protected override void OnUnexpectedDisconnect() { } // server down / network
protected override void OnReconnecting(int a, int max) { } // before each attempt
protected override void OnReconnected() { } // success
protected override void OnReconnectFailed() { } // all attempts exhausted
protected override void OnStateChanged(ConnectionState f, ConnectionState t) { }
}
| Event | OnError | OnUnexpectedDisconnect | OnDisconnected | Auto-Reconnect |
|---|---|---|---|---|
Disconnect() (intentional) |
❌ | ❌ | ✅ | ❌ |
| Network error / server crash | ✅ | ✅ | ✅ (if reconnect failed) | ✅ (if enabled) |
| Graceful close by the server | ❌ | ❌ | ✅ | ❌ |
Auto-reconnect:
var config = new Configuration
{
AutoReconnect = true, MaxReconnectAttempts = 5, ReconnectDelayMs = 1000
};
Heartbeat (detecting "dead" connections) — disabled by default:
var config = new Configuration { HeartbeatEnabled = true, HeartbeatIntervalMs = 5000, HeartbeatTimeoutMs = 15000 };
On the server, BasePeer is symmetric: Close() (kick) → only OnDisconnected; a client crash/IO error → OnError + OnUnexpectedDisconnect + OnDisconnected.
8. Performance and processing order
All flags below are opt-in (the default preserves the original behavior).
Processing order (SequentialDispatch)
⚠️ Default: handlers are fire-and-forget, order is not guaranteed even over TCP.
var config = new Configuration { SequentialDispatch = true };
// the receive loop waits for each handler to finish before the next frame (strict order, less parallelism)
Back-pressure (MaxInFlightMessages)
var config = new Configuration { MaxInFlightMessages = 256 };
// limit on concurrent handlers per connection; when reached, the receive loop is paused
Batching (SendBatching) — for the game tick
var config = new Configuration { SendBatching = true, SendBatchFlushMs = 15 };
// ... per tick:
await SendAsync(t1, m1);
await SendAsync(t2, m2); // accumulated into a buffer
await FlushAsync(); // a single write to the socket (on BaseClient/BasePeer)
Delivers the highest throughput (~1.8M msgs/sec vs ~240k without batching).
Send timeout (SendTimeoutMs, default 30000)
Bounds the time of a single socket write — a "stuck" peer does not block sending forever. 0 disables it.
Nagle (TcpNoDelay, default true)
Nagle disabled = low latency for small frames. For a bulk stream of unbatched messages, false gives higher throughput (but +latency). Details are in PERFORMANCE.md.
9. Production hardening
using System.Security.Cryptography.X509Certificates;
var config = new Configuration
{
Host = "0.0.0.0", Port = 5682,
// TLS over TCP (UDP is NOT encrypted)
UseSsl = true,
ServerCertificate = new X509Certificate2("server.pfx", "password"), // on the server
// on the client: SslTargetHost / ServerCertificateValidationCallback
// Limits / DoS protection
MaxConnectionsLimit = 5000,
MaxUdpPeers = 5000,
MaxMessageSize = 1024 * 1024,
MaxConnectionsPerIpPerSecond = 20,
MaxInFlightMessages = 256,
MaxInboundQueue = 16384, // inbound-queue cap per connection (OOM protection)
};
- Authentication is on the application side: validate credentials in your
OnNewClient/handlers (the library only provides transport). - UDP has no encryption or per-packet authentication — send sensitive data only over TLS-over-TCP (or Both with reliable, which goes over TCP).
- Resilience: a failure in
OnNewClient/StartReceivedoes not kill the accept loop; a malformed TLS handshake does not take down the server; reconnect/heartbeat errors are logged; an exception in a handler/user hook does not break cleanup; a bounded inbound queue protects against OOM.
10. Metrics
var m = config.Metrics; // NetworkMetrics, thread-safe counters
Console.WriteLine(m.Snapshot()); // sent/recv/accepted/rejected/retransmits/acks/handshakesDropped/inboundDropped
int live = server.ActiveConnections;
Most useful for production: InboundDropped (overload), ConnectionsRejected (limits/rate-limit), ReliableRetransmits (UDP losses), HandshakesDropped (UDP flood).
11. Utilities
GameLoopScheduler — periodic tasks
using SetNet.Utils;
var scheduler = new GameLoopScheduler();
scheduler.Every(100, async () => { /* server tick */ await Task.CompletedTask; });
scheduler.StartInBackground();
// await scheduler.StopAsync();
EventManager — in-process pub/sub
using SetNet.Events;
var ev = new EventManager();
ev.Subscribe("PlayerJoined", data => { /* ... */ });
ev.Trigger("PlayerJoined", "Alex");
⚠️
EventManageris in-process and not thread-safe; it is not a networked pub/sub. To invoke it from multiple threads, synchronize it yourself.
12. Full Configuration reference
| Option | Default | Purpose |
|---|---|---|
Host / Port |
— | Endpoint (TCP; UDP too, if UdpPort=0). |
BufferSize |
4096 | Read buffer size. |
TcpNoDelay |
true |
Disable Nagle (low latency). |
TransportType |
Tcp |
Tcp | Udp | Both. |
DefaultDelivery |
Reliable |
For the 2-arg SendAsync(type, msg). |
UdpPort |
0 | 0 = use Port. |
UdpReliabilityEnabled |
true |
Master toggle for reliable UDP. |
UdpReliableChannels |
1 | Number of independent reliable channels. |
UdpReliableWindowSize |
64 | Window (1..64). |
UdpReliableAckTimeoutMs |
100 | Timeout before retransmit. |
UdpReliableMaxRetransmits |
10 | Retransmit cap → onFailure. |
UdpMaxDatagramPayload |
1200 | Max datagram (no fragmentation). |
UdpOrderedReliable |
true |
Ordered reliable delivery. |
UdpHandshakeTimeoutMs |
5000 | UDP handshake timeout. |
UdpPeerExpiryMs |
15000 | Idle time before a UDP peer is removed. |
HeartbeatEnabled |
false |
Ping/Pong to detect dead connections. |
HeartbeatIntervalMs / HeartbeatTimeoutMs |
5000 / 15000 | Heartbeat interval / timeout. |
AutoReconnect |
false |
Client auto-reconnect. |
MaxReconnectAttempts / ReconnectDelayMs |
3 / 1000 | Reconnect policy. |
ConnectTimeoutMs |
10000 | Connect/handshake timeout. |
MaxInFlightMessages |
0 | Back-pressure (0 = unlimited). |
SequentialDispatch |
false |
Strict processing order. |
SendBatching / SendBatchFlushMs |
false / 15 |
Coalesced TCP write. |
SendTimeoutMs |
30000 | Limit on a single socket write (0 = off). |
MaxInboundQueue |
16384 | Inbound-queue cap (OOM protection). |
UseSsl |
false |
TLS over TCP. |
ServerCertificate / SslTargetHost / ServerCertificateValidationCallback |
null | TLS parameters. |
MaxConnections |
100 | Base connection cap. |
MaxConnectionsLimit |
0 | If >0 — overrides MaxConnections. |
MaxUdpPeers |
1000 | UDP peer cap. |
MaxMessageSize |
1 MiB | TCP frame cap. |
MaxConnectionsPerIpPerSecond |
0 | Per-IP rate limit (0 = off). |
Logger |
ConsoleLogger |
Logging (ILogger). |
Metrics |
— | NetworkMetrics counters. |
Validate() is called on connect/start and fail-fast checks incompatible settings.
13. Production checklist
The defaults are optimized for compatibility, not for production. Before launch:
- [ ] Implement authorization in
OnNewClient/handlers. - [ ]
HeartbeatEnabled = true(otherwise dead connections are not detected). - [ ]
MaxInFlightMessages > 0(otherwise unbounded fire-and-forget Tasks under load). - [ ]
MaxConnectionsLimit,MaxConnectionsPerIpPerSecondtuned to your capacity. - [ ]
UseSsl = true+ a certificate, if outside a trusted network (and do not send sensitive data over UDP). - [ ] Export
config.Metrics.Snapshot()to monitoring. - [ ] Soak/load test with real traffic before a full launch.
Detailed scaling limits are in PERFORMANCE.md.
14. Common mistakes
| Symptom | Cause / resolution |
|---|---|
| Handler not called | No [MessageHandler], the wrong type, doesn't implement the interface, or the class is not in a loaded assembly. |
| Messages get "corrupted" | Different serializers on the two ends; (MessagePack) a DTO without [MessagePackObject]/[Key]; or the type doesn't match. |
InvalidOperationException: No serializer configured |
SetNetSerializer.Use(...) not called — do it at startup (see section 4). |
| Won't connect | Host/Port differ on the client and server; firewall; (UDP) handshake is blocked. |
| Out-of-order processing | This is the default behavior — enable SequentialDispatch. |
| Reliable UDP throws on send | DefaultDelivery=Reliable + UdpReliabilityEnabled=false on plain UDP. Validate() catches this. |
| OOM under flood | Check MaxInboundQueue, MaxUdpPeers, MaxMessageSize, MaxConnectionsPerIpPerSecond. |
A full chat example (separate server and client) is in the examples/ folder. Architecture and project structure are in CLAUDE.md.