• J. J. Allen

A First Attempt at a Client/Server CLI w/ .Net Core 3.1 and ENet for Video Games.

At the successful completion of this article you will have an ENet client & server Cli that contains the basics for hosting, connection, disconnection, and message communication.


Pre-Requisites


  • .Net Core 3.1 SDK installed. Available here https://dotnet.microsoft.com/download/dotnet-core/3.1.

  • A code editor or IDE. I'll be using Visual Studio 2019, but VS Code or an equivalent will also work just fine.

  • An understanding of existing Cli will help, but is not required.

  • An understanding of existing game networking will help, but is not required.


Setup


The folder structure of a new .Net project is just as important as the organization of code that you write. This approach is a slight migration from my project setup for Unity 3d project, but is also consistent with how I approach writing .Net REST APIs. If you have a project structure that works for you, then by all means use it here.


In our root directory we have two folders; Projects and Solutions. The Solutions directory contains folders created by Visual Studio where our .sln file resides. Our Projects directory contains the folders created by Visual Studio where our .csproj files reside.


+ {{Project Name}}

| + Projects

| + + {{Project Name}}.Commands

| + + + {{Project}}Cmd.cs

| + + + StartClientCmd.cs

| + + + StartServerCmd.cs

| + + + Abstractions

| + + + + CommandBase.cs

| + + {{Project Name}}.Web

| + + + appsettings.json

| + + + Program.cs

| + + + Configuration

| + + + + LoggingExtensions.cs

| + Solutions

| + + Solution Dir

| + + + {{Project Name}}.sln


I start by creating a new Visual Studio Solution, setting the directory to the Solutions folder in my project root. In Visual Studio, this is called a Blank Solution.


I then have two folders in my Projects directory, {{Project Name}}.Commands and {{Project Name}}.Web. These folders were created by Visual Studio after adding new projects to the solution. The .Commands project is a .Net Core 3.1 Class Library and the .Web project is a .Net Core 3.1 Console Application.


You can do the same by Right Clicking the solution within the Visual Studio Solution Explorer and adding a New Project.


Add a .Net Core Class Library to your solution.


Add a .Net Core Console Application to your solution.


Infrastructure


We will be using NuGet to pull in some project dependencies. NuGet is a package manager for .Net. This is similar to NPM or Yarn, or any other package manager you may have experience with. Right click your solution and click Manage NuGet Packages for Solution... and add the following dependencies to each project.



The Commands project references the following dependencies.


  • ENet-CSharp

  • McMaster.Extensions.CommandLineUtils

  • Microsoft.Extensions.Logging.Abstractions


The Web project references the following dependencies. You will also want to add a project reference to the Commands project as well.


  • McMaster.Extensions.CommandLineUtils

  • Microsoft.Extensions.Configuration

  • Microsoft.Extensions.Configuration.FileExtensions

  • Microsoft.Extensions.Configuration.Json

  • Microsoft.Extensions.DependencyInjection

  • Microsoft.Extensions.Hosting

  • Microsoft.Extensions.Logging

  • Serilog

  • Serilog.Extensions.Logging

  • Serilog.Settings.Configuration

  • Serilog.Sinks.File


Building our Commands


We will be writing three different commands here, each inheriting from the CommandBase abstract class. One command will be our primary command, and the other two will be sub-commands. My primary command will be a class NetServeCmd, and my sub-commands will be StartClientCmd and StartServerCmd.


Implementing CommandBase.cs

using McMaster.Extensions.CommandLineUtils;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.NetworkInformation;
using System.Threading.Tasks;
using ENet;

namespace Studio.OverOne.Commands.Abstractions
{
    [HelpOption("--help")]
    public abstract class CommandBase
    {
        protected IConsole _console;

        protected ILogger _logger;

        #region " Command Parameters "

        [Option(CommandOptionType.SingleValue, ShortName = "h", LongName = "host",
            ValueName = "Host", ShowInHelpText = true, Description = "The address at which the server is hosted. If NULL, then no peers may connect to the host.")]
        public string Host { get; set; }

