Table of Contents

Your first online game

A small lobby: players connect, see each other join and leave, and chat. A /who command lists who is online. The example is three projects in one solution, with about 250 lines of code in all. Each step names the guide that covers it in full.

It assumes the first game and the project setup guide, which cover a single-player game and the project file.

The solution

dotnet new sln -n Lobby
dotnet new classlib -n Lobby.Shared
dotnet new console -n Lobby.Server
dotnet new console -n Lobby.Client
dotnet sln add Lobby.Shared Lobby.Server Lobby.Client
rm Lobby.Shared/Class1.cs

The solution ends up like this:

Lobby/
  Lobby.slnx
  Lobby.Shared/
    Lobby.Shared.csproj
    Packets.cs
  Lobby.Server/
    Lobby.Server.csproj
    Program.cs
    Players.cs
    Handlers.cs
    Chat.cs
  Lobby.Client/
    Lobby.Client.csproj
    Program.cs

The client and the server both reference the shared project, so they agree on the packets. Neither references the other. The packages come next:

dotnet add Lobby.Shared/Lobby.Shared.csproj package BSGameLib.Networking --prerelease
dotnet add Lobby.Server/Lobby.Server.csproj package BSGameLib.Server --prerelease
dotnet add Lobby.Server/Lobby.Server.csproj reference Lobby.Shared/Lobby.Shared.csproj
dotnet add Lobby.Client/Lobby.Client.csproj package BSGameLib.Game --prerelease
dotnet add Lobby.Client/Lobby.Client.csproj package BSGameLib.Networking --prerelease
dotnet add Lobby.Client/Lobby.Client.csproj reference Lobby.Shared/Lobby.Shared.csproj

BSGameLib.Server brings in the chat engine, so the server needs no package of its own for chat. The client takes BSGameLib.Game for the window and BSGameLib.Networking for the connection.

The packets

Both sides send packets as classes with public properties. The three packets below are the whole protocol of the lobby, and they live in the shared project so that both sides build the same classes. See Packets for the types that can be sent.

Lobby.Shared/Packets.cs:

using BSGameLib.Networking;

namespace Lobby.Shared;

// Sent by a client once it connects, with the name it wants to go by.
public class HelloPacket : IDataPacket
{
  public string Name { get; set; } = "";
}

// Sent by a client for each line it types.
public class SayPacket : IDataPacket
{
  public string Text { get; set; } = "";
}

// Sent by the server: a chat line, a join or leave, or the answer to /who.
public class LinePacket : IDataPacket
{
  public string Text { get; set; } = "";
}

Each packet implements IDataPacket. The class name identifies the packet on the wire, so the server and the client must use the same names.

The server

The server is four files. Program.cs starts the host and holds the two small classes the host needs before it can run.

Lobby.Server/Program.cs:

using BSGameLib.Server;
using Microsoft.Extensions.DependencyInjection;

using var host = new ServerHost(new ServerSettings("Lobby"));
host.Run();

public class Bootstrapper : IServiceBootstrapper
{
  public void Configure(IServiceCollection services) => services.AddSessionManagement<Guid>();
}

// The lobby keeps no state of its own in the game loop, so the methods are empty.
public class LobbyServer : IGameServer
{
  public void Start() { }

  public void Stop() { }

  public void Update(TimeSpan elapsed) { }
}
  • ServerHost builds and runs the server, and Run() blocks until the server is stopped. See The host and its settings.
  • The server makes itself a certificate the first time it starts, and keeps it in a certs folder beside the built program. See The generated certificate.
  • The host finds the other classes by scanning the assemblies, so none of them is registered by hand. See How it finds your code.
  • Bootstrapper runs once while the host is built. AddSessionManagement<Guid>() registers the services that map each connection to a player's session ID. See Sessions and connections.
  • LobbyServer is the one IGameServer the host needs. The lobby has nothing to do on each tick, so its methods are empty. See The game loop.

Lobby.Server/Players.cs:

using System.Collections.Concurrent;
using BSGameLib.Chat;
using BSGameLib.Server;

public class Player : IMessageSource
{
  public Guid Id { get; init; }
  public string Name { get; init; } = "";
}

public class PlayerRegistry : IInjectedDependency
{
  private readonly ConcurrentDictionary<Guid, Player> _players = new();

  public void Add(Player player) => _players[player.Id] = player;

  public void Remove(Guid id) => _players.TryRemove(id, out _);

  public bool TryGet(Guid id, out Player player) => _players.TryGetValue(id, out player!);

  public IEnumerable<string> Names => _players.Values.Select(p => p.Name).OrderBy(n => n);
}

Player is the chat engine's source type, so it implements IMessageSource. PlayerRegistry is the one place the server keeps players. The packet handlers run on several threads at once, so it is a ConcurrentDictionary and not a plain one. The IInjectedDependency marker makes the host register it as a shared service.

Lobby.Server/Handlers.cs:

using BSGameLib.Chat;
using BSGameLib.Networking;
using BSGameLib.Server;
using Lobby.Shared;

// A new connection says who it is. The session is bound, the player is added,
// and everyone is told.
public class HelloHandler(
  ISessionIdFactory<Guid> ids,
  IConnectionManager<Guid> sessions,
  PlayerRegistry players,
  IListenSocket socket) : IDataPacketHandler<HelloPacket>
{
  public void Handle(INetConnection connection, HelloPacket packet)
  {
    var id = ids.Create(connection);
    if (!sessions.TryBind(id, connection))
      return;

    var name = string.IsNullOrWhiteSpace(packet.Name) ? "Guest" : packet.Name.Trim();
    players.Add(new Player { Id = id, Name = name });
    socket.SendReliableToAll(new LinePacket { Text = $"{name} joined" });
  }
}

// Each line goes to the chat engine, which runs commands and passes the rest
// on as chat.
public class SayHandler(
  IChatEngine<Player> chat,
  IConnectionRegistry<Guid> sessions,
  PlayerRegistry players) : IDataPacketHandler<SayPacket>
{
  public void Handle(INetConnection connection, SayPacket packet)
  {
    if (!sessions.TryGetSessionId(connection, out var id))
      return;
    if (!players.TryGet(id, out var player))
      return;

    chat.Handle(player, packet.Text);
  }
}

A handler is a class implementing IDataPacketHandler<T> for one packet type, and the host registers it. Its constructor takes what it needs from dependency injection, and the same handler object serves every connection, so it keeps no player's state in a field. See Packets.

  • HelloHandler binds the new connection to a session, adds a player for it, and sends the join line to everyone with SendReliableToAll. Reliable sends arrive in order, which is what a chat line needs. See Delivery modes.
  • SayHandler passes each line to the chat engine. Lines from a connection that has not said hello are dropped.

Lobby.Server/Chat.cs:

using BSGameLib.Chat;
using BSGameLib.Networking;
using BSGameLib.Server;
using Lobby.Shared;

// The lobby's chat engine. Lines that are not commands go to everyone,
// with the sender's name in front.
public sealed class LobbyChat(
  IEnumerable<ICommandProvider> providers,
  ICommandFactoryProvider factories,
  IListenSocket socket) : BaseChatEngine<Player>(providers, factories)
{
  public override void Handle(Player source, string message)
  {
    if (TryHandleCommand(source, ref message))
      return;

    socket.SendReliableToAll(new LinePacket { Text = $"{source.Name}: {message}" });
  }
}

// Runs before the server listens. It announces leaves and adds the /who command.
public class LobbySetup(
  IChatEngine<Player> chat,
  IServerAdapter adapter,
  IConnectionRegistry<Guid> sessions,
  IConnectionManager<Guid> manager,
  PlayerRegistry players,
  IListenSocket socket) : IInitializer
{
  public void Initialize()
  {
    adapter.Disconnected += (_, e) =>
    {
      if (!sessions.TryGetSessionId(e.Client, out var id))
        return;

      manager.UnbindByConnection(e.Client);
      if (players.TryGet(id, out var player))
      {
        players.Remove(id);
        socket.SendReliableToAll(new LinePacket { Text = $"{player.Name} left" });
      }
    };

    chat.CreateCommand("who")
        .WithKeywords("who")
        .WithAction(ctx =>
        {
          var player = ctx.SourceTo<Player>();
          var online = string.Join(", ", players.Names);
          if (sessions.TryGetConnection(player.Id, out var connection))
            socket.SendReliableTo(connection, new LinePacket { Text = $"Online: {online}" });
        })
        .Build();
  }
}
  • LobbyChat is the server's chat engine for Player. The host uses it in place of its default, because a class deriving from BaseChatEngine<TSource> is registered for that source type. A line starting with / runs a command. Any other line goes to everyone, with the sender's name. A line starting with / that names no command is used up and is not sent as chat. See Chat on a server and The chat engine.
  • LobbySetup is an IInitializer, so it runs before the server starts listening. The host runs it before any client can connect, so the command and the event handler exist from the start. See The game loop.
  • The Disconnected event is raised when a connection ends for any reason. The handler unbinds the session, removes the player and tells the others. Nothing does that for you. See Sessions and connections.
  • /who is a command built with the builder. WithAction runs when the command is typed, and SourceTo<Player>() says who typed it, so the answer goes back to that player only. See Commands.

The client

The client is one file: a window that shows the lobby's lines, with a text box under them.

Lobby.Client/Program.cs:

using BSGameLib.Game;
using BSGameLib.Networking;
using ImGuiNET;
using Lobby.Shared;

