Learning Akka.NET: Server of a simple online game

Hello, Habr! I decided it means trying to rewrite the server that I did with MS Orleans on Akka.NET just to try this technology too. If you are interested in what happened before, welcome to cat.

Source code


gitlab.com/VictorWinbringer/msorleansonlinegame/-/tree/master/Server/AkkaActors

About the game


Shooting with des match mode. All against everyone. Green # are opponents. Yellow # is your character. Red $ is a bullet. Shooting is in the direction you are moving.

The direction of movement is controlled by the WASD buttons or arrows. The space bar is used for the shot. I want to make a graphical client on Three.js in the future and put the game on some kind of free hosting. So far, there is only a temporary console client.



Personal impressions


In general, they both solve the problem when you want to parallelize your calculations and do not use lock (object). Roughly speaking, all the code that you have inside the lock can usually be placed in an actor. In addition, each actor lives his own life and can be restarted independently of the others. At the same time, maintaining the viability of the entire system as a whole. Fault tolerance in general. MS Orleans seemed to me more convenient and sharpened under RPC. Akka.NET is simpler and less. It can be used simply as a library for reactive asynchronous computing. MS Orleans immediately requires itself to select a separate port and configure for itself a host that will be launched when the application starts. Akka.NET does not need anything in the basic configuration. I connected the nuget package and use it. But MS Orleans has strongly typed interfaces for actors (grains).In general, if I needed to write a microservice entirely on the actors, I would choose MS Orleans if, in one place, we just parallelize the calculations and avoid synchronizing threads through lock, AutoResetEventSlim or something else like that, Akka.NET. So yes, there is a misconception allegedly the Hallo shooting server is made on the actors. Oh, still. There, on the actors, only any infrastructure like payments and other things. The very logic of the player’s movement and hitting a shot, that’s the game logic, it is calculated in C ++ monolith. Here in an MMO like WoW where where you choose the target clearly and you have a global recharge of almost 1 second in size for all the spells there often use actors.

Code and comments


The entry point of our server. SignalR Hub


      public class GameHub : Hub
    {
        private readonly ActorSystem _system;
        private readonly IServiceProvider _provider;
        private readonly IGrainFactory _client;

        public GameHub(
            IGrainFactory client,
            ActorSystem system,
            IServiceProvider provider
            )
        {
            _client = client;
            _system = system;
            _provider = provider;
        }

        public async Task JoinGame(long gameId, Guid playerId)
        {
//IActorRef     .
//        
            var gameFactory = _provider.GetRequiredServiceByName<Func<long, IActorRef>>("game");
            var game = gameFactory(gameId);
            var random = new Random();
            var player = new Player()
            {
                IsAlive = true,
                GameId = gameId,
                Id = playerId,
                Point = new Point()
                {
                    X = (byte)random.Next(1, GameActor.WIDTH - 1),
                    Y = (byte)random.Next(1, GameActor.HEIGHT - 1)
                }
            };
            game.Tell(player);
        }

        public async Task GameInput(Input input, long gameId)
        {
//            . 
// user    .
//    -  akka://game/user/1/2
            _system.ActorSelection($"user/{gameId}/{input.PlayerId}").Tell(input);
        }
    }

Register our actor system in DI:

services.AddSingleton(ActorSystem.Create("game"));
var games = new Dictionary<long, IActorRef>();
services.AddSingletonNamedService<Func<long, IActorRef>>(
    "game", (sp, name) => gameId =>
{
    lock (games)
    {
        if (!games.TryGetValue(gameId, out IActorRef gameActor))
        {
            var frame = new Frame(GameActor.WIDTH, GameActor.HEIGHT) { GameId = gameId };
            var gameEntity = new Game()
            {
                Id = gameId,
                Frame = frame
            };
//    .
//   IServiceProvide       
            var props = Props.Create(() => new GameActor(gameEntity, sp));
            var actorSystem = sp.GetRequiredService<ActorSystem>();
//        .
            gameActor = actorSystem.ActorOf(props, gameId.ToString());
            games[gameId] = gameActor;
        }
        return gameActor;
    }
});

Actors


Gameactor


    public sealed class GameActor : UntypedActor
    {
        public const byte WIDTH = 100;
        public const byte HEIGHT = 50;
        private DateTime _updateTime;
        private double _totalTime;
        private readonly Game _game;
        private readonly IHubContext<GameHub> _hub;

        public GameActor(Game game, IServiceProvider provider)
        {
            _updateTime = DateTime.Now;
            _game = game;
            _hub = (IHubContext<GameHub>)provider.GetService(typeof(IHubContext<GameHub>));
//        
//RunMessage      .
//      .
            Context
                .System
                .Scheduler
                .ScheduleTellRepeatedly(
//       .
                    TimeSpan.FromMilliseconds(100), 
//     
                    TimeSpan.FromMilliseconds(1),
// 
                    Context.Self, 
//    
                    new RunMessage(),
//   . Nobody   . null .
                    ActorRefs.Nobody
                    );
        }

//    .
//    -  .
        protected override void OnReceive(object message)
        {
            if (message is RunMessage run)
                Handle(run);
            if (message is Player player)
                Handle(player);
            if (message is Bullet bullet)
                Handle(bullet);
        }

//   Create or Update
//            
        private void Update<T>(
            List<T> entities,
            T entity,
            Func<object> createInitMessage,
            Func<Props> createProps
            )
            where T : IGameEntity
        {
            if (!entity.IsAlive)
            {
                var actor = Context.Child(entity.Id.ToString());
                if (!actor.IsNobody())
                    Context.Stop(actor);
                entities.RemoveAll(b => b.Id == entity.Id);
            }
//Create
            else if (!entities.Any(b => b.Id == entity.Id))
            {
                Context.ActorOf(createProps(), entity.Id.ToString());
                entities.Add(entity);
                Context.Child(entity.Id.ToString()).Tell(createInitMessage());
            }
//Update
            else
            {
                entities.RemoveAll(b => b.Id == entity.Id);
                entities.Add(entity);
            }
        }

        private void Handle(Bullet bullet)
        {
            Update(
                _game.Bullets,
                bullet,
                () => new InitBulletMessage(bullet.Clone(), _game.Frame.Clone()),
                () => Props.Create(() => new BulletActor())
                );
        }

        private void Handle(Player player)
        {
            Update(
                _game.Players,
                player,
                () => new InitPlayerMessage(player.Clone(), _game.Frame.Clone()),
                () => Props.Create(() => new PlayerActor())
            );
        }

        private void Handle(RunMessage run)
        {
            var deltaTime = DateTime.Now - _updateTime;
            _updateTime = DateTime.Now;
            var delta = deltaTime.TotalMilliseconds;
            Update(delta);
            Draw(delta);
        }

        private void Update(double deltaTime)
        {
            var players = _game.Players.Select(p => p.Clone()).ToList();
            foreach (var child in Context.GetChildren())
            {
                child.Tell(new UpdateMessage(deltaTime, players));
            }
        }

        private void Draw(double deltaTime)
        {
            _totalTime += deltaTime;
            if (_totalTime < 50)
                return;
            _totalTime = 0;
//PipeTo    Task     .
// ReciveActor    async await 
            _hub.Clients.All.SendAsync("gameUpdated", _game.Clone()).PipeTo(Self);
        }
    }

