Goblin Wars II.NET - the story of creating a network game in C # from scratch

    Good afternoon, dear Khabrovites. I present to you my small project - a network 2D shooter in C #. Despite the fact that the visual component is very simple - in our century you will not be interested in 2D games, some architectural solutions may interest people who are going to write their own game. In the article I will talk about options for implementing the key points of the game.


    Long before I became an electronic engineer, I developed purely software projects. From the very moment I wrote my first line of code (the class in the second, on Turbo Basic, in the "Palace of Pioneers"), I did not leave the desire to write a game. I think that almost everyone who started learning to program faced this. Of course, then there wasn’t enough knowledge for something large-scale, there were only simple text games on the same Turbo Basic and then on Quick Basic. Sometimes I used meager basic BASIC capabilities to display graphics - for example, there was a game where you had to control the green pixel, running away from the red and setting traps in the form of white pixels. However, time passed, I learned more and more, and in the end, in the eighth grade, I decided to write a 2D shooter for two people. Why just for two? Because networks weren’t so widespread then, and the most affordable multiplayer mode was Hot-Seat. At that time, we often fought with friends in Worms and Heroes in this way, but I wanted to drive real time. So I decided to write a game where you could run in real time, shoot from various weapons, pick up boxes with supplies, etc. - but at the same time, we could play together. One player was allocated the alphabetic part of the keyboard, another - digital. Since the mouse was single, it was not used, so as not to give advantages to one of the players. And since there was one monitor, the fighting was limited to a non-scrollable krata. pick up boxes with supplies, etc. - but at the same time, we could play together. One player was allocated the alphabetic part of the keyboard, another - digital. Since the mouse was single, it was not used, so as not to give advantages to one of the players. And since there was one monitor, the fighting was limited to a non-scrollable krata. pick up boxes with supplies, etc. - but at the same time, we could play together. One player was allocated the alphabetic part of the keyboard, another - digital. Since the mouse was single, it was not used, so as not to give advantages to one of the players. And since there was one monitor, the fighting was limited to a non-scrollable krata.
    That is how Goblin Wars came about.



    The game was written - yes, it will have mercy on me. Von Neumann - on Visual Basic and used BitBlt to display graphics. I drew all the graphics myself, to the best of my skills, in 3D Max, including the tileset. We voiced the game together with friends. The game even had a background, which, in short, consisted in the fact that the goblins used to live on earth, then people came who began to destroy them, and the goblins had to go underground. Since then, goblin cities have existed under large cities of people. Goblins collect all kinds of technological rubbish, such as old idle televisions thrown out by people, and construct their robots and similar crafts from them. And since there were few of them, the conflicts arising between the cities were decided to be resolved not by bloody wars, but by special tournaments, to which each city sent the best fighter.
    The game was attended by quite a few types of weapons - bombs, grenades, pistols, automatic weapons, rockets, remote bombs, mines, a phase gun (teleportator) and - the main feature of the game - a phosphorus louse + weapon against it - ABO.
    After the goblin launched the phosphorus louse, control switched to it. It was impossible to stop her, only to change the direction of movement. Crashing into the walls or another goblin, it exploded, causing great damage to the epicenter. The funniest thing was that if the goblin, its manager, was killed, or got into a louse from ABO, it became “wild” - its speed doubled and it began to run uncontrollably on the map, with a characteristic sound rushing at the players who turned out to be close, and trying to get to them. In addition, sometimes a “bonus” fell out in the boxes, throwing a dozen wild lice on the card at once, which also added drive. After the match, statistics were issued with the ranks:

    Can you become a legendary magician?

    We very often played Goblins with friends, and also distributed them at school - at computer science lessons, people from our and parallel classes played goblins, bringing the teacher with shouts from the columns “Do you like lice?” Published by goblins. In general, this game captured us in some way, so even after many years, in between Starcraft 2, no, no, we played a match or two in goblins to recall the old days. More than ten years have passed since the first release of Goblin Wars.

    Goblin Wars II.Net

    I had the idea to write the second version for a long time - since now a fast network connection is no longer a problem, I wanted to be able to play with friends on the network, not only 1x1 at the same keyboard. When I finally matured for writing, I started writing GWII in C ++. He brought to the state of the alpha version, and something enthusiasm faded. Not so long ago, the enthusiasm returned, and I, choosing, this time C #, decided to realize what I had planned. Initially, the plans were more ambitious - at least replace the top view with an isometry. But since I had very little time (work, other projects), and I still didn’t have an artist, in the end I decided to do this: take the maximum graphics from the old ones, redrawing, except that it looked completely disgusting (e.g.
    Immediately present a screenshot of what happened:

    In a new light

    And below, at the request of readers - video gameplay, a three-minute test match with a friend.
    Unfortunately, the rest of the friends are now out of reach, so I had to play 1x1.

    So what are Goblin WarsII?
    The game is built on the principle of client-server, all calculations are carried out on the server side, the client is intended solely for rendering. Rendering is done using OpenGL tools, sound is output through OpenAL, images are loaded by DevIL. Network Library - Lindgren network. OpenGL, OpenAL and DevIL are connected to the sharpe via the Tao Framework wrapper.
    No ready-made engines were used - yes, I know, you could probably take a ready-made 3D engine and get the best result, but I just wanted to write everything myself and from scratch, enjoy my “bike”.
    I will describe the architecture of this bike in the following chapters of the article.

    Game Architecture: General Overview

    What I would like to note first of all - the game is modular. Very modular. In the sense that all subsystems of the game are separate dlls, most of which are in no way connected with each other, and those that are connected know only about interfaces. This makes it easy to throw away one part of the system and replace another. Don't like UDP network, lindgren network? Please take one dll, Network.dll, and write your own, not forgetting about the implementation of the interfaces INetworkClient, INetworkServer. For example, to implement a single player, the so-called Zero-network was implemented, which is just a connection for a client and a server without any networks - a pure call to interface methods. At the same time, the client and the server do not care whether they work with Zero-Network or with a real network.
    The same can be said with respect to, for example, graphics. The architecture allows you to rewrite Graphics.dll, replacing OpenGL output with anything - at least WinAPI, at least D3D. Also, by replacing one dll, in principle, you can replace the top view with an isometry, if such a desire arises.
    The following is a dependency garf at the assembly level: The

    architecture of the game

    GoblinWarsII.exe and Server.exe are actually executable files. They contain only a few lines, because all logic is in dllok. For example, all server code looks like this:

      class Program
            static void Main(string[] args)
                var game = new Game();
                var networkServer = new UDPGameServer(game, int.Parse(args[2]));
                var matchParameters = new MatchParameters 
                {Difficulty = DifficultyLevel.Easy, FragLimit=0, TimeLimit = uint.Parse(args[0]), MapName=args[1]};
                while (Console.ReadKey().KeyChar != 'q');

    Common.dll contains general declarations and helper classes, such as a special timer. Basically, there lies a description of data structures, for example, enum types of items that are needed by both the server and the client:

    public enum ItemType

    Network.dll, of course, contains the network logic connecting the client and server together.
    ServerLogic.dll is used only by the server and contains all the game logic, it is there that all the calculations of game objects take place.
    Media.dll is used only by the client and contains the logic for displaying server objects on the client (I will dwell on this later).
    And finally, Graphics.dll contains the code for directly rendering objects.
    Almost every dllka starts its own processing thread and does not stop others - ServerLogic - a thread for calculating game logic, Network - a thread for receiving and transmitting, Media - a thread for processing media objects (representing server objects on a client), Graphics - a rendering thread.
    Let's move on to a specific implementation, and we will start with the implementation of the server.

    Game Architecture: Server

    So, as was clear from the above code, the main thing in the server is the Game class, an instance of which is created in the Server.exe binary:

    var game = new Game();

    All other systems can only see the server logic interface, which looks something like this:

    Server architecture

    Thus, you can start the game from the outside, add or remove players, get various information about the match, and, most importantly, get all the state of the game objects.
    public IList GetAllObjectStates()
    - One of the most important features of the game.
    It is she who returns a snapshot of the world, according to which it can be completely drawn. Thus, the network system in its transmission thread simply periodically asks Game for a snapshot of the world in order to serialize it later, compress it and transmit it to clients.
    Interaction with a game object representing the player itself (for example, transferring to it the actions that came from the client) also occurs not directly, but through the IPlayerDescriptor interface:

     public interface IPlayerDescriptor
            void PerformAction(PlayerAction action);
            Tuple GetLookCoords();
            long GetId();
            double GetLoginTime();

    As a result, we get what was described above - a completely separate subsystem with game logic. Let us now consider how, in fact, it works inside.
    The “top layer” looks pretty standard Dictionary Dictionary thread safe -

    private readonly ConcurrentDictionary gameObjects;

    and a processing cycle that is called a given number of times per second and counts the time elapsed since the last miscalculation:

    private void ObjectProcessingTaskRoutine()
                if (currentMatchParameters.TimeLimit > 0 && Statistics.MatchTime > currentMatchParameters.TimeLimit)
    		            foreach(var gameObject in gameObjects)
                    if (!gameObject.Value.Destroyed())

    GameContext is a class, the link to the instance of which is passed to the constructor of all created game objects, it contains all the necessary information about the game, such as a link to the game map, a list of players, as well as methods for adding a new object to the game so that you do not need to pass the link on myself
    ConcurrentDictionary gameObjects;

    All fields of the GameContext are immutable, readonly, which eliminates the "damage" to the context of the game objects.

    But the implementation of game objects is more interesting. Initially, I intended to act quite trivially, creating a basic GameObject and inheriting from it specific implementations, such as Player, Bomb, etc. But this approach is not without a drawback. When there are many classes, and especially when there are many subclasses , such as “teleportable objects”, “objects with health” - all this architecture becomes cumbersome and inconvenient.
    The slightest change forces you to redraw it from the very beginning. So I turned to the experience of the creator of the game Dungeon Siege, which I learned from his presentation.
    In short, the essence is as follows: all game objects are only containers for game components and do not contain logic and data, except for the logic of messaging. Components are complete blocks containing the necessary logic and data for some fixed task. The interaction between objects occurs through the exchange of messages that are routed to the components.

    What does this give us? This provides a very, very flexible and convenient way to implement game logic. What the object will become in the game world is now determined only by the set of its components and their constructor parameters. Once you have implemented a component with some part of the logic, you can shove it into any game object without getting confused about how to decompose into classes, unlike the traditional approach.
    Moreover, now all the objects can be made of one class, GameObject, and the list of components can be downloaded from some config on the go.

    In this case, I have not yet taken advantage of the last point - I left the download from the config to “later” and did different classes of objects, but let it not bother you - this was done only so as not to load the configuration from external files yet, other differences between the classes Player, Bomb, Bullet, etc. does not exist, and as soon as my hands reach this, they will all be replaced by a single GameObject.
    Let's now take a closer look at how this is all implemented. So, at the heart of everything is the IGameObject interface:

    internal interface IGameObject
            void SendMessage(ComponentMessageBase msg);
            GameObjectState GetState();
            IComponent[] GetComponents();
            ushort GetId();
            GameObjectType GetGOType();
            IGameObject GetParent();
            void Dispose();
            bool Destroyed();

    A game object can only get such an interface of another object, which provides additional protection against misuse - a third-party object can neither add components to the object nor interact with them in any way, only check for the presence of one or another component, for which IComponent is present [ ] GetComponents ();

    The implementation of the interface, GameObject, contains the logic of messaging and receiving state (which is used when receiving a snapshot of the world):

    public void SendMessage(ComponentMessageBase msg)
            public GameObjectState GetState()
                var states = new List(components.Count);
                lock (lockObj)
                    if (Destroyed())
                        return null;
                    foreach (var component in components)
                        var state = component.GetState();
                        if (state != null)
                return new GameObjectState(Id, type, states);
    		public void Process(double quantValue)
                if (Destroyed())
                lock (lockObj)
                    SendMessage(new MsgTimeQuantPassed(this, quantValue)); //Автоматически добавляем сообщение о том, что истек квант времени
                    while (messageQueue.Count > 0)
                        ComponentMessageBase msg;
                        messageQueue.TryDequeue(out msg);
                        foreach (var component in components)

    A blocking object is necessary in order to guarantee the invariability of the state of the object at the time of the network request.

    Messages are added to the queue of
    private readonly ConcurrentQueue messageQueue;

     private readonly List components;
    contains, of course, all the components of the object.

    Game Architecture: Components

    The base class of a component contains, first of all, three main functions:

     public abstract void ProcessMessage(ComponentMessageBase msg);
            public virtual ComponentState GetState()
                 return null;
            public virtual bool Probe()
                return true;

    ProcessMessage must be necessarily redefined in the successor, since it is it that is responsible for the logic implemented by the component.
    GetState, the only function available through the IComponent interface to external systems, returns the public state of the object, if any - for example, health or coordinates. If the object does not have a public state, then you can not redefine it.
    The Probe function checks component dependencies. If the component does not depend on anything, then you can not touch it. If there is a dependency, such as, for example, the Collector component has a dependency on the Inventory component, then it must be checked in this function. It is used not only for checking, but also for caching links to the corresponding dependencies so as not to search them again each time.
    Now the above may look vague, but let's look at examples, and everything should be clear.
    How, for example, in such an architecture to make a bullet? A rocket?

    So, the first component implemented was SolidBody. This is the component responsible for the physical embodiment of the object in the game world - that is, for its coordinates, interaction with the map and dimensions.

    public SolidBody(GameObject owner, GameContext gameContext, double x, double y, double sizeX, double sizeY, byte angle = 0, bool semisolid = false)

    Like all components, Owner, the owner object and gameContext, the context of the game, from which, in this case, it takes a link to the GameMap, are transferred to the constructor.
    The rest is already specific for SolidBody - the coordinates of the object, its physical dimensions, rotation angle, as well as a pair of flags - to optimize calculations, objects that should not collide with each other are called semisolid - collisions are calculated only for solid-solid and solid-semisolid, two semisolid do not collide. These include, for example, bullets that cannot collide with each other.

    The bullet, of course, will be SolidBody, because has coordinates and dimensions. Moreover, it will be semisolid, since we do not need useless miscalculations of bullet collisions with each other, this must be remembered.

    I will omit the SolidBody implementation at the moment, as it is quite large.
    I only note that to speed up calculations, a list of objects that relate to it is attached to each tile of the map. When you move an object on the map, these lists are updated. Thus, when calculating collisions, we do not need to calculate the distances from each object to each, but rather first quickly select the tiles in the radius of interest to us and calculate the distance only for the objects that touch them.

    Next, we implement the component, which is the focus of logic for all bullets, missiles and other shells - it will be responsible for a simple, uniform, straightforward flight.

    internal class Projectile : Component
            private SolidBody solidBody; //Ссылка на зависимость, снаряд обязан иметь SolidBody!
            private readonly double speed; //Единственный параметр – скорость полета
            public Projectile(GameObject owner, double speed)
                : base(owner)
                this.speed = speed;
            public override void ProcessMessage(ComponentMessageBase msg)
    //Обработка интересующих нас сообщений – в данном случае, нам интересно только сообщение о прошедшем временнОм кванте.
                if (msg.MessageType == MessageTypes.TimeQuantPassed) 
                    ProcessTimeQuantPassed(msg as MsgTimeQuantPassed);
    //Вот и вся логика. Берем из зависимого СолидБоди текущие координаты, считаем их приращение исходя из того, сколько прошло времени, и говорим СолидБоди сдвинуться
            private void ProcessTimeQuantPassed(MsgTimeQuantPassed msg)
                double dT = msg.MillisecondsPassed;
                var solidState = (SolidBodyState)solidBody.GetState();
                byte angle = solidState.Angle;
                double dX = SpecMath.Cos(angle) * speed * dT,
                       dY = SpecMath.Sin(angle) * speed * dT;
    //Обратите внимание, такое возможно только в пределах одного объекта – помним об инкапсуляции и о том, что извне не получишь ссылку на сам компонент. Внешние объекты не могут напрямую подвинуть другие объекты – только послав им соответствующую мессагу.
                solidBody.AppendCoords(dX, dY, angle);
    //Проверяем компонентную зависимость, снаряд обязан иметь СолидБоди. GetOwnerComponent, как уже сказано выше, доступна только изнутри одного объекта. Извне нельзя получить компонент, только его интерфейс, через который можно лишь взять Стейт. Изнутри возможностей больше.
            public override bool Probe()
                solidBody = GetOwnerComponent();
                return solidBody != null;

    It should now become more clear. To make it completely clear, I will demonstrate another component, very simple, and without dependencies - DieOnTTL. As the name implies, the component is responsible for the death of the object after a specified period:

    internal class DieOnTTL : Component
            private readonly double ttl;
            private double lifetime;
            public DieOnTTL(GameObject owner, double ttl)
                : base(owner)
                this.ttl = ttl;
                lifetime = 0.0;
            public override void ProcessMessage(ComponentMessageBase msg)
                if (msg.MessageType == MessageTypes.TimeQuantPassed)
                    ProcessTimeQuantPassed(msg as MsgTimeQuantPassed);
            private void ProcessTimeQuantPassed(MsgTimeQuantPassed msg)
                var dT = msg.MillisecondsPassed;
                lifetime += dT;
                if (lifetime >= ttl)
    //Шлем владельцу компонента соответствующее сообщение
                    Owner.SendMessage(new MsgDeath(Owner));

    What, then, will our bullets look like? Very simple. Here, for example, a pistol bullet:

    class PistolBullet : GameObject
            public PistolBullet(GameContext context, IGameObject parent, double x, double y, byte angle, double speed, double ttl)
                : base(context, GameObjectType.PistolBullet, parent)
                    new SolidBody(this, context.GameMap, x, y, 9, 9, angle, true),
                    new DieOnTTL(this, ttl),
                    new Projectile(this, speed),
                    new DieOnCollide(this,new GameObjectType[]{GameObjectType.Player, GameObjectType.WildLouse}, true, new ushort[]{parent.GetId()}),
                    new DecayOnDeath(this)

    As I said, a separate class was created only because hands had not yet reached the load of the config from the outside. What does this code do? First of all, it transfers the game type to the base GameObject constructor - GameObjectType.PistolBullet
    This is the type that the client will draw on, it is checked by various components that are important for collisions with a bullet and similar interactions.
    All that remains is to add components that will make the bullet a bullet. In this case, the parameters are transferred externally to the constructor of the object itself, and from there to the component constructors. But no one bothers to hard-write them right here in the code, or load them together with a list of components from some XML.
    First of all, SolidBody, since a bullet is quite a physical object that has coordinates and collides with others. Do not forget to specify true in the semisolid parameter - we do not need extra calculations.
    Bullets should disappear after a certain flight time, even if they do not collide with anything. So we add the DieOnTTL object that we have already created with the lifetime parameter.
    The bullet should fly forward. We implemented this in Projectile, we add it with the corresponding speed.
    The bullet should die from a collision. Add DieOnCollide. I omitted it, but it’s quite trivial - SolidBody sends out MsgCollide messages, so we don’t need to re-implement anything, just check MsgCollide.CollidedObject for who we are all faced with. The parameters here indicate the collide with whom we can’t ignore, it says collide with the walls and the ID of the object is specified, which we need to ignore - the ID of the parent who created this bullet is passed there, so that the bullets do not hurt themselves.
    And finally, the last thing we need to do is somehow respond to the messages that we are dead - DecayOnDeath will just silently kill the object when it receives the MsgDeath message.
    Well, what if we want a rocket? Nothing is easier:

     class Rocket : GameObject
            public Rocket(GameContext context, IGameObject parrent, double x, double y, byte angle, double speed, double ttl, double radius)
                : base(context, GameObjectType.Rocket, parrent)
                    new SolidBody(this, context.GameMap, x, y, 15, 15, angle, true),
                    new DieOnTTL(this, ttl),
                    new Projectile(this, speed),
                    new DieOnCollide(this,new[]{GameObjectType.Player, GameObjectType.WildLouse}, true, new ushort[]{parrent.GetId()}),
                    new ExplodeOnDeath(this,context, radius)

    We do the same, except that the rocket we have is a little bigger than the bullet (SolidBody’s geometric dimensions are larger), and the rockets no longer collide with objects like WildLouse, which, as you probably noticed, were in the list of colliding objects in the DieOnCollide constructor by the bullet, and most importantly - the rocket does not quietly die at death, but explodes loudly, so instead of DecayOnDeath, we add ExplodeOnDeath with the radius parameter. All! We made a rocket on almost the same components - no rewriting of existing code. All we needed to do was make another component responsible for the explosion upon death (it behaves the same as DecayOnDeath, but creates a new object at the point of death, Explosion), which, no doubt, will be useful elsewhere.
    Yes, also do not forget to specify the appropriate type for the object - GameObjectType.Rocket.

    A teleporting bullet, for example, differs only in the presence of the Teleporter component:

                    new SolidBody(this, context.GameMap, x, y, 30, 30, angle,false, false, true),
                    new DieOnTTL(this, ttl),
                    new Projectile(this, speed),
                    new Teleporter(this, context),
                    new DieOnCollide(this, new GameObjectType[] {}, true, new ushort[] { parrent.GetId() }),
                    new PhaseOnDeath(this, context, radius)

    He is responsible for teleporting objects containing the Teleportable component. To make an object teleportable, simply add Teleportable to the list of components.
    The vast majority of components do not even require any additional inheritance, which makes the entire architecture very flexible and transparent.

    Here is a list of components used in the game:

    • AOE - responsible for the area of ​​effect exposure. An object MsgAOE with an Effect field is sent to objects within the range of action, which shows how strong the effect of the effect is (it is calculated depending on the distance to the epicenter, you can pass the effect profile to the component constructor to customize how the effect will change with distance).
      Used by an explosion, teleporting bullet.
    • BaseModifierManager - the component responsible for modifiers. That is, for things like “quadruple damage”, “acceleration” and other “buns” that appear on a player after finding an item and are valid for a certain time. One of the components where inheritance is required, since the basic version involves overriding functions:
      protected virtual void ProcessModifier(ItemType itemType, ModifierDescriptor modifier, double quantValue) 
      - for modifiers operating every quantum of time (health regeneration, poison)

      protected virtual void ModifierAcquired(ItemType itemType,  ModifierDescriptor modifier)
      - event occurring upon the acquisition of a modifier

      protected virtual void ModifierLost(ItemType itemType, ModifierDescriptor modifier)
      - event that occurs when the modifier is lost
    • BaseWeapon - responsible for weapons, also requires inheritance. The basic logic includes checking the availability of the corresponding item, which is a cartridge for weapons, checking the cooldown - this information is recorded in WeaponDescriptors, the processing of which this component is engaged. Pulling overridden override functions
      protected abstract void Shoot(WeaponDescriptor weaponDescriptor, SolidBody solidBody) 
      - with a successful shot, passing there the handle of the weapon from which they shot

      protected virtual void OutOfAmmo(WeaponDescriptor weaponDescriptor, SolidBody solidBody)
      - in the absence of cartridges

      protected virtual void Cooldowning(WeaponDescriptor weaponDescriptor)
      - when cooldown
    • Collectable - a component that sends MsgItemsAvailable message to the objects colliding with it with the Collector component, screaming “I'm here, pick me up”
    • Collector - a component that, upon hearing MsgItemsAvailable, takes items into inventory.
    • Various DecayOn ... and DieOn ... have already been described above.
    • Healthy – отвечает за наличие здоровья и нанесения дамага. Также шлет MsgDeath когда здоровье доходит до 0.
    • Inventory – компонент-инвентарь, отвечает за хранение списка айтемов с их количеством. Нужен всему, что юзает айтемы – Коллектору, Weapon и т.п.
    • Projectile – простой снаряд.
    • SolidBody – было описано выше, физическое воплощение – координаты, столкновения. Кроме того предоставляет статическую функцию FindPath для поиска пути на карте.
    • Walker – как Projectile, но для «разумных» объектов – движется в заданном направлении, которое задается мессагой MsgWalk. Останавливается если в MsgWalk в качестве направления указано Direction.Stop

    This is an almost complete list of the main components of the game that are not tied to implementation. This is part of the engine. I also implemented several components that are directly related to GoblinWars - this includes implementations of the BaseWeapon and BaseModidierManager descendants that implement specific processing, WildLouseLogic - the component responsible for the behavior of wild lice, etc.

    External systems, or rather, the network, interacts with the player, as already mentioned, with the same messages. To do this, the network yanks the corresponding PlayerDescriptor for the PerformAction () method;
    The implementation of this method is the usual creation of messages and sending them to PlayerRescriptor.PlayerObject.SendMeddage () depending on the required Action.


    Here, in principle, everything that forms the basis of ServerLogic.dll. In the next article I will talk about the network part of the server and client and its interaction with other subsystems.
    I’m not going to upload the full source code yet, but if anyone has any questions about implementation, I’m ready to answer. I will post the game for the test at the end of the last article, if anyone is interested.

    If there are artists who are ready, purely for their own pleasure, to redraw the graphics - I will be very grateful. All the graphics here are simple two-dimensional sprites, you need to draw a goblin in 8 directions, phosphorus louse in 8 directions and, most importantly, tiles, but now they are sorely lacking.
    Also, in principle, I’m interested in porting the client to other platforms, but I don’t want to do this alone. If anyone wants to try porting to a mobile system or to the web, on some HTML5 - this is also discussed.
    Thanks for attention.

    Also popular now: