📜 ⬆️ ⬇️

Technical diary: the second half year of the development of a new mobile PvP



I am the lead team in Pixonic, where I work for a year. About the start and development of one of our new projects, I previously wrote an article on Habré. In the course of further production, after another six months, I have accumulated a large number of interesting experience, which I wanted to share again. This time we will talk about the process of increasing the functionality in the mobile client and maintaining the code in a flexible state.

I am sure the vast majority at least once launched some multiplayer game. At the start, the client, as a rule, writes several magical messages and after a few seconds (although in the case of one well-known desktop shooter - a few minutes), the player enters the main menu, where there is a cherished button “To battle” or something like that. But the launch process consists of a huge number of stages that occur very quickly and without player intervention:
')

Moreover, this functionality is not written immediately completely, but gradually expands and is constantly improving throughout the life of the project. One of the difficult tasks of a designer is to prevent such development of the code in which he loses such good properties as loose coupling (loose coupling) and re-usability .

But it often happens that a code module that looks concise and versatile turns into a monster because of any one little nuance in the TK. In a word: in no case should you allow macaroni in the code - they will not be untangled quickly if changes occur .

This design task fell to me and, after a year of development, I will talk about the logic that guided the design of the game client modules (then all the material goes in chronological order as you add or change functionality).

For some, this material may seem just another interpretation of the SOLID principles, but examples from real and large-scale practice only help to consolidate and improve their understanding.

Each time describing the application module, I will add it to the diagram of links. In the diagrams, the modules will be linked by arrows, which mean sole possession and use of one module by another. The slave module has no user information. Following this rule, your architecture will always look like a tree. In my opinion, the tree is the symbol of a flexible code and its correct extension.

But before continuing, I must again make a reservation:


So, let's start with the lowest level, the direct interaction between the game server and the mobile client.

Transport layer



In any multiplayer game there are integrated or independently written data transports - some network protocols that take responsibility for the delivery, integrity, opposition to duplication and incorrect sequence of transmitted data.

In our new project, I decided from the very beginning to abstract their implementations in order to make the API universal and synchronous, as well as for the additional possibility of substitution of implementations. First of all - the protocol of high-frequency delivery in the gameplay process.

We use the Low Level Photon Network to transfer data from the game server to the client and back directly during the game with high frequency. Creating an abstraction in the code looked like this:

public interface INetworkPeer { PeerStatus PeerStatus { get; } //  int Ping { get; } IQueue<byte[]> ReciveQueue { get; } //   void Connect(); void Send(byte[], bool sendReliable); void Update(); //    ,  Service  PhotonPeer void Disconnect(); } public enum PeerStatus { Disconnected = 0, Connecting = 1, Connected = 2, } 

"Thread safety" or, if you like - "flow-evident", should be read on the interface. As you can see, the INetworkPeer interface API is synchronous, the Update method hints that some of the work will be performed in the context of the caller’s execution.

What did it give us

While working on the simulation code, the fastest way to work with the new functionality is not to deploy a local server with modified code on your work computer at all. We had the opportunity to write a second implementation for this interface, inside which the code from the common submodule was already used - so the client becomes the server itself.

A little later, we used this substitution to create a local simulation with modified rules (this is how the training system works in the client now). This mode does not load the server unnecessarily and does not require an Internet player in the early stages, and this, in turn, improves the passing funnel.

We are experimenting with other implementations of transports and changing them if necessary. The main reasons are the optimization of work with memory and system calls to increase the capacity and performance of servers.

Deserialization stream



The next task is to transform the arrays of the received bytes into data transfer objects (type GameClientMessage). I hid these responsibilities behind this interface (note that this interface is not tied to the implementation of INetworkPeer):

 public interface INetworkPeerService { float Ping { get; } NetworkServiceStatus Status { get; } //     void Connect(INetworkPeer peer); void SendMessage(GameClientMessage message, bool sendReliable); //   DTO ,   . void Disconnect(); bool HasMessage(); // ,    . GameClientMessage DequeueMessage(); //    } public enum NetworkServiceStatus { Disconnected = 0, Connecting = 1, Connected = 2, } 

Please note that INetworkPeerService is aware of the INetworkPeer type and uses it in the Connect method, while the implementations of INetworkPeer, at the same time, know nothing about INetworkPeerService.

What did it give us

Inside this abstraction, you can encapsulate and safely develop the functionality associated with the serialization of messages. In our case, under the hood is the composition of the following responsibilities:


The last point is very important, since your frame rate should not depend on the number of messages received per frame. We are also protected from the spontaneous laboriousness of the operation to expand the pool of objects.

Network model, its state and handshake procedure



When connecting to the game, it is not enough just to establish a connection. The game server must understand: who you are, why you are connected and what to do with you. And on the client the sequence of states should change:

  1. Initiate a connection to the server.
  2. Wait for a successful connection or give an error.
  3. Send information about intentions (who I am and what game I was sent by the player selection service).
  4. Wait for a positive response from the server and get a session identifier from it.
  5. Start work related to the game, send input and provide access to the data.
  6. In case of disconnection from the server, accept the required state.

In my opinion, the State design pattern obviously begs here. As can be seen from the example below, this machine is closed from the user and is able to make decisions in its area of ​​responsibility:

 public interface IGameplayNetworkModel { NetworkState NetworkState { get; } //     int SessionId { get; } //       IQueue<GameState> GameStates { get; } //    float Ping { get; } void ProcessNetwork(TimeData timeData); //Update ,  , Service void ConnectToServer(INetworkPeer peer, string roomId, string playerId); //INetworPeer    INetworkPeerService.Connect(peer). void SendInput(IEnumerable<InputFrame> input); void ExitGameplay(); } 

In the implementation of the IGameplayNetworkModel interface, the constructor looks like this:

 public GameplayNetworkModel(INetworkPeerService networkPeerService) 

This is a classic injection through the designer of the lower level entity into the essence of the upper level. INetworkPeerService does not know anything about GameplayNetworkModel or even IGameplayNetworkModel. Both NetworkPeerService and GameplayNetworkModel are created once for an application and exist for the entire duration of the client operation. A higher level user who will use IGameplayNetworkModel for work should not know anything about entities that are hidden from him - such as INetworkPeerService and even lower.

What did it give us

The most important thing is that the user of this interface will be protected from all the details of working with network states. What is the difference, why you can’t send the input, get the latest data about the game and have to show the connection loss window? The main thing is to trust the implementation.

By itself, a state pattern is a very powerful tool for hiding functionality. It is very easy to add new states to a discharged execution chain with more complex requirements. I will mention this pattern again and again in the following examples.

Model match game. Encapsulation of interpolation and game data storage



When in Unity, via the Update () call, your code gets execution control, in online games you usually need to do 3 things (simplified):

  1. Collect the input to be sent to the server (if there is one and if the state of the network allows).
  2. Update network status and accept what has come and is ready to be processed for this frame.
  3. Pick up data and start its visualization.

But fighting for the smoothness of the picture in the conditions of a poor mobile connection and non-guaranteed delivery, you need to additionally implement the following functionalities:


In our case, this is encapsulated behind the gameplay model interface:

 public interface IGameplayModel : IDisposable { int PlayerSessionId { get; } //      ICurrentState CurrentState { get; } //      ,   . void SetCurrentInputTo(InputData input); //    . void ConnectToGame(string roomId, string playerName, string playerId, INetworkPeer networkPeer); //    void ExitGamePlay(); //  void UpdateFrame(TimeData timeData); //     . } 

In the implementation of the UpdateFrame method, IGameplayNetworkModel.ProcessNetwork (timeData) is called at the required moment. The implementation constructor looks like this:

 public GameplayModel(IGameplayNetworkModel gameplayNetworkModel) 

What did it give us

This is already a full-fledged network client model for our game. Theoretically, nothing more is needed to play. Good practice is to write a separate user implementation of this abstraction as a console application. The dotTrace and dotMemory tools came to our rescue, they are much clearer than the Unity profiler, and can additionally tell you what the problems are.

In the process, we wrote several implementations of this interface, which gave us very cheap useful functionality:


Artifacts
From a certain point on, graphic artifacts began to appear. Characters and objects began to move with minor jerks and this was reproduced only on Android assemblies. We had to go through everything - from the synchronization of time with the server to the interpolator formulas. But in the end it turned out that the bug began to be reproduced after the transition from Unity version 2017.1.1p1 to 2017.4.1f1. After research and communication with support, it turned out that there is a bug in the calculation of the Time.deltaTime engine - the time deltas do not correspond to the physical flow of time (they promised to fix it in Unity 2018.2). Due to the fact that we do not use Time.deltaTime directly in the code, we send TimeData through the Update tree, we easily edited it like this: we got Stopwatch at the very beginning of the code tree and used Stopwatch.Elapsed and counted the deltas manually, correcting only with Time. timeScale.

General application model. Encapsulating the launch of the application and reconnect to the game



At one point, our team came up with the task of reconnecting the player to the game if he somehow disappeared from the battlefield. If everything is clear with the Internet turned off, in case of the application shutting down and its subsequent restarting, it didn’t immediately become clear how this feature should work. It was not enough to expand the state collection of IGameplayModel, since its interface clearly indicates control over the work from the outside.

The solution was: to create a state machine of a higher level, which would monitor the state of the gameplay model and re-connect when necessary. In addition, at the start, the initial states of this machine should check the records of unfinished games and, if there are any, try to connect to the game to continue. And in the final case, if such a game no longer exists on the server, then return to the standard state of readiness.

List of states:


The interface of this highest level model at the time looked like this:

 public interface IAppModel : IDisposable { ApplicationState ApplicationState { get; } //    .             . GameplayData GamePlayData { get; } //      void StartGamePlay(GameplayStartParameters parameters); void PlayReplay(Replay replay); void RefreshRoomsList(string serverAddress); // ,      void ExitGamePlay(); void SetLastGamePlayInput(Vector2 input, ISafeList<SkillInputData> skillButtonStates); //       . void SelectHero(int dropTeamMemberId, bool isConfirmed); //     void Update(TimeData timeData); // ,    . } 

What did it give us

This gave us not only an elegant solution to reconnecting between sessions, but also an extension tool for the initialization stages. Somehow I will show how we used this tool to the fullest.

Preliminary results


By separating responsibilities and encapsulating responsibilities, our application combines many functions. All components are interchangeable and some changes have little effect on others. Dependencies can be displayed on a graph as a chain of links from wider elements to more specialized ones.

In practice, this design gives very good indicators of support and code variability. For us (taking into account all deadlines, short deadlines and ordinary everyday fabrication / change of features), code changes are lightweight, and the tasks on refactoring are not calculated in weeks.

By the way, you may have noticed that I completely did not touch on the topic of interaction with the second server:


This set of client responsibilities is also embedded in the dependency tree at the application model level and forms a separate large branch of types and relationships. But more about that next time.

Source: https://habr.com/ru/post/354336/


All Articles