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) { }
}
ServerHostbuilds and runs the server, andRun()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
certsfolder 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.
Bootstrapperruns 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.LobbyServeris the oneIGameServerthe 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.
HelloHandlerbinds the new connection to a session, adds a player for it, and sends the join line to everyone withSendReliableToAll. Reliable sends arrive in order, which is what a chat line needs. See Delivery modes.SayHandlerpasses 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();
}
}
LobbyChatis the server's chat engine forPlayer. The host uses it in place of its default, because a class deriving fromBaseChatEngine<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.LobbySetupis anIInitializer, 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
Disconnectedevent 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. /whois a command built with the builder.WithActionruns when the command is typed, andSourceTo<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);
}
LobbyClientis a game.Initializeruns 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 onlyLinePackethere. Sending needs no registration. See The client. Connectblocks until the connection is up, so it runs on a background thread.Connectedis raised beforeConnectreturns, and it sends the hello. See Connecting.HandlerRunnerposts each handler toIGameThread, so the handler adds its line on the game's thread, whereEndFramereads 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.
Disposesays 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
- Packets: what packets can carry, and how to change one safely.
- Delivery modes: when to send a packet reliably, and when not to.
- The chat engine and Commands: commands with arguments, groups and help.
- Sessions and connections: sending to one player, or to all but one.
- Running as a service: running the server on a machine other players reach.