diff --git a/OpenPolytopia.Common/LobbyData.cs b/OpenPolytopia.Common/LobbyData.cs
new file mode 100644
index 00000000..3b8867aa
--- /dev/null
+++ b/OpenPolytopia.Common/LobbyData.cs
@@ -0,0 +1,104 @@
+namespace OpenPolytopia.Common;
+
+using Network;
+
+///
+/// A player inside a lobby
+///
+public class LobbyPlayerData : INetworkSerializable {
+ ///
+ /// Server-assigned id of the player
+ ///
+ public uint PlayerId;
+
+ ///
+ /// Name of the player
+ ///
+ public string Name = "";
+
+ ///
+ /// Tribe chosen by the player
+ ///
+ public uint Tribe;
+
+ ///
+ /// Whether the player is ready to start the game
+ ///
+ public bool Ready;
+
+ public void Serialize(List bytes) {
+ PlayerId.Serialize(bytes);
+ Name.Serialize(bytes);
+ Tribe.Serialize(bytes);
+ Ready.Serialize(bytes);
+ }
+
+ public void Deserialize(byte[] bytes, ref uint index) {
+ PlayerId.Deserialize(bytes, ref index);
+ Name = StringSerialization.Read(bytes, ref index);
+ Tribe.Deserialize(bytes, ref index);
+ Ready.Deserialize(bytes, ref index);
+ }
+}
+
+///
+/// Represents a lobby where players can join and start a game
+///
+public class LobbyData : INetworkSerializable {
+ ///
+ /// ID of the lobby
+ ///
+ public ulong Id;
+
+ ///
+ /// Number of max players that can join this lobby
+ ///
+ public uint MaxPlayers;
+
+ ///
+ /// If the game in the lobby has started
+ ///
+ public bool Started;
+
+ ///
+ /// If the game in the lobby is about to start (all players ready)
+ ///
+ public bool Starting;
+
+ ///
+ /// The players currently in the lobby
+ ///
+ public List Players = [];
+
+ ///
+ /// Number of players in the lobby
+ ///
+ public uint PlayersCount => (uint)Players.Count;
+
+ ///
+ /// Number of players ready to start
+ ///
+ public uint ReadyCount => (uint)Players.Count(player => player.Ready);
+
+ ///
+ /// Returns the player data from a given player id
+ ///
+ /// the player's id
+ public LobbyPlayerData? this[uint playerId] => Players.FirstOrDefault(player => player.PlayerId == playerId);
+
+ public void Serialize(List bytes) {
+ Id.Serialize(bytes);
+ MaxPlayers.Serialize(bytes);
+ Started.Serialize(bytes);
+ Starting.Serialize(bytes);
+ Players.Serialize(bytes);
+ }
+
+ public void Deserialize(byte[] bytes, ref uint index) {
+ Id.Deserialize(bytes, ref index);
+ MaxPlayers.Deserialize(bytes, ref index);
+ Started.Deserialize(bytes, ref index);
+ Starting.Deserialize(bytes, ref index);
+ Players.Deserialize(bytes, ref index);
+ }
+}
diff --git a/OpenPolytopia.Common/Network/ClientConnection.cs b/OpenPolytopia.Common/Network/ClientConnection.cs
new file mode 100644
index 00000000..c64cc269
--- /dev/null
+++ b/OpenPolytopia.Common/Network/ClientConnection.cs
@@ -0,0 +1,108 @@
+namespace OpenPolytopia.Common.Network;
+
+using System.Collections.Concurrent;
+using System.Net.Sockets;
+using Packets;
+
+///
+/// Client-side connection to the game server
+///
+///
+/// Received packets are queued in to let the consumer
+/// process them on its own thread; gets answered automatically
+/// and the connection gets closed if the server doesn't send anything for longer than
+///
+public class ClientConnection(string address, int port) : IDisposable {
+ private static readonly TimeSpan TIMEOUT = TimeSpan.FromSeconds(30);
+ private static readonly TimeSpan TIMEOUT_CHECK_INTERVAL = TimeSpan.FromSeconds(5);
+
+ private readonly TcpClient _client = new();
+ private readonly CancellationTokenSource _cts = new();
+ private NetworkConnection? _connection;
+
+ ///
+ /// Packets received from the server, waiting to be processed
+ ///
+ public ConcurrentQueue IncomingPackets { get; } = new();
+
+ ///
+ /// Fired when the connection to the server gets closed
+ ///
+ public event Action? OnDisconnected;
+
+ ///
+ /// true while connected to the server
+ ///
+ public bool Connected => _connection?.Connected ?? false;
+
+ ///
+ /// Connects to the server and starts reading packets in background
+ ///
+ public async Task ConnectAsync() {
+ PacketRegistrar.RegisterAllPackets();
+ await _client.ConnectAsync(address, port, _cts.Token);
+
+ _connection = new NetworkConnection(0, _client);
+ _connection.OnPacketReceived += PacketReceivedAsync;
+ _connection.OnDisconnected += _ => OnDisconnected?.Invoke();
+
+ // read packets and watch for a dead server in background
+ _ = _connection.RunAsync(_cts.Token);
+ _ = TimeoutLoopAsync(_connection, _cts.Token);
+ }
+
+ ///
+ /// Sends a packet to the server
+ ///
+ /// the packet to send
+ public async Task SendPacketAsync(IPacket packet) {
+ if (_connection == null) {
+ return;
+ }
+
+ await _connection.SendPacketAsync(packet, _cts.Token);
+ }
+
+ ///
+ /// Closes the connection
+ ///
+ public void Disconnect() {
+ _cts.Cancel();
+ _connection?.Close();
+ }
+
+ private static async Task TimeoutLoopAsync(NetworkConnection connection, CancellationToken ct) {
+ using var timer = new PeriodicTimer(TIMEOUT_CHECK_INTERVAL);
+
+ try {
+ while (await timer.WaitForNextTickAsync(ct)) {
+ // the server pings every 10 seconds, a long silence means it's gone
+ if (DateTime.UtcNow - connection.LastReceived > TIMEOUT) {
+ connection.Close();
+ return;
+ }
+ }
+ }
+ catch (OperationCanceledException) {
+ // client disconnecting
+ }
+ }
+
+ private async Task PacketReceivedAsync(NetworkConnection connection, IPacket packet) {
+ // echo keep alive packets back, everything else goes to the queue
+ if (packet is KeepAlivePacket keepAlive) {
+ await connection.SendPacketAsync(keepAlive, _cts.Token);
+ return;
+ }
+
+ IncomingPackets.Enqueue(packet);
+ }
+
+ public void Dispose() {
+ Disconnect();
+ _cts.Dispose();
+ _connection?.Dispose();
+ _client.Dispose();
+ GC.SuppressFinalize(this);
+ }
+}
diff --git a/OpenPolytopia.Common/Network/INetworkSerializable.cs b/OpenPolytopia.Common/Network/INetworkSerializable.cs
new file mode 100644
index 00000000..83d92e27
--- /dev/null
+++ b/OpenPolytopia.Common/Network/INetworkSerializable.cs
@@ -0,0 +1,24 @@
+namespace OpenPolytopia.Common.Network;
+
+///
+/// Interface for types that need to be serialized to be sent on the network
+///
+public interface INetworkSerializable {
+ ///
+ /// Serialize data
+ ///
+ /// the bytes where to serialize into, use
+ public void Serialize(List bytes);
+
+ ///
+ /// Deserialize data
+ ///
+ /// the buffer bytes where to read from
+ /// the index where to start reading
+ ///
+ /// It is assumed that every Deserialize operation increments index as needed.
+ /// For example, increments index by one
+ /// while increments it by four
+ ///
+ public void Deserialize(byte[] bytes, ref uint index);
+}
diff --git a/OpenPolytopia.Common/Network/NetworkConnection.cs b/OpenPolytopia.Common/Network/NetworkConnection.cs
new file mode 100644
index 00000000..d506aebb
--- /dev/null
+++ b/OpenPolytopia.Common/Network/NetworkConnection.cs
@@ -0,0 +1,128 @@
+namespace OpenPolytopia.Common.Network;
+
+using System.IO;
+using System.Net.Sockets;
+using Packets;
+
+///
+/// Wraps a connected to send and receive packets
+///
+///
+/// Used by the client for its connection to the server
+/// and by the server for every connected client
+///
+public class NetworkConnection(uint id, TcpClient client) : IDisposable {
+ private readonly NetworkStream _stream = client.GetStream();
+ private readonly SemaphoreSlim _writeLock = new(1, 1);
+ private int _closed;
+
+ ///
+ /// Id of this connection; assigned by the server
+ ///
+ public uint Id { get; } = id;
+
+ ///
+ /// Timestamp of the last packet received on this connection
+ ///
+ public DateTime LastReceived { get; private set; } = DateTime.UtcNow;
+
+ ///
+ /// Fired for every packet received on this connection
+ ///
+ public event Func? OnPacketReceived;
+
+ ///
+ /// Fired once when the connection gets closed for any reason
+ ///
+ public event Action? OnDisconnected;
+
+ ///
+ /// true while the underlying socket is connected
+ ///
+ public bool Connected => _closed == 0 && client.Connected;
+
+ ///
+ /// Sends a packet to the remote endpoint
+ ///
+ ///
+ /// This method is thread-safe
+ ///
+ /// the packet to send
+ /// cancellation token
+ public async Task SendPacketAsync(IPacket packet, CancellationToken ct = default) =>
+ await SendFrameAsync(PacketProtocol.FramePacket(packet), ct);
+
+ ///
+ /// Sends an already framed packet to the remote endpoint
+ ///
+ ///
+ /// This method is thread-safe
+ ///
+ /// the framed packet to send
+ /// cancellation token
+ public async Task SendFrameAsync(byte[] frame, CancellationToken ct = default) {
+ await _writeLock.WaitAsync(ct);
+ try {
+ await _stream.WriteAsync(frame, ct);
+ }
+ finally {
+ _writeLock.Release();
+ }
+ }
+
+ ///
+ /// Reads packets from the connection until it gets closed or the token gets cancelled
+ ///
+ ///
+ /// Fires for every packet
+ /// and at the end
+ ///
+ /// cancellation token
+ public async Task RunAsync(CancellationToken ct = default) {
+ try {
+ while (!ct.IsCancellationRequested) {
+ var packet = await PacketProtocol.ReadPacketAsync(_stream, ct);
+ LastReceived = DateTime.UtcNow;
+
+ // unknown packet, skip it
+ if (packet == null) {
+ continue;
+ }
+
+ var handler = OnPacketReceived;
+ if (handler != null) {
+ await handler(this, packet);
+ }
+ }
+ }
+ catch (Exception e) when (e is OperationCanceledException or EndOfStreamException or IOException
+ or ProtocolViolationException or ObjectDisposedException or SocketException) {
+ // connection closed or unusable, fall through to cleanup
+ }
+ finally {
+ Close();
+ }
+ }
+
+ ///
+ /// Closes the connection
+ ///
+ ///
+ /// Calling this multiple times is safe
+ ///
+ public void Close() {
+ // atomic exchange so two threads closing at once fire OnDisconnected only once
+ if (Interlocked.Exchange(ref _closed, 1) == 1) {
+ return;
+ }
+
+ client.Close();
+ OnDisconnected?.Invoke(this);
+ }
+
+ public void Dispose() {
+ Close();
+ _writeLock.Dispose();
+ GC.SuppressFinalize(this);
+ }
+}
diff --git a/OpenPolytopia.Common/Network/NetworkConstants.cs b/OpenPolytopia.Common/Network/NetworkConstants.cs
new file mode 100644
index 00000000..367415cd
--- /dev/null
+++ b/OpenPolytopia.Common/Network/NetworkConstants.cs
@@ -0,0 +1,21 @@
+namespace OpenPolytopia.Common.Network;
+
+public static class NetworkConstants {
+ ///
+ /// Version of the network protocol
+ ///
+ ///
+ /// The handshake fails if client and server have different versions
+ ///
+ public const string VERSION = "0.1.0";
+
+ ///
+ /// Default port the server listens on
+ ///
+ public const int DEFAULT_PORT = 6969;
+
+ ///
+ /// Maximum size in bytes of a single packet, counting the packet id and the payload
+ ///
+ public const uint MAX_PACKET_SIZE = 1024 * 1024;
+}
diff --git a/OpenPolytopia.Common/Network/NetworkSerialization.cs b/OpenPolytopia.Common/Network/NetworkSerialization.cs
new file mode 100644
index 00000000..7d3dc3bd
--- /dev/null
+++ b/OpenPolytopia.Common/Network/NetworkSerialization.cs
@@ -0,0 +1,105 @@
+namespace OpenPolytopia.Common.Network;
+
+using System.Text;
+
+// Everything is written in network byte order (big-endian)
+// Every Deserialize should increment the index
+
+public static class BoolSerialization {
+ public static void Serialize(this bool value, List bytes) => bytes.Add((byte)value.ToUInt());
+
+ public static void Deserialize(this ref bool value, byte[] bytes, ref uint index) => value = bytes[index++] == 1;
+}
+
+public static class ByteSerialization {
+ public static void Serialize(this byte value, List bytes) => bytes.Add(value);
+
+ public static void Deserialize(this ref byte value, byte[] bytes, ref uint index) => value = bytes[index++];
+}
+
+public static class UIntSerialization {
+ public static void Serialize(this uint value, List bytes) {
+ bytes.Add((byte)(value >> 24));
+ bytes.Add((byte)(value >> 16));
+ bytes.Add((byte)(value >> 8));
+ bytes.Add((byte)value);
+ }
+
+ public static byte[] Serialize(this uint value) => [
+ (byte)(value >> 24), (byte)(value >> 16), (byte)(value >> 8), (byte)value
+ ];
+
+ public static void Deserialize(this ref uint value, byte[] bytes, ref uint index) {
+ value = ((uint)bytes[index] << 24) | ((uint)bytes[index + 1] << 16) | ((uint)bytes[index + 2] << 8) |
+ bytes[index + 3];
+ index += 4;
+ }
+
+ public static uint Read(byte[] bytes, ref uint index) {
+ var value = 0u;
+ value.Deserialize(bytes, ref index);
+ return value;
+ }
+}
+
+public static class ULongSerialization {
+ public static void Serialize(this ulong value, List bytes) {
+ bytes.Add((byte)(value >> 56));
+ bytes.Add((byte)(value >> 48));
+ bytes.Add((byte)(value >> 40));
+ bytes.Add((byte)(value >> 32));
+ bytes.Add((byte)(value >> 24));
+ bytes.Add((byte)(value >> 16));
+ bytes.Add((byte)(value >> 8));
+ bytes.Add((byte)value);
+ }
+
+ public static void Deserialize(this ref ulong value, byte[] bytes, ref uint index) {
+ value = 0;
+ for (var i = 0; i < 8; i++) {
+ value = (value << 8) | bytes[index++];
+ }
+ }
+
+ public static ulong Read(byte[] bytes, ref uint index) {
+ var value = 0ul;
+ value.Deserialize(bytes, ref index);
+ return value;
+ }
+}
+
+public static class StringSerialization {
+ public static void Serialize(this string value, List bytes) {
+ var encoded = Encoding.UTF8.GetBytes(value);
+ ((uint)encoded.Length).Serialize(bytes);
+ bytes.AddRange(encoded);
+ }
+
+ public static string Read(byte[] bytes, ref uint index) {
+ var length = UIntSerialization.Read(bytes, ref index);
+ var value = Encoding.UTF8.GetString(bytes, (int)index, (int)length);
+ index += length;
+ return value;
+ }
+}
+
+public static class ListSerialization {
+ public static void Serialize(this List list, List bytes) where T : INetworkSerializable {
+ ((uint)list.Count).Serialize(bytes);
+ foreach (var element in list) {
+ element.Serialize(bytes);
+ }
+ }
+
+ public static void Deserialize(this List list, byte[] bytes, ref uint index)
+ where T : INetworkSerializable, new() {
+ var length = UIntSerialization.Read(bytes, ref index);
+
+ for (var i = 0; i < length; i++) {
+ var value = new T();
+ value.Deserialize(bytes, ref index);
+ list.Add(value);
+ }
+ }
+
+}
diff --git a/OpenPolytopia.Common/Network/PacketProtocol.cs b/OpenPolytopia.Common/Network/PacketProtocol.cs
new file mode 100644
index 00000000..efee6ff8
--- /dev/null
+++ b/OpenPolytopia.Common/Network/PacketProtocol.cs
@@ -0,0 +1,102 @@
+namespace OpenPolytopia.Common.Network;
+
+using System.IO;
+using Packets;
+
+///
+/// Thrown when a packet violates the wire format, like being malformed or too big
+///
+public class ProtocolViolationException(string message) : Exception(message);
+
+///
+/// Implements the wire format of the protocol
+///
+///
+/// Every packet is framed as [uint content length][uint packet id][payload],
+/// with every integer in network byte order (big-endian);
+/// the content length covers the packet id and the payload
+///
+public static class PacketProtocol {
+ ///
+ /// Frames a packet into a byte list ready to be sent on the wire
+ ///
+ /// the packet to frame
+ /// the list where to append the framed packet
+ /// if the framed packet exceeds
+ public static void FramePacket(IPacket packet, List bytes) {
+ // remember where this packet starts to insert the content length later
+ var startIndex = bytes.Count;
+
+ // serialize the id and the payload
+ PacketRegistrar.GetPacketId(packet).Serialize(bytes);
+ packet.Serialize(bytes);
+
+ // fail on the sender instead of disconnecting the receivers
+ var contentLength = (uint)(bytes.Count - startIndex);
+ if (contentLength > NetworkConstants.MAX_PACKET_SIZE) {
+ bytes.RemoveRange(startIndex, bytes.Count - startIndex);
+ throw new ProtocolViolationException($"Packet too big: {contentLength} bytes");
+ }
+
+ // insert the content length before the id
+ bytes.InsertRange(startIndex, contentLength.Serialize());
+ }
+
+ ///
+ /// Frames a packet into a byte array ready to be sent on the wire
+ ///
+ /// the packet to frame
+ /// the framed packet
+ public static byte[] FramePacket(IPacket packet) {
+ List bytes = [];
+ FramePacket(packet, bytes);
+ return [.. bytes];
+ }
+
+ ///
+ /// Reads exactly one packet from the stream
+ ///
+ ///
+ /// Blocks until a full packet is available or the stream gets closed
+ ///
+ /// the stream to read from
+ /// cancellation token
+ /// the packet or null if the packet id isn't registered
+ /// if the remote endpoint violates the protocol
+ /// if the connection gets closed
+ public static async Task ReadPacketAsync(Stream stream, CancellationToken ct = default) {
+ // read the content length
+ var header = new byte[4];
+ await stream.ReadExactlyAsync(header, ct);
+ var index = 0u;
+ var contentLength = UIntSerialization.Read(header, ref index);
+
+ // a packet must contain at least the packet id
+ if (contentLength is < 4 or > NetworkConstants.MAX_PACKET_SIZE) {
+ throw new ProtocolViolationException($"Invalid packet length: {contentLength}");
+ }
+
+ // read the whole content (packet id + payload)
+ var content = new byte[contentLength];
+ await stream.ReadExactlyAsync(content, ct);
+
+ // parse the packet id and create the corresponding packet
+ index = 0u;
+ var packetId = UIntSerialization.Read(content, ref index);
+ var packet = PacketRegistrar.CreatePacket(packetId);
+
+ // skip unknown packets so older clients can talk to newer servers
+ if (packet == null) {
+ return null;
+ }
+
+ try {
+ packet.Deserialize(content, ref index);
+ }
+ catch (Exception e) when (e is IndexOutOfRangeException or ArgumentOutOfRangeException) {
+ throw new ProtocolViolationException($"Malformed payload for packet {packet.GetType().Name}");
+ }
+
+ return packet;
+ }
+}
diff --git a/OpenPolytopia.Common/Network/PacketRegistrar.cs b/OpenPolytopia.Common/Network/PacketRegistrar.cs
new file mode 100644
index 00000000..ba0e6994
--- /dev/null
+++ b/OpenPolytopia.Common/Network/PacketRegistrar.cs
@@ -0,0 +1,70 @@
+namespace OpenPolytopia.Common.Network;
+
+using Packets;
+
+public static class PacketRegistrar {
+ private static readonly Dictionary> _packetFactories = new(32);
+ private static readonly Dictionary _packetIds = new(32);
+ private static readonly object _lock = new();
+ private static bool _registered;
+
+ ///
+ /// Register a new packet with the given ID
+ ///
+ /// the id of the packet
+ /// the type of the packet
+ public static void RegisterPacket(uint id) where T : IPacket, new() {
+ _packetFactories.Add(id, () => new T());
+ _packetIds.Add(typeof(T), id);
+ }
+
+ ///
+ /// Creates a new empty packet instance given the ID
+ ///
+ /// the id of the packet
+ /// a new packet instance or null if the id isn't registered
+ public static IPacket? CreatePacket(uint id) =>
+ _packetFactories.TryGetValue(id, out var factory) ? factory() : null;
+
+ ///
+ /// Returns a packet ID given its type
+ ///
+ /// the packet
+ /// the packet ID
+ public static uint GetPacketId(IPacket packet) => _packetIds[packet.GetType()];
+
+ ///
+ /// Registers all the packets of the protocol
+ ///
+ ///
+ /// Calling this multiple times is safe
+ ///
+ public static void RegisterAllPackets() {
+ lock (_lock) {
+ if (_registered) {
+ return;
+ }
+
+ _registered = true;
+
+ RegisterPacket(0);
+ RegisterPacket(1);
+ RegisterPacket(2);
+ RegisterPacket(3);
+ RegisterPacket(4);
+ RegisterPacket(5);
+ RegisterPacket(6);
+ RegisterPacket(7);
+ RegisterPacket(8);
+ RegisterPacket(9);
+ RegisterPacket(10);
+ RegisterPacket(11);
+ RegisterPacket(12);
+ RegisterPacket(13);
+ RegisterPacket(14);
+ RegisterPacket(15);
+ RegisterPacket(16);
+ RegisterPacket(17);
+ }
+ }
+}
diff --git a/OpenPolytopia.Common/Network/Packets/HandshakePacket.cs b/OpenPolytopia.Common/Network/Packets/HandshakePacket.cs
new file mode 100644
index 00000000..a0cdcada
--- /dev/null
+++ b/OpenPolytopia.Common/Network/Packets/HandshakePacket.cs
@@ -0,0 +1,43 @@
+namespace OpenPolytopia.Common.Network.Packets;
+
+///
+/// First packet sent by the client to check protocol compatibility
+///
+public class HandshakePacket : IPacket {
+ ///
+ /// Protocol version of the client, see
+ ///
+ public string Version = "";
+
+ public void Serialize(List bytes) => Version.Serialize(bytes);
+
+ public void Deserialize(byte[] bytes, ref uint index) => Version = StringSerialization.Read(bytes, ref index);
+}
+
+///
+/// Server response to
+///
+public class HandshakeResponsePacket : IPacket {
+ ///
+ /// true if the client version is compatible with the server
+ ///
+ public bool Ok;
+
+ ///
+ /// Id assigned to the client by the server
+ ///
+ ///
+ /// Valid only if is true
+ ///
+ public uint PlayerId;
+
+ public void Serialize(List bytes) {
+ Ok.Serialize(bytes);
+ PlayerId.Serialize(bytes);
+ }
+
+ public void Deserialize(byte[] bytes, ref uint index) {
+ Ok.Deserialize(bytes, ref index);
+ PlayerId.Deserialize(bytes, ref index);
+ }
+}
diff --git a/OpenPolytopia.Common/Network/Packets/IPacket.cs b/OpenPolytopia.Common/Network/Packets/IPacket.cs
new file mode 100644
index 00000000..c5ae32ce
--- /dev/null
+++ b/OpenPolytopia.Common/Network/Packets/IPacket.cs
@@ -0,0 +1,12 @@
+namespace OpenPolytopia.Common.Network.Packets;
+
+///
+/// Interface to declare a packet
+///
+///
+/// A packet is sent on the wire as [uint content length][uint packet id][payload]
+/// where the content length covers the packet id and the payload.
+/// Every packet must be registered in with a unique id
+/// and must have a parameterless constructor
+///
+public interface IPacket : INetworkSerializable;
diff --git a/OpenPolytopia.Common/Network/Packets/KeepAlivePacket.cs b/OpenPolytopia.Common/Network/Packets/KeepAlivePacket.cs
new file mode 100644
index 00000000..7b4b28b9
--- /dev/null
+++ b/OpenPolytopia.Common/Network/Packets/KeepAlivePacket.cs
@@ -0,0 +1,15 @@
+namespace OpenPolytopia.Common.Network.Packets;
+
+///
+/// Sent periodically by the server to check if a client is still alive
+///
+///
+/// The client echoes it back; a client that doesn't send anything for too long gets disconnected
+///
+public class KeepAlivePacket : IPacket {
+ public void Serialize(List bytes) {
+ }
+
+ public void Deserialize(byte[] bytes, ref uint index) {
+ }
+}
diff --git a/OpenPolytopia.Common/Network/Packets/LobbyActionResult.cs b/OpenPolytopia.Common/Network/Packets/LobbyActionResult.cs
new file mode 100644
index 00000000..377d93bc
--- /dev/null
+++ b/OpenPolytopia.Common/Network/Packets/LobbyActionResult.cs
@@ -0,0 +1,23 @@
+namespace OpenPolytopia.Common.Network.Packets;
+
+///
+/// Result of a lobby-related request
+///
+public enum LobbyActionResult : byte {
+ Ok = 0,
+ NotRegistered = 1,
+ LobbyNotFound = 2,
+ LobbyAlreadyStarted = 3,
+ LobbyFull = 4,
+ AlreadyJoinedLobby = 5,
+ NotInLobby = 6,
+ InvalidParameters = 7,
+ TooManyLobbies = 8,
+}
+
+public static class LobbyActionResultSerialization {
+ public static void Serialize(this LobbyActionResult value, List bytes) => bytes.Add((byte)value);
+
+ public static void Deserialize(this ref LobbyActionResult value, byte[] bytes, ref uint index) =>
+ value = (LobbyActionResult)bytes[index++];
+}
diff --git a/OpenPolytopia.Common/Network/Packets/LobbyPackets.cs b/OpenPolytopia.Common/Network/Packets/LobbyPackets.cs
new file mode 100644
index 00000000..37620ea6
--- /dev/null
+++ b/OpenPolytopia.Common/Network/Packets/LobbyPackets.cs
@@ -0,0 +1,275 @@
+namespace OpenPolytopia.Common.Network.Packets;
+
+///
+/// Asks the server for the list of all the lobbies
+///
+public class GetLobbiesPacket : IPacket {
+ public void Serialize(List bytes) { }
+
+ public void Deserialize(byte[] bytes, ref uint index) { }
+}
+
+///
+/// Server response to
+///
+public class GetLobbiesResponsePacket : IPacket {
+ ///
+ /// All the lobbies currently on the server
+ ///
+ public List Lobbies = [];
+
+ public void Serialize(List bytes) => Lobbies.Serialize(bytes);
+
+ public void Deserialize(byte[] bytes, ref uint index) => Lobbies.Deserialize(bytes, ref index);
+}
+
+///
+/// Creates a new lobby
+///
+///
+/// The sender automatically joins the new lobby
+///
+public class CreateLobbyPacket : IPacket {
+ ///
+ /// Number of max players that can join the lobby
+ ///
+ public uint MaxPlayers;
+
+ ///
+ /// Tribe chosen by the player creating the lobby
+ ///
+ public uint Tribe;
+
+ public void Serialize(List bytes) {
+ MaxPlayers.Serialize(bytes);
+ Tribe.Serialize(bytes);
+ }
+
+ public void Deserialize(byte[] bytes, ref uint index) {
+ MaxPlayers.Deserialize(bytes, ref index);
+ Tribe.Deserialize(bytes, ref index);
+ }
+}
+
+///
+/// Server response to
+///
+public class CreateLobbyResponsePacket : IPacket {
+ ///
+ /// Result of the operation
+ ///
+ public LobbyActionResult Result;
+
+ ///
+ /// Id of the newly created lobby
+ ///
+ ///
+ /// Valid only if is
+ ///
+ public ulong LobbyId;
+
+ public void Serialize(List bytes) {
+ Result.Serialize(bytes);
+ LobbyId.Serialize(bytes);
+ }
+
+ public void Deserialize(byte[] bytes, ref uint index) {
+ Result.Deserialize(bytes, ref index);
+ LobbyId.Deserialize(bytes, ref index);
+ }
+}
+
+///
+/// Joins an existing lobby
+///
+public class JoinLobbyPacket : IPacket {
+ ///
+ /// Id of the lobby to join
+ ///
+ public ulong LobbyId;
+
+ ///
+ /// Tribe chosen by the player
+ ///
+ public uint Tribe;
+
+ public void Serialize(List bytes) {
+ LobbyId.Serialize(bytes);
+ Tribe.Serialize(bytes);
+ }
+
+ public void Deserialize(byte[] bytes, ref uint index) {
+ LobbyId.Deserialize(bytes, ref index);
+ Tribe.Deserialize(bytes, ref index);
+ }
+}
+
+///
+/// Server response to
+///
+public class JoinLobbyResponsePacket : IPacket {
+ ///
+ /// Result of the operation
+ ///
+ public LobbyActionResult Result;
+
+ ///
+ /// Id of the lobby the player tried to join
+ ///
+ public ulong LobbyId;
+
+ public void Serialize(List bytes) {
+ Result.Serialize(bytes);
+ LobbyId.Serialize(bytes);
+ }
+
+ public void Deserialize(byte[] bytes, ref uint index) {
+ Result.Deserialize(bytes, ref index);
+ LobbyId.Deserialize(bytes, ref index);
+ }
+}
+
+///
+/// Leaves a lobby the player joined before
+///
+public class LeaveLobbyPacket : IPacket {
+ ///
+ /// Id of the lobby to leave
+ ///
+ public ulong LobbyId;
+
+ public void Serialize(List bytes) => LobbyId.Serialize(bytes);
+
+ public void Deserialize(byte[] bytes, ref uint index) => LobbyId.Deserialize(bytes, ref index);
+}
+
+///
+/// Server response to
+///
+public class LeaveLobbyResponsePacket : IPacket {
+ ///
+ /// Result of the operation
+ ///
+ public LobbyActionResult Result;
+
+ ///
+ /// Id of the lobby the player tried to leave
+ ///
+ public ulong LobbyId;
+
+ public void Serialize(List bytes) {
+ Result.Serialize(bytes);
+ LobbyId.Serialize(bytes);
+ }
+
+ public void Deserialize(byte[] bytes, ref uint index) {
+ Result.Deserialize(bytes, ref index);
+ LobbyId.Deserialize(bytes, ref index);
+ }
+}
+
+///
+/// Updates the ready state of the player in a lobby
+///
+///
+/// When all the players in a lobby are ready, the game starts
+///
+public class SetReadyPacket : IPacket {
+ ///
+ /// Id of the lobby
+ ///
+ public ulong LobbyId;
+
+ ///
+ /// true to mark the player as ready, false to remove the ready state
+ ///
+ public bool Ready;
+
+ public void Serialize(List bytes) {
+ LobbyId.Serialize(bytes);
+ Ready.Serialize(bytes);
+ }
+
+ public void Deserialize(byte[] bytes, ref uint index) {
+ LobbyId.Deserialize(bytes, ref index);
+ Ready.Deserialize(bytes, ref index);
+ }
+}
+
+///
+/// Server response to
+///
+public class SetReadyResponsePacket : IPacket {
+ ///
+ /// Result of the operation
+ ///
+ public LobbyActionResult Result;
+
+ ///
+ /// Id of the lobby
+ ///
+ public ulong LobbyId;
+
+ public void Serialize(List bytes) {
+ Result.Serialize(bytes);
+ LobbyId.Serialize(bytes);
+ }
+
+ public void Deserialize(byte[] bytes, ref uint index) {
+ Result.Deserialize(bytes, ref index);
+ LobbyId.Deserialize(bytes, ref index);
+ }
+}
+
+///
+/// Broadcast by the server when a lobby gets created or modified
+///
+public class LobbyUpdatedPacket : IPacket {
+ ///
+ /// The new state of the lobby
+ ///
+ public LobbyData Lobby = new();
+
+ public void Serialize(List bytes) => Lobby.Serialize(bytes);
+
+ public void Deserialize(byte[] bytes, ref uint index) => Lobby.Deserialize(bytes, ref index);
+}
+
+///
+/// Broadcast by the server when a lobby gets deleted
+///
+public class LobbyDeletedPacket : IPacket {
+ ///
+ /// Id of the deleted lobby
+ ///
+ public ulong LobbyId;
+
+ public void Serialize(List bytes) => LobbyId.Serialize(bytes);
+
+ public void Deserialize(byte[] bytes, ref uint index) => LobbyId.Deserialize(bytes, ref index);
+}
+
+///
+/// Sent by the server to every player of a lobby when its game starts
+///
+public class GameStartedPacket : IPacket {
+ ///
+ /// Id of the lobby whose game started
+ ///
+ public ulong LobbyId;
+
+ ///
+ /// The players taking part in the game
+ ///
+ public List Players = [];
+
+ public void Serialize(List bytes) {
+ LobbyId.Serialize(bytes);
+ Players.Serialize(bytes);
+ }
+
+ public void Deserialize(byte[] bytes, ref uint index) {
+ LobbyId.Deserialize(bytes, ref index);
+ Players.Deserialize(bytes, ref index);
+ }
+}
diff --git a/OpenPolytopia.Common/Network/Packets/SetNamePacket.cs b/OpenPolytopia.Common/Network/Packets/SetNamePacket.cs
new file mode 100644
index 00000000..7a920b94
--- /dev/null
+++ b/OpenPolytopia.Common/Network/Packets/SetNamePacket.cs
@@ -0,0 +1,29 @@
+namespace OpenPolytopia.Common.Network.Packets;
+
+///
+/// Registers the player on the server or renames him
+///
+public class SetNamePacket : IPacket {
+ ///
+ /// The new name of the player
+ ///
+ public string Name = "";
+
+ public void Serialize(List bytes) => Name.Serialize(bytes);
+
+ public void Deserialize(byte[] bytes, ref uint index) => Name = StringSerialization.Read(bytes, ref index);
+}
+
+///
+/// Server response to
+///
+public class SetNameResponsePacket : IPacket {
+ ///
+ /// true if the name was accepted
+ ///
+ public bool Ok;
+
+ public void Serialize(List bytes) => Ok.Serialize(bytes);
+
+ public void Deserialize(byte[] bytes, ref uint index) => Ok.Deserialize(bytes, ref index);
+}
diff --git a/OpenPolytopia.Common/Network/ServerConnection.cs b/OpenPolytopia.Common/Network/ServerConnection.cs
new file mode 100644
index 00000000..b11cf650
--- /dev/null
+++ b/OpenPolytopia.Common/Network/ServerConnection.cs
@@ -0,0 +1,316 @@
+namespace OpenPolytopia.Common.Network;
+
+using System.Collections.Concurrent;
+using System.Net.Sockets;
+using System.Threading.Channels;
+using Packets;
+
+///
+/// Accepts TCP connections and manages one per client
+///
+///
+/// Outgoing packets go through one bounded queue per client, so they get delivered
+/// in the order they were enqueued and a slow client can't delay the others
+/// nor grow the memory of the server by never draining his queue.
+/// Every it sends a to every client
+/// and disconnects the ones that didn't send anything back for longer than ;
+/// clients that don't complete a handshake within get disconnected too
+///
+/// the port to listen on
+/// the ip address to bind to; null to listen on every interface
+public class ServerConnection(int port, string? bindAddress = null) : IDisposable {
+ private static readonly TimeSpan KEEP_ALIVE_INTERVAL = TimeSpan.FromSeconds(10);
+ private static readonly TimeSpan TIMEOUT = TimeSpan.FromSeconds(30);
+ private static readonly TimeSpan HANDSHAKE_TIMEOUT = TimeSpan.FromSeconds(10);
+ private static readonly TimeSpan SEND_TIMEOUT = TimeSpan.FromSeconds(10);
+ private static readonly TimeSpan ACCEPT_RETRY_DELAY = TimeSpan.FromSeconds(1);
+
+ ///
+ /// Max frames queued for a single client; way more than lobby traffic ever needs
+ ///
+ private const int MAX_QUEUED_FRAMES = 256;
+
+ private readonly TcpListener _listener = bindAddress == null
+ ? TcpListener.Create(port)
+ : new TcpListener(System.Net.IPAddress.Parse(bindAddress), port);
+ private readonly ConcurrentDictionary _clients = new();
+ private readonly CancellationTokenSource _cts = new();
+ private uint _nextId;
+
+ ///
+ /// Fired when a new client connects
+ ///
+ ///
+ /// Fired before any packet is received from the client
+ ///
+ public event Action? OnClientConnected;
+
+ ///
+ /// Fired when a client disconnects
+ ///
+ public event Action? OnClientDisconnected;
+
+ ///
+ /// Fired for every packet received from any client
+ ///
+ public event Func? OnPacketReceived;
+
+ ///
+ /// Listens to incoming connections
+ ///
+ ///
+ /// Runs until gets called
+ ///
+ public async Task RunAsync() {
+ PacketRegistrar.RegisterAllPackets();
+ _listener.Start();
+
+ // manage the keep alive in background
+ _ = KeepAliveLoopAsync(_cts.Token);
+
+ try {
+ while (!_cts.IsCancellationRequested) {
+ TcpClient tcpClient;
+ try {
+ tcpClient = await _listener.AcceptTcpClientAsync(_cts.Token);
+ }
+ catch (SocketException e) {
+ // transient failure, like a connection aborted mid-handshake; keep accepting the others.
+ // back off a little so a persistent failure, like running out of file descriptors,
+ // doesn't turn this into a busy loop
+ Console.Error.WriteLine($"Failed to accept a connection: {e.Message}");
+ await Task.Delay(ACCEPT_RETRY_DELAY, _cts.Token);
+ continue;
+ }
+
+ var id = Interlocked.Increment(ref _nextId);
+
+ var connection = new NetworkConnection(id, tcpClient);
+ connection.OnPacketReceived += ClientPacketReceivedAsync;
+ connection.OnDisconnected += ClientDisconnected;
+
+ var client = new Client(connection);
+ _clients[id] = client;
+
+ OnClientConnected?.Invoke(connection);
+
+ // manage the client in background
+ _ = connection.RunAsync(_cts.Token);
+ _ = SenderLoopAsync(client, _cts.Token);
+ }
+ }
+ catch (OperationCanceledException) {
+ // server stopping
+ }
+ finally {
+ _listener.Stop();
+
+ foreach (var client in _clients.Values) {
+ client.Connection.Close();
+ }
+ }
+ }
+
+ ///
+ /// Stops the server and disconnects every client
+ ///
+ public void Stop() => _cts.Cancel();
+
+ ///
+ /// Marks a client as having completed the handshake
+ ///
+ ///
+ /// Only clients that completed the handshake receive broadcasts and keep alive packets;
+ /// the others get disconnected after
+ ///
+ /// the id of the client
+ public void CompleteHandshake(uint id) {
+ if (_clients.TryGetValue(id, out var client)) {
+ client.HandshakeDone = true;
+ }
+ }
+
+ ///
+ /// Checks if a client completed the handshake
+ ///
+ /// the id of the client
+ public bool IsHandshakeDone(uint id) => _clients.TryGetValue(id, out var client) && client.HandshakeDone;
+
+ ///
+ /// Disconnects a client after delivering the packets already queued for him
+ ///
+ /// the id of the client
+ public void Kick(uint id) {
+ if (_clients.TryGetValue(id, out var client)) {
+ client.Kicked = true;
+ client.Outgoing.Writer.TryComplete();
+ }
+ }
+
+ ///
+ /// Queues a packet for a single client
+ ///
+ /// the id of the client
+ /// the packet to send
+ public void SendTo(uint id, IPacket packet) => SendTo(id, PacketProtocol.FramePacket(packet));
+
+ ///
+ /// Queues an already framed packet for a single client
+ ///
+ /// the id of the client
+ /// the framed packet to send
+ public void SendTo(uint id, byte[] frame) {
+ if (_clients.TryGetValue(id, out var client)) {
+ Enqueue(client, frame);
+ }
+ }
+
+ ///
+ /// Queues a packet for every client that completed the handshake
+ ///
+ /// the packet to broadcast
+ public void Broadcast(IPacket packet) => Broadcast(PacketProtocol.FramePacket(packet));
+
+ ///
+ /// Queues an already framed packet for every client that completed the handshake
+ ///
+ /// the framed packet to broadcast
+ public void Broadcast(byte[] frame) {
+ foreach (var client in _clients.Values) {
+ if (client.HandshakeDone) {
+ Enqueue(client, frame);
+ }
+ }
+ }
+
+ ///
+ /// Queues a packet for the given clients
+ ///
+ /// the ids of the clients
+ /// the packet to send
+ public void BroadcastTo(IEnumerable ids, IPacket packet) {
+ var frame = PacketProtocol.FramePacket(packet);
+ foreach (var id in ids) {
+ SendTo(id, frame);
+ }
+ }
+
+ ///
+ /// Queues a frame for a client
+ ///
+ ///
+ /// A full queue means the client reads too slowly to keep up with the server,
+ /// so he gets disconnected instead of eating up memory
+ ///
+ /// the client to queue the frame for
+ /// the framed packet to queue
+ private static void Enqueue(Client client, byte[] frame) {
+ if (!client.Outgoing.Writer.TryWrite(frame) && !client.Kicked) {
+ client.Connection.Close();
+ }
+ }
+
+ ///
+ /// Sends the queued packets of a client one at a time, in order
+ ///
+ ///
+ /// If a send fails or takes longer than , the client gets disconnected
+ ///
+ private static async Task SenderLoopAsync(Client client, CancellationToken ct) {
+ try {
+ await foreach (var frame in client.Outgoing.Reader.ReadAllAsync(ct)) {
+ // timeout the send so a client that stopped reading can't pile up frames forever
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ cts.CancelAfter(SEND_TIMEOUT);
+ await client.Connection.SendFrameAsync(frame, cts.Token);
+ }
+ }
+ catch (Exception) {
+ // send failure or server stopping, close below
+ }
+
+ // the queue only completes on a kick, disconnect once it's drained
+ client.Connection.Close();
+ }
+
+ private async Task ClientPacketReceivedAsync(NetworkConnection connection, IPacket packet) {
+ // the keep alive response is managed here, the rest is forwarded
+ if (packet is KeepAlivePacket) {
+ return;
+ }
+
+ var handler = OnPacketReceived;
+ if (handler != null) {
+ await handler(connection, packet);
+ }
+ }
+
+ private void ClientDisconnected(NetworkConnection connection) {
+ if (_clients.TryRemove(connection.Id, out var client)) {
+ // stop the sender loop
+ client.Outgoing.Writer.TryComplete();
+ }
+
+ OnClientDisconnected?.Invoke(connection);
+ }
+
+ private async Task KeepAliveLoopAsync(CancellationToken ct) {
+ using var timer = new PeriodicTimer(KEEP_ALIVE_INTERVAL);
+
+ try {
+ var keepAlive = PacketProtocol.FramePacket(new KeepAlivePacket());
+
+ while (await timer.WaitForNextTickAsync(ct)) {
+ var now = DateTime.UtcNow;
+
+ foreach (var client in _clients.Values) {
+ // kick clients that timed out
+ if (now - client.Connection.LastReceived > TIMEOUT) {
+ client.Connection.Close();
+ continue;
+ }
+
+ // kick clients that connected but never completed a handshake
+ if (!client.HandshakeDone) {
+ if (now - client.ConnectedAt > HANDSHAKE_TIMEOUT) {
+ client.Connection.Close();
+ }
+
+ continue;
+ }
+
+ SendTo(client.Connection.Id, keepAlive);
+ }
+ }
+ }
+ catch (OperationCanceledException) {
+ // server stopping
+ }
+ }
+
+ public void Dispose() {
+ Stop();
+ _cts.Dispose();
+ _listener.Dispose();
+ GC.SuppressFinalize(this);
+ }
+
+ ///
+ /// Server-side state of a connected client
+ ///
+ private sealed class Client(NetworkConnection connection) {
+ public NetworkConnection Connection { get; } = connection;
+ public DateTime ConnectedAt { get; } = DateTime.UtcNow;
+ public volatile bool HandshakeDone;
+ public volatile bool Kicked;
+
+ ///
+ /// Packets waiting to be sent to this client
+ ///
+ ///
+ /// Bounded, so a client that stops draining it can't grow the memory of the server forever
+ ///
+ public Channel Outgoing { get; } =
+ Channel.CreateBounded(new BoundedChannelOptions(MAX_QUEUED_FRAMES) { SingleReader = true });
+ }
+}
diff --git a/OpenPolytopia.Server/GameServer.cs b/OpenPolytopia.Server/GameServer.cs
new file mode 100644
index 00000000..ec4a527f
--- /dev/null
+++ b/OpenPolytopia.Server/GameServer.cs
@@ -0,0 +1,322 @@
+namespace OpenPolytopia.Server;
+
+using OpenPolytopia.Common;
+using OpenPolytopia.Common.Network;
+using OpenPolytopia.Common.Network.Packets;
+
+///
+/// Manages players, lobbies and starting games on top of a
+///
+///
+/// Every packet is queued while holding the state lock,
+/// so the clients receive the lobby updates in the same order the server applied them
+///
+/// the port to listen on
+/// the ip address to bind to; null to listen on every interface
+public class GameServer(int port, string? bindAddress = null) : IDisposable {
+ ///
+ /// How often the server checks for lobbies to start
+ ///
+ private static readonly TimeSpan START_LOBBY_INTERVAL = TimeSpan.FromSeconds(5);
+
+ ///
+ /// Max lobbies the server accepts before refusing new ones
+ ///
+ private const int MAX_LOBBIES = 100;
+
+ private readonly ServerConnection _server = new(port, bindAddress);
+ private readonly LobbyManager _lobbyManager = new();
+ private readonly Dictionary _playerNames = new();
+
+ // guards _lobbyManager and _playerNames: packet handlers run on many client tasks
+ private readonly SemaphoreSlim _stateLock = new(1, 1);
+
+ private readonly CancellationTokenSource _cts = new();
+
+ ///
+ /// Runs the server until gets called
+ ///
+ public async Task RunAsync() {
+ _server.OnPacketReceived += ManagePacketAsync;
+ _server.OnClientDisconnected += connection => _ = ClientDisconnectedAsync(connection);
+
+ // check for lobbies to start in background
+ _ = StartLobbiesLoopAsync(_cts.Token);
+
+ Console.WriteLine($"Server listening on {bindAddress ?? "*"}:{port}");
+ await _server.RunAsync();
+ }
+
+ ///
+ /// Stops the server
+ ///
+ public void Stop() {
+ _cts.Cancel();
+ _server.Stop();
+ }
+
+ private async Task ManagePacketAsync(NetworkConnection connection, IPacket packet) {
+ try {
+ await DispatchPacketAsync(connection, packet);
+ }
+ catch (Exception e) {
+ // log the error without taking the server down
+ Console.Error.WriteLine($"Error while managing {packet.GetType().Name} from client {connection.Id}: {e}");
+ }
+ }
+
+ private async Task DispatchPacketAsync(NetworkConnection connection, IPacket packet) {
+ if (packet is HandshakePacket handshake) {
+ ManageHandshake(connection, handshake);
+ return;
+ }
+
+ // kick clients that send anything else before a successful handshake
+ if (!_server.IsHandshakeDone(connection.Id)) {
+ connection.Close();
+ return;
+ }
+
+ switch (packet) {
+ // register the player or rename him
+ case SetNamePacket setName:
+ await ManageSetNameAsync(connection, setName);
+ break;
+ // respond with all the lobbies currently on the server
+ case GetLobbiesPacket:
+ await ManageGetLobbiesAsync(connection);
+ break;
+ // create a new lobby with the sender inside
+ case CreateLobbyPacket createLobby:
+ await ManageCreateLobbyAsync(connection, createLobby);
+ break;
+ // add the sender to an existing lobby
+ case JoinLobbyPacket joinLobby:
+ await ManageJoinLobbyAsync(connection, joinLobby);
+ break;
+ // remove the sender from a lobby
+ case LeaveLobbyPacket leaveLobby:
+ await ManageLeaveLobbyAsync(connection, leaveLobby);
+ break;
+ // update the ready state of the sender in a lobby
+ case SetReadyPacket setReady:
+ await ManageSetReadyAsync(connection, setReady);
+ break;
+ }
+ }
+
+ private void ManageHandshake(NetworkConnection connection, HandshakePacket packet) {
+ var ok = packet.Version == NetworkConstants.VERSION;
+
+ if (ok) {
+ _server.CompleteHandshake(connection.Id);
+ }
+
+ _server.SendTo(connection.Id, new HandshakeResponsePacket { Ok = ok, PlayerId = connection.Id });
+
+ // kick clients with an incompatible version, after the response gets delivered
+ if (!ok) {
+ _server.Kick(connection.Id);
+ }
+ }
+
+ private async Task ManageSetNameAsync(NetworkConnection connection, SetNamePacket packet) {
+ var name = packet.Name.Trim();
+ var ok = name.Length is > 0 and <= 32;
+
+ await _stateLock.WaitAsync();
+ try {
+ if (ok) {
+ _playerNames[connection.Id] = name;
+
+ // propagate the rename into the lobbies the player joined
+ List updated = [];
+ _lobbyManager.RenamePlayerInLobbies(connection.Id, name, updated);
+ foreach (var lobby in updated) {
+ _server.Broadcast(new LobbyUpdatedPacket { Lobby = lobby });
+ }
+ }
+
+ _server.SendTo(connection.Id, new SetNameResponsePacket { Ok = ok });
+ }
+ finally {
+ _stateLock.Release();
+ }
+ }
+
+ private async Task ManageGetLobbiesAsync(NetworkConnection connection) {
+ await _stateLock.WaitAsync();
+ try {
+ _server.SendTo(connection.Id, new GetLobbiesResponsePacket { Lobbies = [.. _lobbyManager.Lobbies] });
+ }
+ finally {
+ _stateLock.Release();
+ }
+ }
+
+ private async Task ManageCreateLobbyAsync(NetworkConnection connection, CreateLobbyPacket packet) {
+ await _stateLock.WaitAsync();
+ try {
+ LobbyData? lobby = null;
+ LobbyActionResult result;
+
+ if (!_playerNames.TryGetValue(connection.Id, out var name)) {
+ result = LobbyActionResult.NotRegistered;
+ }
+ else if (packet.MaxPlayers is < 2 or > 16 || !Enum.IsDefined((TribeType)packet.Tribe)) {
+ result = LobbyActionResult.InvalidParameters;
+ }
+ // one lobby per player and a global cap, or a client could flood the server
+ else if (_lobbyManager.IsPlayerInAnyLobby(connection.Id)) {
+ result = LobbyActionResult.AlreadyJoinedLobby;
+ }
+ else if (_lobbyManager.LobbiesCount >= MAX_LOBBIES) {
+ result = LobbyActionResult.TooManyLobbies;
+ }
+ else {
+ result = LobbyActionResult.Ok;
+ lobby = _lobbyManager.CreateLobby(packet.MaxPlayers,
+ new LobbyPlayerData { PlayerId = connection.Id, Name = name, Tribe = packet.Tribe });
+ }
+
+ _server.SendTo(connection.Id, new CreateLobbyResponsePacket { Result = result, LobbyId = lobby?.Id ?? 0 });
+
+ if (lobby != null) {
+ _server.Broadcast(new LobbyUpdatedPacket { Lobby = lobby });
+ }
+ }
+ finally {
+ _stateLock.Release();
+ }
+ }
+
+ private async Task ManageJoinLobbyAsync(NetworkConnection connection, JoinLobbyPacket packet) {
+ await _stateLock.WaitAsync();
+ try {
+ LobbyActionResult result;
+
+ if (!_playerNames.TryGetValue(connection.Id, out var name)) {
+ result = LobbyActionResult.NotRegistered;
+ }
+ else if (!Enum.IsDefined((TribeType)packet.Tribe)) {
+ result = LobbyActionResult.InvalidParameters;
+ }
+ // one lobby per player, or a client could flood the server
+ else if (_lobbyManager.IsPlayerInAnyLobby(connection.Id)) {
+ result = LobbyActionResult.AlreadyJoinedLobby;
+ }
+ else {
+ result = _lobbyManager.JoinLobby(packet.LobbyId,
+ new LobbyPlayerData { PlayerId = connection.Id, Name = name, Tribe = packet.Tribe });
+ }
+
+ _server.SendTo(connection.Id, new JoinLobbyResponsePacket { Result = result, LobbyId = packet.LobbyId });
+
+ if (result == LobbyActionResult.Ok && _lobbyManager[packet.LobbyId] is { } lobby) {
+ _server.Broadcast(new LobbyUpdatedPacket { Lobby = lobby });
+ }
+ }
+ finally {
+ _stateLock.Release();
+ }
+ }
+
+ private async Task ManageLeaveLobbyAsync(NetworkConnection connection, LeaveLobbyPacket packet) {
+ await _stateLock.WaitAsync();
+ try {
+ var result = _lobbyManager.LeaveLobby(packet.LobbyId, connection.Id);
+
+ _server.SendTo(connection.Id, new LeaveLobbyResponsePacket { Result = result, LobbyId = packet.LobbyId });
+
+ if (result == LobbyActionResult.Ok && _lobbyManager[packet.LobbyId] is { } lobby) {
+ // remove the lobby if it became empty
+ if (lobby.PlayersCount == 0) {
+ _lobbyManager.RemoveLobby(lobby.Id);
+ _server.Broadcast(new LobbyDeletedPacket { LobbyId = lobby.Id });
+ }
+ else {
+ _server.Broadcast(new LobbyUpdatedPacket { Lobby = lobby });
+ }
+ }
+ }
+ finally {
+ _stateLock.Release();
+ }
+ }
+
+ private async Task ManageSetReadyAsync(NetworkConnection connection, SetReadyPacket packet) {
+ await _stateLock.WaitAsync();
+ try {
+ var result = _lobbyManager.SetReady(packet.LobbyId, connection.Id, packet.Ready);
+
+ _server.SendTo(connection.Id, new SetReadyResponsePacket { Result = result, LobbyId = packet.LobbyId });
+
+ if (result == LobbyActionResult.Ok && _lobbyManager[packet.LobbyId] is { } lobby) {
+ _server.Broadcast(new LobbyUpdatedPacket { Lobby = lobby });
+ }
+ }
+ finally {
+ _stateLock.Release();
+ }
+ }
+
+ private async Task ClientDisconnectedAsync(NetworkConnection connection) {
+ await _stateLock.WaitAsync();
+ try {
+ _playerNames.Remove(connection.Id);
+
+ List updated = [];
+ List deletedIds = [];
+ _lobbyManager.RemovePlayerFromAllLobbies(connection.Id, updated, deletedIds);
+
+ foreach (var lobby in updated) {
+ _server.Broadcast(new LobbyUpdatedPacket { Lobby = lobby });
+ }
+
+ foreach (var id in deletedIds) {
+ _server.Broadcast(new LobbyDeletedPacket { LobbyId = id });
+ }
+ }
+ finally {
+ _stateLock.Release();
+ }
+ }
+
+ private async Task StartLobbiesLoopAsync(CancellationToken ct) {
+ using var timer = new PeriodicTimer(START_LOBBY_INTERVAL);
+
+ try {
+ while (await timer.WaitForNextTickAsync(ct)) {
+ await _stateLock.WaitAsync(ct);
+ try {
+ foreach (var lobby in _lobbyManager.TakeStartingLobbies()) {
+ // TODO: world generation
+ // TODO: initialize game data
+ // TODO: add players to the game
+
+ Console.WriteLine($"Starting game for lobby {lobby.Id} with {lobby.PlayersCount} players");
+
+ // notify the players that their game started and remove the lobby from the list
+ _server.BroadcastTo(lobby.Players.Select(player => player.PlayerId),
+ new GameStartedPacket { LobbyId = lobby.Id, Players = lobby.Players });
+ _server.Broadcast(new LobbyDeletedPacket { LobbyId = lobby.Id });
+ }
+ }
+ finally {
+ _stateLock.Release();
+ }
+ }
+ }
+ catch (OperationCanceledException) {
+ // server stopping
+ }
+ }
+
+ public void Dispose() {
+ Stop();
+ _cts.Dispose();
+ _server.Dispose();
+ _stateLock.Dispose();
+ GC.SuppressFinalize(this);
+ }
+}
diff --git a/OpenPolytopia.Server/LobbyManager.cs b/OpenPolytopia.Server/LobbyManager.cs
new file mode 100644
index 00000000..0cdc2a3b
--- /dev/null
+++ b/OpenPolytopia.Server/LobbyManager.cs
@@ -0,0 +1,233 @@
+namespace OpenPolytopia.Server;
+
+using OpenPolytopia.Common;
+using OpenPolytopia.Common.Network.Packets;
+
+///
+/// Owns all the lobbies on the server
+///
+///
+/// This class isn't thread-safe, serializes every access through its own lock
+///
+public class LobbyManager {
+ ///
+ /// Minimum players needed to start a game; solo games aren't allowed
+ ///
+ private const uint MIN_PLAYERS_TO_START = 2;
+
+ private readonly Dictionary _lobbies = new();
+ private ulong _nextId;
+
+ ///
+ /// All the lobbies on the server
+ ///
+ public IReadOnlyCollection Lobbies => _lobbies.Values;
+
+ ///
+ /// Number of lobbies currently on the server
+ ///
+ public int LobbiesCount => _lobbies.Count;
+
+ ///
+ /// Returns a lobby given its id
+ ///
+ /// the id of the lobby
+ public LobbyData? this[ulong id] => _lobbies.GetValueOrDefault(id);
+
+ ///
+ /// Creates a new lobby and adds the creator to it
+ ///
+ /// max players that can join the lobby
+ /// the player creating the lobby
+ /// the new lobby
+ public LobbyData CreateLobby(uint maxPlayers, LobbyPlayerData creator) {
+ var lobby = new LobbyData { Id = ++_nextId, MaxPlayers = maxPlayers, Players = [creator] };
+ _lobbies[lobby.Id] = lobby;
+ return lobby;
+ }
+
+ ///
+ /// Adds a player to a lobby, applying all the lobby rules
+ ///
+ /// the id of the lobby to join
+ /// the joining player
+ /// the result of the operation
+ public LobbyActionResult JoinLobby(ulong lobbyId, LobbyPlayerData player) {
+ var lobby = this[lobbyId];
+ if (lobby == null) {
+ return LobbyActionResult.LobbyNotFound;
+ }
+
+ if (lobby.Starting) {
+ return LobbyActionResult.LobbyAlreadyStarted;
+ }
+
+ if (lobby[player.PlayerId] != null) {
+ return LobbyActionResult.AlreadyJoinedLobby;
+ }
+
+ if (lobby.PlayersCount >= lobby.MaxPlayers) {
+ return LobbyActionResult.LobbyFull;
+ }
+
+ lobby.Players.Add(player);
+ return LobbyActionResult.Ok;
+ }
+
+ ///
+ /// Removes a player from a lobby
+ ///
+ /// the id of the lobby to leave
+ /// the id of the leaving player
+ /// the result of the operation
+ public LobbyActionResult LeaveLobby(ulong lobbyId, uint playerId) {
+ var lobby = this[lobbyId];
+ if (lobby == null) {
+ return LobbyActionResult.LobbyNotFound;
+ }
+
+ if (lobby.Starting) {
+ return LobbyActionResult.LobbyAlreadyStarted;
+ }
+
+ var player = lobby[playerId];
+ if (player == null) {
+ return LobbyActionResult.NotInLobby;
+ }
+
+ lobby.Players.Remove(player);
+
+ // the leaving player could be the last not-ready one
+ TryMarkStarting(lobby);
+ return LobbyActionResult.Ok;
+ }
+
+ ///
+ /// Sets the ready state of a player in a lobby
+ ///
+ ///
+ /// When every player in the lobby is ready, the lobby is marked as starting
+ ///
+ /// the id of the lobby
+ /// the id of the player
+ /// the new ready state
+ /// the result of the operation
+ public LobbyActionResult SetReady(ulong lobbyId, uint playerId, bool ready) {
+ var lobby = this[lobbyId];
+ if (lobby == null) {
+ return LobbyActionResult.LobbyNotFound;
+ }
+
+ if (lobby.Starting) {
+ return LobbyActionResult.LobbyAlreadyStarted;
+ }
+
+ var player = lobby[playerId];
+ if (player == null) {
+ return LobbyActionResult.NotInLobby;
+ }
+
+ player.Ready = ready;
+ TryMarkStarting(lobby);
+ return LobbyActionResult.Ok;
+ }
+
+ ///
+ /// Marks a lobby as starting when it has at least players
+ /// and every one of them is ready
+ ///
+ ///
+ /// Called after every change to the players of a lobby,
+ /// because removing the last not-ready player can start it too
+ ///
+ /// the lobby to check
+ private static void TryMarkStarting(LobbyData lobby) {
+ if (lobby.PlayersCount >= MIN_PLAYERS_TO_START && lobby.ReadyCount == lobby.PlayersCount) {
+ lobby.Starting = true;
+ }
+ }
+
+ ///
+ /// Checks if a player joined any lobby
+ ///
+ /// the id of the player
+ public bool IsPlayerInAnyLobby(uint playerId) => _lobbies.Values.Any(lobby => lobby[playerId] != null);
+
+ ///
+ /// Renames a player in every lobby he joined
+ ///
+ /// the id of the player
+ /// the new name
+ /// filled with the lobbies that changed
+ public void RenamePlayerInLobbies(uint playerId, string name, List updated) {
+ foreach (var lobby in _lobbies.Values) {
+ var player = lobby[playerId];
+ if (player == null) {
+ continue;
+ }
+
+ player.Name = name;
+ updated.Add(lobby);
+ }
+ }
+
+ ///
+ /// Removes a lobby
+ ///
+ /// the id of the lobby to remove
+ public void RemoveLobby(ulong lobbyId) => _lobbies.Remove(lobbyId);
+
+ ///
+ /// Removes a player from every lobby he joined
+ ///
+ ///
+ /// Used when a client disconnects; lobbies that become empty get removed
+ ///
+ /// the id of the disconnected player
+ /// filled with the lobbies that changed
+ /// filled with the ids of the lobbies that got removed
+ public void RemovePlayerFromAllLobbies(uint playerId, List updated, List deleted) {
+ foreach (var lobby in _lobbies.Values.ToArray()) {
+ var player = lobby[playerId];
+ if (player == null) {
+ continue;
+ }
+
+ lobby.Players.Remove(player);
+
+ if (lobby.PlayersCount == 0) {
+ _lobbies.Remove(lobby.Id);
+ deleted.Add(lobby.Id);
+ }
+ else {
+ // the disconnected player could be the last not-ready one,
+ // or he could drop the lobby below the minimum to start
+ lobby.Starting = false;
+ TryMarkStarting(lobby);
+ updated.Add(lobby);
+ }
+ }
+ }
+
+ ///
+ /// Removes and returns all the lobbies that are marked as starting
+ ///
+ /// the lobbies whose game must start now
+ public List TakeStartingLobbies() {
+ List starting = [];
+
+ foreach (var lobby in _lobbies.Values.ToArray()) {
+ if (!lobby.Starting) {
+ continue;
+ }
+
+ // the flag marks the snapshot handed to the game; the lobby itself leaves the manager,
+ // so no lobby inside it is ever started
+ lobby.Started = true;
+ _lobbies.Remove(lobby.Id);
+ starting.Add(lobby);
+ }
+
+ return starting;
+ }
+}
diff --git a/StdbModule/StdbModule.csproj b/OpenPolytopia.Server/OpenPolytopia.Server.csproj
similarity index 76%
rename from StdbModule/StdbModule.csproj
rename to OpenPolytopia.Server/OpenPolytopia.Server.csproj
index eb1aacc3..0e1eec5d 100644
--- a/StdbModule/StdbModule.csproj
+++ b/OpenPolytopia.Server/OpenPolytopia.Server.csproj
@@ -1,4 +1,4 @@
-
+
net8.0
@@ -11,7 +11,7 @@
-
+
diff --git a/OpenPolytopia.Server/Program.cs b/OpenPolytopia.Server/Program.cs
new file mode 100644
index 00000000..167237d4
--- /dev/null
+++ b/OpenPolytopia.Server/Program.cs
@@ -0,0 +1,49 @@
+namespace OpenPolytopia.Server;
+
+using OpenPolytopia.Common.Network;
+
+internal static class Program {
+ ///
+ /// Environment variable that overrides the default port
+ ///
+ private const string PORT_ENV = "OPENPOLYTOPIA_PORT";
+
+ ///
+ /// Environment variable that overrides the default bind address
+ ///
+ private const string BIND_ADDRESS_ENV = "OPENPOLYTOPIA_BIND_ADDRESS";
+
+ ///
+ /// Entry point of the server
+ ///
+ ///
+ /// Usage: OpenPolytopia.Server [port] [bind-address];
+ /// both arguments are optional and fall back to the
+ /// OPENPOLYTOPIA_PORT/OPENPOLYTOPIA_BIND_ADDRESS environment variables,
+ /// then to port on every interface
+ ///
+ private static async Task Main(string[] args) {
+ var portValue = args.Length > 0 ? args[0] : Environment.GetEnvironmentVariable(PORT_ENV);
+ var port = NetworkConstants.DEFAULT_PORT;
+ if (!string.IsNullOrWhiteSpace(portValue) &&
+ (!int.TryParse(portValue, out port) || port is <= 0 or > ushort.MaxValue)) {
+ Console.Error.WriteLine($"Invalid port: {portValue}");
+ Environment.Exit(1);
+ }
+
+ var bindAddress = args.Length > 1 ? args[1] : Environment.GetEnvironmentVariable(BIND_ADDRESS_ENV);
+ if (string.IsNullOrWhiteSpace(bindAddress)) {
+ bindAddress = null;
+ }
+
+ using var gameServer = new GameServer(port, bindAddress);
+
+ // stop gracefully on ctrl+c
+ Console.CancelKeyPress += (_, eventArgs) => {
+ eventArgs.Cancel = true;
+ gameServer.Stop();
+ };
+
+ await gameServer.RunAsync();
+ }
+}
diff --git a/OpenPolytopia.sln b/OpenPolytopia.sln
index 268b3f11..86427fbd 100644
--- a/OpenPolytopia.sln
+++ b/OpenPolytopia.sln
@@ -6,7 +6,7 @@ Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "FSharpLibrary", "FSharpLibr
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenPolytopia.Common", "OpenPolytopia.Common\OpenPolytopia.Common.csproj", "{07BAA3DA-9489-4A3A-85AD-92C16108241D}"
EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StdbModule", "StdbModule\StdbModule.csproj", "{0B313502-8039-43DA-AC92-4B8146E88349}"
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenPolytopia.Server", "OpenPolytopia.Server\OpenPolytopia.Server.csproj", "{0B313502-8039-43DA-AC92-4B8146E88349}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
diff --git a/OpenPolytopia/OpenPolytopia.csproj b/OpenPolytopia/OpenPolytopia.csproj
index 3248946f..4a28e427 100644
--- a/OpenPolytopia/OpenPolytopia.csproj
+++ b/OpenPolytopia/OpenPolytopia.csproj
@@ -47,8 +47,6 @@
-
-
diff --git a/OpenPolytopia/OpenPolytopia.csproj.old b/OpenPolytopia/OpenPolytopia.csproj.old
deleted file mode 100644
index afbf4da2..00000000
--- a/OpenPolytopia/OpenPolytopia.csproj.old
+++ /dev/null
@@ -1,64 +0,0 @@
-
-
- true
- latest
- enable
- OpenPolytopia
-
- CS9057
-
-
- true
-
-
-
- portable
- true
- OpenPolytopia
- 0.1.0
- OpenPolytopia
- © 2024 Enn3DevPlayer, GamerPlayer888, Remoxx, C0MPL3XDEV
- Enn3DevPlayer, GamerPlayer888, Remoxx, C0MPL3XDEV
- Enn3DevPlayer, GamerPlayer888, Remoxx, C0MPL3XDEV
-
-
- $(DefaultItemExcludes);test/**/*
-
- net8.0
- true
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/OpenPolytopia/OpenPolytopia.csproj.old.1 b/OpenPolytopia/OpenPolytopia.csproj.old.1
deleted file mode 100644
index afbf4da2..00000000
--- a/OpenPolytopia/OpenPolytopia.csproj.old.1
+++ /dev/null
@@ -1,64 +0,0 @@
-
-
- true
- latest
- enable
- OpenPolytopia
-
- CS9057
-
-
- true
-
-
-
- portable
- true
- OpenPolytopia
- 0.1.0
- OpenPolytopia
- © 2024 Enn3DevPlayer, GamerPlayer888, Remoxx, C0MPL3XDEV
- Enn3DevPlayer, GamerPlayer888, Remoxx, C0MPL3XDEV
- Enn3DevPlayer, GamerPlayer888, Remoxx, C0MPL3XDEV
-
-
- $(DefaultItemExcludes);test/**/*
-
- net8.0
- true
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/OpenPolytopia/src/Client.cs.uid b/OpenPolytopia/src/Client.cs.uid
deleted file mode 100644
index fd369383..00000000
--- a/OpenPolytopia/src/Client.cs.uid
+++ /dev/null
@@ -1 +0,0 @@
-uid://bl30yxis03aax
diff --git a/OpenPolytopia/src/Game.cs b/OpenPolytopia/src/Game.cs
index 8cb402c3..c6116204 100644
--- a/OpenPolytopia/src/Game.cs
+++ b/OpenPolytopia/src/Game.cs
@@ -5,26 +5,72 @@ namespace OpenPolytopia;
public partial class Game : Control {
[Export] public PackedScene? LobbyScene;
+ private NetworkNode _network = null!;
+ private Label _statusLabel = null!;
+ private string _playerName = "";
+ private bool _switching;
+
public override void _Ready() {
- // Check if the lobby scene was set
- if (LobbyScene == null) {
- return;
- }
+ _statusLabel = GetNode