        [Option(CommandOptionType.SingleValue, ShortName = "p", LongName = "port",
            ValueName = "Port", ShowInHelpText = true, Description = "The port to use on the host to allow client connections.")]
        public string Port { get; set; }

        [Option(CommandOptionType.SingleOrNoValue, ShortName = "mp", LongName = "max-peers",
            ValueName = "Max Peers", ShowInHelpText = true, Description = "The maximum number of peers that can connect to the host.")]
        public string MaxPeers { get; set; }

        #endregion

        protected virtual Task<int> OnExecute(CommandLineApplication app)
            => Task.FromResult(0);

        protected int GetRandomOpenPort(int portStartIndex, int portEndIndex)
        {
            List<int> lUnusedPorts = GetOpenPorts(portStartIndex, portEndIndex)
                .ToList();

            Random lRandom = new Random();
            int lPortIndex = lRandom.Next(0, lUnusedPorts.Count);
            return lUnusedPorts[lPortIndex];
        }

        protected IEnumerable<int> GetOpenPorts(int portStartIndex, int portEndIndex)
        {
            IPGlobalProperties lProps = IPGlobalProperties.GetIPGlobalProperties();
            IPEndPoint[] lUdpEndPoints = lProps.GetActiveUdpListeners();
            IPEndPoint[] lTcpEndPoints = lProps.GetActiveTcpListeners();

            IEnumerable<int> lUsedUdpPorts = lUdpEndPoints
                .Select(x => x.Port);

            IEnumerable<int> lUsedTcpPorts = lTcpEndPoints
                .Select(x => x.Port);

            List<int> lUsedPorts = new List<int>();
            lUsedPorts.AddRange(lUsedUdpPorts);
            lUsedPorts.AddRange(lUsedTcpPorts);

            for (int lPort = portStartIndex; lPort < portEndIndex; lPort++)
            {
                if (lUsedPorts.Contains(lPort) == false)
                    yield return lPort;
            }
        }

        protected async Task Disconnect(Host server, Peer peer)
        {
            peer.Disconnect(0);

            while (server.Service(3000, out Event lNetEvent) > 0)
            {
                if (lNetEvent.Type == EventType.Receive)
                    lNetEvent.Packet.Dispose();

                if (lNetEvent.Type == EventType.Disconnect)
                    return;
            }

            peer.Reset();
        }

        protected void OnException(Exception ex)
        {
            OutputError(ex.Message);
            _logger.LogError(ex.Message);
            _logger.LogDebug(ex, ex.Message);
        }

        protected void Output(string data, bool newLine = true)
        {
            _logger?.LogInformation(data);

            string lMessage = newLine
                ? $"{data}{Environment.NewLine}"
                : data;

            OutputToConsole(lMessage);
        }

        protected void OutputToConsole(string data)
        {
            _console.BackgroundColor = ConsoleColor.Black;
            _console.ForegroundColor = ConsoleColor.White;
            _console.Out.Write(data);
            _console.ResetColor();
        }

        protected void OutputError(string message)
        {
            _console.BackgroundColor = ConsoleColor.Red;
            _console.ForegroundColor = ConsoleColor.White;
            _console.Error.WriteLine(message);
            _console.ResetColor();
        }
    }
}

Implementing NetServeCmd.cs

using McMaster.Extensions.CommandLineUtils;
using Microsoft.Extensions.Logging;
using System;
using System.Reflection;
using System.Threading.Tasks;

namespace Studio.OverOne.Commands
{
    using Abstractions;

    [Command(Name = "netserve", UnrecognizedArgumentHandling = UnrecognizedArgumentHandling.CollectAndContinue, OptionsComparison = StringComparison.InvariantCultureIgnoreCase)]
    [Subcommand(typeof(StartClientCmd), typeof(StartServerCmd))]
    [VersionOptionFromMember("--version", MemberName = nameof(GetVersion))]
    public sealed class NetServeCmd : CommandBase
    {
        public NetServeCmd(IConsole console, ILogger<NetServeCmd> logger)
        {
            _console = console;
            _logger = logger;
        }

        protected override Task<int> OnExecute(CommandLineApplication app)
        {
            app.ShowHelp();
            return Task.FromResult(0);
        }

