Table of Contents

The client

ClientAdapter connects a game to a server, sends packets and raises events. You create it from a logger, a DataPacketManager (see Packets) and a NetworkAdapterConfiguration:

using BSGameLib.Networking;
using Microsoft.Extensions.Logging;

ILogger logger = Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance;
var packets = new SimpleDataPacketProvider();
packets.RegisterPacket<ChatPacket>();
var manager = new DataPacketManager(logger, packets, new SimpleDataPacketHandlerProvider());

var client = new ClientAdapter(logger, manager, new NetworkAdapterConfiguration
{
  Quic = new QuicTransportConfiguration(),
});

The defaults are right for most games. The client trusts a server the first time it connects and refuses a changed certificate after that; see Server certificates for the rest. The client ignores Port in the configuration, because you pass the port to Connect.

Connecting

Connect takes the server's address and port, and blocks until the connection is up or has failed:

client.Connect("game.example.com", 55237);

The address can be an IP address, IPv4 or IPv6, or a host name, which is looked up with DNS. Call Connect on a background thread, not in your update loop, since it blocks. A connection that cannot be made, because nothing is listening on that port, throws an IOException. Other failures, such as a server certificate the client refuses, throw their own exceptions. A connection that is made, but whose handshake with BSGameLib then fails, is closed again and throws, and IsConnected stays false.

IsConnected is true while there is a connection. Connect again on the same adapter to join another server or reconnect: the adapter closes the previous connection first, and raises Disconnected for it. A Connect that a newer one replaces, when a Disconnected handler connects again while it is in progress, throws an InvalidOperationException, and the newer connection stands.

Events

Event When it is raised
Connected When the connection is up. It is raised on a thread from the thread pool, before Connect returns, and IsConnected is already true.
Disconnected When the connection ends: the server closed it, it was lost, or you called Disconnect. It is raised once for each connection.

The event's DisconnectReason is the reason for the close: the reason you gave to Disconnect, or else the reason the server gave when it closed the connection. It is null when neither gave one, and when the connection was lost rather than closed. A packet the server sends before it closes arrives as usual, so it can say more than the reason does; see the server below.

Sending

Three methods send a packet to the server. They differ in how the packet travels, and Delivery modes explains when to use each:

client.SendReliable(new ChatPacket { Sender = "Ann", Message = "Hello" });
client.SendUnreliable(new ChatPacket { Sender = "Ann", Message = "Hello" });
client.SendUnreliableSequenced(new ChatPacket { Sender = "Ann", Message = "Hello" });

Sending while not connected throws an InvalidOperationException with the message "Not connected to a server."

Disconnect when you are done:

client.Disconnect();

Disconnect closes the connection and raises Disconnected. It takes an optional reason, such as "Leaving", which the event's DisconnectReason then carries.

Handlers and threads

A packet's handler runs on one of the client's receive threads, not on your game's thread. Reliable packets go to one thread, which runs their handlers one at a time, in the order they arrived. Unreliable packets go to another thread, which runs their handlers one at a time, in arrival order. Both threads run beside your game's loop.

That matters because the game reads its state every frame. A handler that changes that state at the same moment races with the loop, and the loop can see a change half made. The result is often an intermittent crash rather than an error you can trace to the packet, so a handler should not change game state directly.

HandlerRunner decides where each handler runs. It is given the packet's type, or null for a type this client does not know, and the handler to run. In a game built on BSGameLib.Game, the engine's IGameThread is the queue to use. The game runs what is posted to it once a frame, before its update steps (see Work from other threads):

using BSGameLib.Game;
using BSGameLib.Networking;

static void RunHandlersOnTheGameThread(ClientAdapter client, IGameThread gameThread)
{
  client.HandlerRunner = (packetType, run) => gameThread.Post(run);
}

Get the IGameThread with GetService<IGameThread>() in the game, or with constructor injection. Handlers posted this way still run in the order they were posted, so reliable ones keep their order.

Some packets do not need the game's thread: a ping, or a large download that the game handles in its own time. Run those where they arrive, and post the rest:

using System;
using System.Collections.Generic;
using BSGameLib.Game;
using BSGameLib.Networking;

static void RunSomeHandlersOnTheNetworkThread(ClientAdapter client, IGameThread gameThread)
{
  var networkOnly = new HashSet<Type> { typeof(PingPacket), typeof(DownloadChunkPacket) };
  client.HandlerRunner = (packetType, run) =>
  {
    if (networkOnly.Contains(packetType))
      run();
    else
      gameThread.Post(run);
  };
}

A packet left on the network thread can run before packets posted to the game's thread that arrived earlier, so only leave a packet there when its handler can cope with that. An unknown type arrives as null, which is not in the set, so it is posted to the game's thread.

Without BSGameLib.Game, queue the handlers yourself and run them from your update loop:

using System;
using System.Collections.Concurrent;
using BSGameLib.Networking;

static void QueueHandlersForYourLoop(ClientAdapter client, ConcurrentQueue<Action> pending)
{
  client.HandlerRunner = (packetType, run) => pending.Enqueue(run);
}