BulletActor


    public class BulletActor : UntypedActor
    {
        private Bullet _bullet;
        private Frame _frame;

        protected override void OnReceive(object message)
        {
            if (message is InitBulletMessage bullet)
                Handle(bullet);
            if (message is UpdateMessage update)
                Handle(update);
        }

        private void Handle(InitBulletMessage message)
        {
            _bullet = message.Bullet;
            _frame = message.Frame;
        }

        private void Handle(UpdateMessage message)
        {
            if (_bullet == null)
                return;
            if (!_bullet.IsAlive)
            {
                Context.Parent.Tell(_bullet.Clone());
                return;
            }
            _bullet.Move(message.DeltaTime);
            if (_frame.Collide(_bullet))
                _bullet.IsAlive = false;
            if (!_bullet.IsInFrame(_frame))
                _bullet.IsAlive = false;
            foreach (var player in message.Players)
            {
                if (player.Id == _bullet.PlayerId)
                    continue;

//         
//  
                if (player.Collide(_bullet))
                {
                    _bullet.IsAlive = false;
                    Context
                        .ActorSelection(Context.Parent.Path.ToString() + "/" + player.Id.ToString())
                        .Tell(new DieMessage());
                }
            }
            Context.Parent.Tell(_bullet.Clone());
        }
    }

PlayerActor


    public class PlayerActor : UntypedActor
    {
        private Player _player;
        private Queue<Direction> _directions;
        private Queue<Command> _commands;
        private Frame _frame;

        public PlayerActor()
        {
            _directions = new Queue<Direction>();
            _commands = new Queue<Command>();
        }

        protected override void OnReceive(object message)
        {
            if (message is Input input)
                Handle(input);
            if (message is UpdateMessage update)
                Handle(update);
            if (message is InitPlayerMessage init)
                Handle(init);
            if (message is DieMessage)
            {
                _player.IsAlive = false;
                Context.Parent.Tell(_player.Clone());
            }
        }

        private void Handle(InitPlayerMessage message)
        {
            _player = message.Player;
            _frame = message.Frame;
        }

        private void Handle(Input message)
        {
            if (_player == null)
                return;
            if (_player.IsAlive)
            {
                foreach (var command in message.Commands)
                {
                    _commands.Enqueue(command);
                }

                foreach (var direction in message.Directions)
                {
                    _directions.Enqueue(direction);
                }
            }
        }

        private void Handle(UpdateMessage update)
        {
            if (_player == null)
                return;
            if (_player.IsAlive)
            {
                HandleCommands(update.DeltaTime);
                HandleDirections();
                Move(update.DeltaTime);
            }
            Context.Parent.Tell(_player.Clone());
        }

        private void HandleDirections()
        {
            while (_directions.Count > 0)
            {
                _player.Direction = _directions.Dequeue();
            }
        }

        private void HandleCommands(double delta)
        {
            _player.TimeAfterLastShot += delta;
            if (!_player.HasColldown && _commands.Any(command => command == Command.Shoot))
            {
//Shot      
//         
                var bullet = _player.Shot();
                Context.Parent.Tell(bullet.Clone());
                _commands.Clear();
            }
        }

        private void Move(double delta)
        {
            _player.Move(delta);
            if (_frame.Collide(_player))
                _player.MoveBack();
        }
    }

Messages forwarded between actors


//    .
//            .
public sealed class DieMessage { }

//      
//        
    public sealed class InitBulletMessage
    {
        public Bullet Bullet { get; }
        public Frame Frame { get; }

        public InitBulletMessage(Bullet bullet, Frame frame)
        {
            Bullet = bullet ?? throw new ApplicationException(" ");
            Frame = frame ?? throw new ApplicationException(" ");
        }
    }

//      
//     
    public class InitPlayerMessage
    {
        public Player Player { get; }
        public Frame Frame { get; }

        public InitPlayerMessage(Player player, Frame frame)
        {
            Player = player ?? throw new ApplicationException(" !");
            Frame = frame ?? throw new ApplicationException(" ");
        }
    }

//          
public sealed class RunMessage { }

//     
//       .
    public sealed class UpdateMessage
    {
        public double DeltaTime { get; }
//          
        public List<Player> Players { get; }

        public UpdateMessage(double deltaTime, List<Player> players)
        {
            DeltaTime = deltaTime;
            Players = players ?? throw new ApplicationException(" !");
        }
    }

All Articles