        private static string GetVersion()
            => typeof(NetServeCmd).Assembly
                       ?.GetCustomAttribute<AssemblyInformationalVersionAttribute>()
                       ?.InformationalVersion ?? "N/A";
    }
}

Implementing StartClientCmd.cs

using System;
using System.Threading.Tasks;
using ENet;
using McMaster.Extensions.CommandLineUtils;
using Microsoft.Extensions.Logging;

namespace Studio.OverOne.Commands
{
    using Abstractions;

    [Command(Name = "start-client", Description = "Starts a NetServe server.")]
    public class StartClientCmd : CommandBase
    {
        #region " Internal Variables "

        private string _host;

        private ushort _port;

        #endregion

        public StartClientCmd(IConsole console, ILogger<StartClientCmd> logger)
        {
            _console = console;
            _logger = logger;
        }

        protected override async Task<int> OnExecute(CommandLineApplication app)
        {
            do
            {
                if (string.IsNullOrWhiteSpace(Host))
                    Host = Prompt.GetString("Host:");


                if (!string.IsNullOrWhiteSpace(Host))
                    _host = Host;

            } while (string.IsNullOrWhiteSpace(Host));

            do
            {
                if(string.IsNullOrWhiteSpace(Port))
                    Port = Prompt.GetString("Port:");

                if (ushort.TryParse(Port, out ushort lPort))
                    _port = lPort;
                else
                    Port = string.Empty;

            } while (string.IsNullOrWhiteSpace(Port));

            // Initialize ENet.
            ENet.Library.Initialize();

            Host lClient = new Host();

            Address lAddress = new Address();
            lAddress.SetHost(_host);
            lAddress.Port = _port;

            // Start ENet client.
            lClient.Create();
            Peer lPeer = lClient.Connect(lAddress);

            try
            {
                bool lRunning = true;
                _console.CancelKeyPress += (o, e) => lRunning = false;

                Output($"[Client] Client started - Host: {_host}, Port: {_port}");
                Output($"[Client] Terminate client by pressing CTRL + C . . .");

                while (lRunning)
                {
                    bool lPolled = false;
                    while (!lPolled)
                    {
                        if (lClient.CheckEvents(out Event lNetEvent) <= 0)
                        {
                            if (lClient.Service(15, out lNetEvent) <= 0)
                                break;

                            lPolled = true;
                        }

                        if (lNetEvent.Type == EventType.None)
                            break;

                        if (lNetEvent.Type == EventType.Connect)
                        {
                            Output($"[Client] Client connected to server.");
                            break;
                        }

                        if (lNetEvent.Type == EventType.Disconnect)
                        {
                            Output($"[Client] Client disconnected from server.");
                            break;
                        }

                        if (lNetEvent.Type == EventType.Timeout)
                        {
                            Output($"[Client] Client connection timeout.");
                            break;
                        }

                        if (lNetEvent.Type == EventType.Receive)
                        {
                            Output(
                                $"[Client] Packet received from server - Channel ID: {lNetEvent.ChannelID}, Data Length: {lNetEvent.Packet.Length}");

                            lNetEvent.Packet
                                .Dispose();

                            break;
                        }
                    }
                }

                await Disconnect(lClient, lPeer);

                Output("[Client] Client terminated.");
                return 0;
            }
            catch (Exception ex)
            {
                OnException(ex);
                return 1;
            }
            finally
            {
                lClient?.Flush();
                lClient?.Dispose();

                // De-initialize ENet.
                ENet.Library.Deinitialize();
            }
        }
    }
}

Implementing StartServerCmd.cs

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using ENet;
using McMaster.Extensions.CommandLineUtils;
using Microsoft.Extensions.Logging;

namespace Studio.OverOne.Commands
{
    using Abstractions;

    [Command(Name = "start-server", Description = "Starts a NetServe server.")]
    public class StartServerCmd : CommandBase
    {
        public StartServerCmd(IConsole console, ILogger<StartServerCmd> logger)
        {
            _console = console;
            _logger = logger;
        }

        #region " Internal Variables "

        private ushort _port;

        private int _maxPeers;

        private Peer[] _peers;