// In your update loop, once a frame:
static void RunQueuedHandlers(ConcurrentQueue<Action> pending)
{
  while (pending.TryDequeue(out var run))
    run();
}

With no runner, each handler runs on the receive thread where its packet arrives. That is only safe for a handler that touches nothing the game reads.

An exception from a handler is logged where the handler ran, and the connection stays up.

Statistics

GetStatistics returns a NetworkStatistics:

var stats = client.GetStatistics();
Console.WriteLine($"Sent {stats.BytesSent} bytes, received {stats.BytesReceived} bytes.");
Console.WriteLine($"Round trip: {stats.AverageRoundTripTime?.TotalMilliseconds ?? 0} ms.");

BytesSent and BytesReceived are totals since the adapter started, including connections that have closed. Connections lists the open connection, each with its own totals and a RoundTripTime that is null until the first one has been measured. The byte counts are what the library puts on the wire, with its own headers. They do not include QUIC's encryption and acknowledgements.

Keep-alive and timeouts

Both ends send a QUIC ping every 5 seconds, so each knows the other is still there, and they ping each other every 5 seconds to measure the round trip. A peer that stops answering is dropped after about 20 seconds, and the Disconnected event is raised. These values are fixed and cannot be configured.

Platforms

QUIC runs on Windows, on Linux (x64 and arm64) and on macOS on Apple silicon. The package bundles the Linux and macOS libraries; on Windows QUIC is part of .NET. There is no bundled library for Intel Macs, so QUIC there depends on a system-installed msquic.

IPv4 and IPv6

Where the machine has IPv6, a server listens on both IPv4 and IPv6 at once, on both its ports. Clients can connect to either kind of address: 127.0.0.1 and ::1 both reach the same server. A client on IPv4 shows as an IPv4 address in IpAddress, such as 127.0.0.1, with its port in RemotePort, even though the server listens on IPv6 as well.

ServerAdapter

A dedicated server normally runs under BSGameLib.Server, which creates the adapter for you. ServerAdapter is the same adapter, for a server you host yourself. It needs a certificate, which is covered in Server certificates:

using System.Security.Cryptography.X509Certificates;
using BSGameLib.Networking;
using Microsoft.Extensions.Logging;

ILogger logger = Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance;
var manager = new DataPacketManager(logger, new SimpleDataPacketProvider(), new SimpleDataPacketHandlerProvider());
var world = new object();

var certificate = X509CertificateLoader.LoadPkcs12FromFile("server.pfx", "password");
var server = new ServerAdapter(logger, manager, new NetworkAdapterConfiguration
{
  Port = 55237,
  Quic = new QuicTransportConfiguration { ServerCertificate = certificate },
});

server.Connected += (_, e) => Console.WriteLine($"Joined from {e.Client.IpAddress}");
server.Disconnected += (_, e) => Console.WriteLine("Left");

// Run each handler under the game's lock, so the handlers and the game loop never overlap.
server.HandlerRunner = (packetType, run) =>
{
  lock (world)
    run();
};

server.Start();
// ... the server runs until you stop it:
server.Stop();

Start throws an InvalidOperationException if there is no ServerCertificate. Stop closes every connection.

The server's events are Connected, Disconnected, ForciblyDisconnected and DataReceived. ForciblyDisconnected is raised when you call Disconnect yourself, and Disconnected follows it. DataReceived is raised for every frame and datagram as it arrives, after the packet has been passed to its handler.

HandlerRunner decides where each handler runs, as in Handlers and threads above. It is set before Start. Packets arrive on the transport's threads, and handlers for different connections can run at the same time. With no runner, each handler runs where its packet arrives. With one, the runner receives the packet's type, or null for a type this server does not know, and the function to run. The runner above takes a lock around the handler. The other common choice is to post the handler to the game's own thread, as the client does, with server.HandlerRunner = (packetType, run) => gameThread.Post(run); in a BSGameLib.Game game.

The server sends with the same three methods as the client, but each takes the connection to send to, which you get from an event's Client:

server.SendReliable(connection, new ChatPacket { Sender = "Server", Message = "Welcome" });

Two things to know about the server's sends:

  • SendUnreliable and SendUnreliableSequenced send nothing to a client that has not yet answered the UDP handshake. That answer comes a moment after Connected, so an unreliable packet sent from inside Connected is dropped without an error. Send anything that must arrive reliably.
  • SendReliableThenDisconnect(connection, packet, reason) sends the packet and then closes the connection once the client has read it, waiting up to two seconds. The client's Disconnected carries the reason, and the packet arrives first, so it can say more.

Disconnect(connection, reason) closes the connection once the client has read the reason, waiting up to two seconds, and the client's Disconnected carries it. Without a reason it closes straight away.

Firewalls

A server uses two UDP ports, both at once:

Port Used for
Port QUIC: connections and reliable packets.
Port + 2 Unreliable and sequenced packets.

Open both to clients. Port + 1 isn't used.