using var game = new LobbyClient(args.FirstOrDefault() ?? "Guest");
game.Run();

public class LobbyClient(string name)
  : Game(new WindowOptions { Title = "Lobby", Width = 640, Height = 480 })
{
  private readonly List<string> _lines = [];
  private ClientAdapter? _client;
  private string _text = "";

  protected override void Initialize()
  {
    var gameThread = GetService<IGameThread>();

    var packets = new SimpleDataPacketProvider();
    packets.RegisterPacket<LinePacket>();
    var handlers = new SimpleDataPacketHandlerProvider();
    handlers.RegisterHandler(new LineHandler(line => _lines.Add(line)));
    var manager = new DataPacketManager(Logger, packets, handlers);

    _client = new ClientAdapter(Logger, manager, new NetworkAdapterConfiguration
    {
      Quic = new QuicTransportConfiguration(),
    });

    // Handlers run on the game's thread, so they can change _lines.
    _client.HandlerRunner = (packetType, run) => gameThread.Post(run);
    _client.Connected += (_, _) => _client.SendReliable(new HelloPacket { Name = name });

    Task.Run(() =>
    {
      try
      {
        _client.Connect("127.0.0.1", 55237);
      }
      catch (Exception e)
      {
        gameThread.Post(() => _lines.Add($"Could not connect: {e.Message}"));
      }
    });

    base.Initialize();
  }

  protected override void EndFrame(IGameTime gameTime)
  {
    ImGui.Begin("Lobby");
    foreach (var line in _lines)
      ImGui.TextWrapped(line);

    if (ImGui.InputText("##say", ref _text, 200, ImGuiInputTextFlags.EnterReturnsTrue))
      Send();
    ImGui.End();

    base.EndFrame(gameTime);
  }

  protected override void Dispose(bool isDisposing)
  {
    _client?.Disconnect("Leaving");
    base.Dispose(isDisposing);
  }

  private void Send()
  {
    var text = _text.Trim();
    _text = "";
    if (text.Length == 0 || _client is not { IsConnected: true })
      return;

    _client.SendReliable(new SayPacket { Text = text });
  }
}

public class LineHandler(Action<string> onLine) : IDataPacketHandler<LinePacket>
{
  public void Handle(INetConnection connection, LinePacket packet) => onLine(packet.Text);
}
  • LobbyClient is a game. Initialize runs once, after the window exists, so that is where the connection is made. See Your first game.
  • The client registers the packets it receives with its DataPacketManager. That is only LinePacket here. Sending needs no registration. See The client.
  • Connect blocks until the connection is up, so it runs on a background thread. Connected is raised before Connect returns, and it sends the hello. See Connecting.
  • HandlerRunner posts each handler to IGameThread, so the handler adds its line on the game's thread, where EndFrame reads the list. Without it, the handler would run on a network thread while the frame draws the same list. See Handlers and threads and Work from other threads.
  • The window is drawn with Dear ImGui, which ships with the game package. It is the engine's tool for debug panels and developer views. A game's own menus and screens belong in BSGameLib.UI. See Dear ImGui.
  • Dispose says goodbye when the window closes, so the other players see the leave straight away. Without it, the server would notice only when the connection timed out.

Run it

Start the server first. It listens on port 55237, and a client that connects before the server is up stops with a "Could not connect" line in its window.

dotnet run --project Lobby.Server

In another terminal, start a client with a name. The -- separates the name from the arguments for dotnet run:

dotnet run --project Lobby.Client -- Ann

Start a second client in a third terminal, with another name:

dotnet run --project Lobby.Client -- Ben

Click the text box in a client's window, type a line and press Enter. Type /who to see who is online. Close a window to leave, and press Ctrl+C in the server's terminal to stop the server.

The clients connect to 127.0.0.1, so the example runs on one machine. To play from another machine, change the address in Connect, and open the server's UDP ports: see Running the server. The server listens on every address of the machine, so only open the ports to the players you want.

What you should see

The server prints its start-up lines, and then one line for each connection:

info: BSGameLib.Server[0]
      QUIC server transport started. QUIC on port 55237, UDP game channel on port 55239.
info: Microsoft.Hosting.Lifetime[0]
      Application started. Press Ctrl+C to shut down.
info: BSGameLib.Server[0]
      Client connected from 127.0.0.1.

The first time a client connects, it trusts the server's certificate, and it keeps the record in its own folder. See Server certificates.

In Ann's window, once Ann has connected:

Ann joined

When Ben connects, both windows show Ben joined. Ann's window then shows Ann: hello after she types hello, and Ben's window shows it too. When Ben types /who, only Ben's window shows Online: Ann, Ben. When Ben closes his window, Ann's shows Ben left.

Next