        #endregion

        protected override async Task<int> OnExecute(CommandLineApplication app)
        {
            do
            {
                if (string.IsNullOrWhiteSpace(Port))
                    Port = Prompt.GetString("Port:");

                if (ushort.TryParse(Port, out ushort lPort))
                    _port = lPort;
                else
                    Port = string.Empty;

            } while (string.IsNullOrWhiteSpace(Port));

            do
            {
                if (string.IsNullOrWhiteSpace(MaxPeers))
                    MaxPeers = Prompt.GetString("Max Peers:");

                if (int.TryParse(MaxPeers, out int lMaxPeers))
                {
                    _maxPeers = lMaxPeers;
                    _peers = new Peer[lMaxPeers];
                }
                else
                    MaxPeers = string.Empty;

            } while (string.IsNullOrWhiteSpace(MaxPeers));

            // Initialize ENet.
            ENet.Library.Initialize();

            Host lServer = new Host();

            Address lAddress = new Address();
            lAddress.Port = _port;

            // Start ENet server.
            lServer.Create(lAddress, _maxPeers);
            lServer.PreventConnections(true);
            try
            {
                bool lRunning = true;
                _console.CancelKeyPress += (o, e) => lRunning = false;
                lServer.PreventConnections(false);

                Output($"[Server] Server started - Port: {_port}, Max Peers: {_maxPeers}");
                Output($"[Server] Terminate server by pressing CTRL + C . . .");

                while (lRunning)
                {
                    bool lPolled = false;
                    while (!lPolled)
                    {
                        if (lServer.CheckEvents(out Event lNetEvent) <= 0)
                        {
                            if (lServer.Service(15, out lNetEvent) <= 0)
                                break;

                            lPolled = true;
                        }

                        if (lNetEvent.Type == EventType.None)
                            break;

                        if (lNetEvent.Type == EventType.Connect)
                        {
                            if (_peers[lNetEvent.Peer.ID].IsSet == false)
                                _peers[lNetEvent.Peer.ID] = lNetEvent.Peer;

                            Output($"[Server] Client connected - ID: {lNetEvent.Peer.ID}, IP: {lNetEvent.Peer.IP}");
                            break;
                        }

                        if (lNetEvent.Type == EventType.Disconnect)
                        {
                            if (_peers[lNetEvent.Peer.ID].IsSet == true)
                                _peers[lNetEvent.Peer.ID] = default;

                            Output($"[Server] Client disconnected - ID: {lNetEvent.Peer.ID}, IP: {lNetEvent.Peer.IP}");
                            break;
                        }

                        if (lNetEvent.Type == EventType.Timeout)
                        {
                            if (_peers[lNetEvent.Peer.ID].IsSet == true)
                                _peers[lNetEvent.Peer.ID] = default;

                            Output($"[Server] Client timeout - ID: {lNetEvent.Peer.ID}, IP: {lNetEvent.Peer.IP}");
                            break;
                        }

                        if (lNetEvent.Type == EventType.Receive)
                        {
                            Output($"[Server] Packet received from - ID: {lNetEvent.Peer.ID}, IP: {lNetEvent.Peer.IP}, Channel ID: {lNetEvent.ChannelID}, Data Length: {lNetEvent.Packet.Length}");

                            lNetEvent.Packet
                                .Dispose();

                            break;
                        }
                    }
                }

                lServer.PreventConnections(true);
                List<Task> lDisconnections = _peers.Where(x => x.IsSet == true)
                    .Select(x => Disconnect(lServer, x))
                    .ToList();

                await Task.WhenAll(lDisconnections);

                Output("[Server] Server terminated.");
                return 0;
            }
            catch (Exception ex)
            {
                OnException(ex);
                return 1;
            }
            finally
            {
                lServer?.Flush();
                lServer?.Dispose();

                // De-initialize ENet.
                ENet.Library.Deinitialize();
            }
        }
    }
}


Writing the CLI


Now that we have our primary command and our sub-commands, lets pull it all together by writing the CLI. The CLI will allow you to run your primary command from a terminal of your choice to start either your server or client.


The appsettings is used by .Net Core to configure Serilog. This allows us to bundle different appsetting.json configuration files with our Cli when deployed to web servers so we can spin up different servers with different configurations.


Implementing LoggingExtensions.cs

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Serilog;
using Serilog.Extensions.Logging;

namespace Studio.OverOne.Web.Configuration
{
    public static class LoggingExtensions
    {
        public static IServiceCollection AddLogging(this IServiceCollection services)
        {
            services.AddLogging(config =>
            {
                config.ClearProviders();
                config.AddProvider(new SerilogLoggerProvider(Log.Logger));
            });

            return services;
        }
    }
}

Implementing appsettings.json

{
  "Serilog": {
    "MinimumLevel": "Debug",
    "WriteTo": [
      {
        "Name": "Console",
        "Args": {
          "outputTemplate": "===> {Timestamp:HH:mm:ss.fff zzz} [{Level:w3}] {Message:lj}{NewLine}{Exception}"
        }
      },
      {
        "Name": "File",
        "Args": {
          "path": "C:\\NetServe\\log\\netserve.log",
          "rollingInterval": "Day",
          "outputTemplate": "===> {Timestamp:HH:mm:ss.fff zzz} [{Level:w3}] {Message:lj}{NewLine}{Exception}"
        }
      }
    ]
  }
}

Implementing Program.cs

using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using System;
using System.IO;
using System.Threading.Tasks;
using Serilog;

namespace Studio.OverOne.Web
{
    using Commands;
    using Configuration;

    internal class Program
    {
        private static async Task<int> Main(string[] args)
        {
            IConfiguration lConfiguration = new ConfigurationBuilder()
                .SetBasePath(Directory.GetCurrentDirectory())
                .AddJsonFile($"{AppDomain.CurrentDomain.BaseDirectory}//appsettings.json", optional: false)
                .AddEnvironmentVariables()
                .Build();

            Log.Logger = new LoggerConfiguration()
                .ReadFrom.Configuration(lConfiguration)
                .Enrich.FromLogContext()
                .CreateLogger();

            try
            {
                IHostBuilder lHostBuilder = new HostBuilder()
                    .ConfigureServices((context, services) =>
                    {
                        services.AddLogging();
                    });

                return await lHostBuilder
                    .RunCommandLineApplicationAsync<NetServe>(args);
            }
            catch(Exception ex)
            {
                Log.Fatal(ex, "Host terminated unexpectedly.");
                return 1;
            }
            finally
            {
                Log.CloseAndFlush();
            }
        }
    }
}

Usage


To use our NetServe Cli, open a terminal window and navigate to the directory in which your project has been built or published. If you did not change the output directories for your builds, this will be a /bin directory located within the NetServe folder within your Projects directory.


To start a server, run the following command;

Joshua@DevMachine .../Studio.OverOne.Web/bin/Debug

$ ./netserve start-server -p:4545 -mp:3

This will start a local game server, listening on port 4545 with 3 client connections available. If everything worked, you should see a message similar to this;

[Server] Server started - Port: 4545, Max Peers: 3
[Server] Terminate server by pressing CTRL + C . . .

To start a client and connect to our server, run the following command in a new terminal;

Joshua@DevMachine .../Studio.OverOne.Web/bin/Debug  $ ./netserve start-client -h:localhost -p:4545

This will start a local client that connects to your local server. If everything worked, you should see a message similar to this in your console terminal;

[Client] Client started - Host: localhost, Port: 4545
[Client] Terminate client by pressing CTRL + C . . .
[Client] Client connected to server.

When a client connected to a server, you should see the following message in your server terminal;

[Server] Client connected - ID: 0, IP: ::1

To shut down the server or disconnect the client, you can use the command CTRL + C in either terminal window.


Shutting down the server will attempt to force the clients to disconnect. Shutting down a client will notify the server that the client is leaving before terminating its connection.


Next Steps


You now have the infrastructure for starting a client and server that can communicate with each other. We'll figure out how to add game specific events in a later post, but for now experiment!


My article was built using the official ENet tutorials as a reference. You can find them here; http://enet.bespin.org/Tutorial.html.




0 views

2018-2020 © Over One Studio LLC.