📜 ⬆️ ⬇️

The story of creating a classic RTS at home from scratch (Part 2: "Resurrection") End of article: Network


About a year ago my article came out, which can be called the " first part " of this article. In the first part, as far as I could, I sorted out in detail the thorny path of an enthusiastic developer who I was once able to independently walk from beginning to end. The result of these efforts was the game of the RTS genre "The Land of Onimods " that I created at home without engines, designers and other modern development tools. For the project, C ++ and Assembler were used , well, and my own head was the main tool.

In this article I will try to talk about how I decided to take on the role of "resuscitator" and try to "resurrect" this project. A lot of attention will be paid to writing your own game server.

This is the end of the article, the beginning is here .

→ Beginning of the article: Resurrection of the game
→ Continued article: GUI
')

Network


I do not think that without analyzing the attached example, it will be possible in the subtleties to understand what I will try to talk about. However, the general thoughts on this matter, I hope, I will be able to convey in any case. I also have to separately note that I come up with such solutions on my own, so there is always the risk that some of my solution to the problem will be suboptimal. I studied the library for the network libuv mainly by the method of “scientific spear”, and I also fully admit that people who are engaged in network programming all the time will be able to correct some of my interpretations. And now, perhaps, I will return to the topic of the article ...

At first, I honestly wanted to use the WinSock library for networking. However, I quickly changed my mind, as it was obvious that in this way I would again be completely tied to Windows , which I really did not want. Therefore, I rummaged through the Internet and found an interesting solution called libuv . This library is free. Works on Windows , Unix , Mac OS and Android . And also does not pull modern innovations, like the requirement to use the latest standard of the C ++ language. And in general, it is written in pure C, which I consider to be an additional benefit for the developer, since my eternally lifesaving principle sounds like: “the simpler the better.”

At first I had 2 problems:

  1. I never wrote servers from scratch, so I had to come up with the general structure of the program.

  2. Documentation on libuv , in my opinion, leaves much to be desired. In general, I always wondered how you could make quite a decent product and be too lazy to more or less describe its capabilities. But, unfortunately, almost all developers suffer from this. The last stage of laziness in this area is the use of automatic generation of documentation using the Doxygen utility (and others like it), which turns comments into code in the help, and also automatically generates connections between classes and structures. I’m probably behind the times, but I don’t see a better way to harm my own library than automatically generating a “trash bin” for it from various structures and diagrams, where it is often not clear where to start.

The second problem I had was partially compensated by the fact that I also never used WinSock and, accordingly, it was not so important to me how to deal with WinSock or libuv . But since libuv promised me several platforms without having to rewrite anything, he clearly won in my eyes.

Despite the fact that now game developers often remove the possibility of playing on a local network, I decided that I would keep this version of the network game too. In the first part of the article, I personally explained that to play RTS over a local network, no server is needed at all, since the game runs on each computer independently and no coordinating center is required. And indeed it is. However, in my case, I wanted to get two options for a network game: via the Internet and through a local network. If I began to use the peer-to-peer way of interaction between computers for a local network, such a solution would create a second branch of code. Would such a solution work faster? Probably yes, but hardly a player would be able to sense the difference, since in LAN, data usually quickly reaches the target.

As a result, I logically reasoned that I would also use a server for the local network.

A bit of theory about addresses and ports.


As many probably know, every computer in the world included in the Internet should be assigned a unique address (unless, of course, NAT or something similar is used). This address is called IP. An IP address consists of 4 digits and, in the “human form”, looks like this: 234.123.34.18

At these addresses computers and find each other. However, usually many programs are running on the same computer at the same time, so there is an additional concept of “port”. Programs can discover these ports and establish interaction through them. To make it clearer ... IP-address is like: Russia, Buhalovskaya oblast, village “Bolshaya golodukhino”, ul. "New Russian descent", d. 18, and "port" is the apartment number where the money is in which a specific person lives. Without an apartment (port) number it is impossible to deliver a “letter” to a specific person (program), therefore the concept of “port” is very important. Usually the port is written after the IP-address separated by a colon, for example: 234.123.34.18:57

The delivery of messages between computers is carried out by special programs called protocols. The most famous protocol that holds the entire Internet is TCP . There is another very important protocol called UDP , but it is somewhat more difficult to use.

Briefly explain the difference for those who are not very familiar with this topic.

UDP allows you to transfer data in chunks. This piece of data is called a datagram . The datagram can be sent to the specified IP address and port. But ... the UDP protocol “promises nothing to you”, i.e. The sent datagram can easily get lost on the way, and in this case, the recipient simply does not receive anything. And if several datagrams were sent, they may come in a different order or some may not come, i.e. Any options are possible here and this is a perfectly legal behavior for UDP. The only thing that UDP guarantees is the fact that if the datagram has arrived, it has come completely, i.e. “half” datagram cannot come.

TCP works in a completely different way. He does not send any incomprehensible datagrams to the unknown network space to the mercy of fate - he first establishes a channel of communication with the recipient and clearly sends data through this channel. TCP ensures that all transmitted data reaches the recipient, and reach exactly in the order in which they were sent. The data itself does not come in the form of fixed-length datagrams, but in the form of a stream of bytes (a good analogy is byte-writing data to a file). Note that with this approach, TCP can divide the sent messages into parts as it pleases, i.e. The recipient may first receive only a portion of the sent data, and after a while the remaining half will arrive.

For communication between the program and the port, so-called sockets are used. The socket is connected to the port and then all the interaction with the port occurs through this socket. The existence of a socket without a port does not make much sense, since the socket is not involved in anything other than interacting with the port.

Protocol selection


At the initial stage, I needed to choose a protocol for interaction. If you do not write your own solution, then there are only two options: TCP and UDP . UDP is good where you can not allow delays in the game process. Usually, the game itself is performed on the server, and clients only receive data from the server about changes in the game situation. Such an approach allows literally “not paying attention” to a player with whom there is a bad connection. All other players will continue to play quite comfortably, as the server does not stop the game to wait for the lagging behind. An example of such a game is Counter Strike .

In the case of RTS, this approach is often not suitable, since the game process requires a large amount of computation and therefore runs on each computer, and these computers must do everything the same. Therefore, each computer from each other computer must constantly receive a list of actions performed by the player in one "network tact". If the list is late, you will have to wait until it is received. Those. even if you use UDP , you will have to control the delivery of messages yourself. Therefore, TCP was selected.

As I heard, Starcraft2, despite the fact that it belongs to the genre of RTS , still works on the server. It is possible that modern iron has reached such a level as to use this method. But in my case, the network is made in a “classic” way for the RTS .

What does the server do?


In fact, he does almost nothing until he receives any message from the client. In the case of TCP , the server’s task is to open the port and listen to it. If a request comes to this port from a client asking to establish a CONNECT connection, then the server should perform ACCEPT , which opens a new random port and informs the client. The client, having received a random port from the server, opens its own random port and a connection is established on these ports between the server and the client. The connection will exist until one of the parties wishes to close it, or until the connection is broken for technical reasons. The server establishes one connection with each client. All data that clients send to each other, passes through the server, but never directly from client to client, as in the case of a peer-to-peer network.

My server has the following tasks:


What does the client do?


On the client, the game itself takes place. The client knows that he is not alone in the game, and it is necessary to send messages to the other participants about the player’s actions and receive the same messages.


Differences between the Internet server and the LAN server


How paradoxical it sounds, but the local network server is somewhat more complex than the Internet server. Why is that?

Please note that the Internet server runs as a separate program, which is running somewhere somewhere and should ideally work forever. The local server is created for a time on a computer that acts as a host. Such a server does not exist as a separate program, but only as a separate stream that has access to any data of the main application, which entails small packet problems that can be treated by synchronization of flows. And “multithreading” is generally a separate debugging section that can seriously and permanently spoil the life of any developer.

Further, note that the principle of connecting the client to the Internet server and the local server is completely different. In order for the client to connect to the Internet server, it must know its IP address and port. And this data should indicate the player in any text field. And in the local network, existing game sessions should be detected without specifying any IP addresses. Such an action is achieved through a so-called “broadcast request”, whose IP is 255.255.255.255. This address means “the entire local network at once,” but for TCP, this will not work, since this is a pure UDP feature. Why is TCP unable to work with a broadcast address? Well, as I explained above, TCP must establish a communication channel and communicate strictly one by one. And here it is necessary to “hail” the entire local network according to the principle “hey, is there anyone here? Call out! " Well, those who "are" should respond by transferring information about the game session to the applicant. Therefore, on the local server will have to use more and UDP .

Well, for a sweet ... Imagine that during the game the host on which the local server is running, suddenly lost or just left the game for some reason. What will happen to you? And the same thing happens in Diablo2 , when the one who created the game decided to leave the game session before the others. In Diablo2 , the rest of the unfortunates on a black screen get a message in the “host is no longer available” style, and, in fact, everything ... the players are simply thrown out of the game. The reason for this behavior is that leaving the session, the host also closes its local server, and after all all the players are attached to it. To combat such ugly behavior in DirectPlay , there once existed a beautiful thing called “host migration”. In a nutshell, it looks like this ... when information about his sudden demise comes from the local server, the remaining clients decide to start a new server and reconnect to it again. You can run a local server on the client, which is the very first in the list of players in the session. Next, the server must wait until everyone who was previously in the game can connect to it, well, and if everything is in order, the game will continue.

The Internet server, however, also has its own characteristics. The main feature is that there is always a danger that the server will crash or freeze due to a developer or OS error. In this case, all players who are currently in the game will be very annoyed. But with this situation it is hardly possible to do something, except to try to identify as many errors as possible before the release. But, nevertheless, in any complex program errors remain, and the question here is only in the probability of their occurrence.

But even suppose that the server suddenly took and "died", and the players were disconnected. After the developer stops cursing for his “curved hands”, the player usually tries to reconnect to the server. But ... if the server is "dead", then it will not work until someone restarts it, and if nobody watches the server, this can happen very soon. As a result, the player's anger can begin to spontaneously increase, which can lead to serious psychological consequences. He will begin to subside badly, tests will deteriorate sharply and, practically, a person will be close to a nervous breakdown. Personally, I would not want to take on such responsibility, so I decided to insure in advance.

The Internet server should not work independently - there should be a controller who will start the server and try to monitor its work. This requires periodically sending a signal to the server and receiving a response. If the signal remains unanswered, this means that the server "crashes." In this case, you must first kill the server process permanently, and then restart the server. This approach should somehow protect the server, although I do not know how effective it will be in practice, but I have no doubt that this is a move in the right direction.

In addition, the controller program can perform some additional actions. For example, you can instruct her to upgrade the server. In my case, it looks like this:

1) For example, I decided to fix something on the server and assembled a new version of the server, but if someone is interested in the game, then someone constantly plays on the server. Restarting the server will result in players being thrown out of the game, which is always fraught with emotional experiences.

2) Knowing this, I teach the server and the monitoring utility to update the server in “soft mode”, which is performed as follows. Through the administration utility, the new server is transferred as a regular file, which gets to the computer where the main server is running. After that, the main server closes its listening port, but does not shut down, that is, in practice, new players will not be able to join anymore, and those who are “already here” will continue to play quietly. Further, this server will periodically check for a situation when all players disconnect from it, and then it will quietly complete the work. At this time, the controller program will detect the sent file of the new server and launch it for execution in parallel with the old server. Since the listening port from the old server will already be released, the new server will start listening to it, which will connect all new players to it. After some time, the old server will turn itself off, and only one new version of the server will remain in operation.

All this sounds pretty good, but, unfortunately, at the time of this writing, I could not check the server under a good real load. This is the typical problem of developing projects with enthusiasm, when there is no team of testers who are paid to simply play and report problems found. But common sense tells me that I am doing everything in the right direction, so I have included my thoughts on this subject in the article.

Libuv library functions


In this section, I will try to briefly describe the functions of the libuv library that I had to deal with during the development of network interaction. At a minimum, this information can be a useful reference, including for me, since good memory is not my strong quality.

The libuv interaction with the user is entirely based on the use of callback functions or callback functions . How it works? For example, a user wants to send a message via TCP . For this, the uv_write () function is used, which, of course, takes as parameters “what to send” and “through which socket”. But besides this, you must also specify the address of the user-defined function, which will be called when the sending is successfully completed. It is these functions that allow you to control the events. The same applies to receiving messages, only for this purpose the function uv_read_start () is used , which also indicates the user function that will be called after receiving the next piece of data.

The most important function is the uv_run () function , which, in essence, is something like a message processing loop in Windows . The library makes all calls to callback functions only inside uv_run () . This means that even if libuv itself received some message over the network, the user will never know about it until uv_run () is called.

The function uv_run () is declared as follows:

int uv_run(uv_loop_t* loop, uv_run_mode mode); 

The first parameter, uv_loop_t * loop, is a pointer to a structure that libuv uses for some of its personal needs. You need to create this variable once and never touch it again. You can create it, for example, like this:

 uv_loop_t loop; memset(&loop, 0, sizeof(loop)); uv_loop_init(&loop); 

This is all that concerns the loop parameter and there is no special need to know how it is used inside libuv . But ... there is one important nuance. If you have a multi-threaded program, then for each thread you need to use its own loop . In my case, I create one loop for the game itself and another for the local server, which runs on another thread. And accordingly, I have my own uv_run () on each thread.

The second parameter, uv_run_mode mode, determines the mode in which the uv_run () function will operate. For the server, use the value UV_RUN_DEFAULT , and for the client, use UV_RUN_NOWAIT . Let's try to figure out why.

The UV_RUN_DEFAULT parameter causes the uv_run () function to execute as long as there is at least some work for it. And such work, for example, is the task of listening to the port. Those. if a socket is first created that listens to a port, then uv_run () will never complete until that socket exists. And this is the main task of the server - to wait for the connection from the client and install it. Therefore, the option with UV_RUN_DEFAULT is very correct for the server, and the line:

 uv_run(&loop, UV_RUN_DEFAULT) 

Often it is the last line in the program, because when you exit this cycle, the server simply quits.

The exit from the uv_run function (& loop, UV_RUN_DEFAULT) will occur independently when the user destroys the listening socket.

To exit the uv_run function (& loop, UV_RUN_DEFAULT) , the uv_stop () function is used , which takes the same loop as a parameter. After such a call, uv_run () will exit, but return an error, which means that it was interrupted too soon and she still has something to do. By the way, no one bothers to call uv_run () in this case again.

The UV_RUN_NOWAIT parameter causes the uv_run () function to deal only with the events that have occurred so far. That is, if any network messages were received, then callback functions will be called for them. After that, the uv_run () function will be completed. This behavior is well suited for the client, since the client is also involved in the game itself, in addition to exchanging network messages. In my case, I call uv_run (& loop, UV_RUN_NOWAIT) once at the beginning of the game clock and once at the end (the clock frequency is about 60 Hz). This is done so that it is possible to process the received messages before the start of the clock, and after the end of the clock, immediately send your own.

As mentioned above, the TCP protocol requires a mandatory connection. A connection request is always sent by the client to a specific IP address and port of the server. To do this, use the uv_tcp_connect () function.

The function uv_tcp_connect () is declared as follows:

 int uv_tcp_connect(uv_connect_t* req, uv_tcp_t* handle, const sockaddr* addr, uv_connect_cb cb); 

The first parameter uv_connect_t * req is a pointer to some kind of structure, which, apparently, libuv is very necessary for something . The task of the user is simply to create this structure and pass it to the function. Creating a structure is more than easy:

 uv_connect_t connect_data; 

Just in case, I write zeros into it, but it seems that this is not necessary:

 memset(&connect_data, 0, sizeof(connect_data)); 

Also note that this variable must continue to exist after calling uv_tcp_connect () , since its address is used in the callback function, so it is easier to make it global.

The second parameter, uv_tcp_t * handle, is a TCP socket that must be created in advance, but not bound to any port. Creating a TCP socket is done with the uv_tcp_init () function, which will be discussed a bit later.

The third parameter const sockaddr * addr is the IP address and port of the server from which the connection is requested. Libuv has a function uv_ip4_addr () , which helps to fill this structure with data.

 sockaddr_in dest; uv_ip4_addr("234.123.34.18", 57, &dest); 

The fourth parameter, uv_connect_cb cb , is the custom callback function. And it is in this function that the user can determine whether a connection has been established and somehow respond to this fact.

In my case, the callback function looks like this:

 void OnConnect(uv_connect_t* req, int status) { if (status==0) { //   ... ... ... } else { //    ... ... ... } } 

Creating sockets.


The TCP socket is described by the uv_tcp_t structure. First you need to allocate memory for this structure:

 uv_tcp_t* tcp_socket=malloc(sizeof(uv_tcp_t)); 

Those interested can clear the allocated memory with zeroes, although this is an optional operation:

 memset(tcp_socket, 0, sizeof(uv_tcp_t)); 

Then you can create the socket itself:

 uv_tcp_init(&loop, tcp_socket); 

here the loop is the same long-suffering loop that is served in uv_run () .

And now a very important moment for game writers:

 uv_tcp_nodelay(tcp_socket, true); 

I will try to explain what this setting. The fact is that TCP considers itself to be a very smart protocol and it knows that sending data in small portions is unprofitable, since the minimum transmitted data size will in any case be equal to the size of the packet. In other words, if you send 1 byte, the result will still be sent a whole packet, in which only 1 byte of the payload will be sent, and the rest will be garbage. Therefore, by default, smart TCP will wait 200 milliseconds for the user to add more data to send, to send everything at once. This mechanism with the expectation is called the " Nagle " algorithm and is not suitable for games at all. Therefore, this setting seems to say the TCP protocol - “listen, dear, let's not get smart, but send the data immediately.” In practice, this setting prohibits the TCP protocol from using the " Nagle " algorithm .

Now, if this is a server, then the main socket must be immediately bound to the port:

 sockaddr_in address; uv_ip4_addr("0.0.0.0",   , &address); uv_tcp_bind(tcp_socket, (const struct sockaddr*)&address, 0); 

In this case, the address is “0.0.0.0”, which means that the socket will be tied to all network adapters that are present on the computer, and not just to any one. PORT NUMBER SERVER should be selected independently and the main thing here is its uniqueness so as not to create conflict with other programs.

Next, the server should enable port listening for the socket:

 uv_listen((uv_stream_t*)tcp_socket, 1024, OnAccept); 

The first parameter tcp_socket is, of course, the socket itself, but the second one is much more specific. This is the maximum number of connection requests that can wait for their turn. Imagine that you have a super-popular server and players are eager to play on it, i.e. There are a lot of connection requests from clients. If the server does not have time to answer them, then it puts them in a queue. And this number 1024 in this case is the maximum size of this queue. Those who do not fit in the queue, the server will respond with three words.

The third OnAccept parameter is a callback function that will be called when a CONNECT connection request is received from any client, for which the client uses the uv_tcp_connect () function.

The OnAccept () function might look something like this:

 void OnAccept(uv_stream_t *server, int status) { if (status<0) { //    return; } //    ,        uv_tcp_t* tcp_socket=malloc(sizeof(uv_tcp_t)); memset(tcp_socket, 0, sizeof(uv_tcp_t)); uv_tcp_init(&loop, tcp_socket); if (uv_accept(server, (uv_stream_t*)tcp_socket)==0) //     { //   uv_read_start((uv_stream_t*)tcp_socket, OnAllocBuffer, OnReadTCP); //      } else { //     uv_close((uv_handle_t*) tcp_socket, OnCloseSocket); //  ,      } } 

Let's take a closer look at the interiors of the OnAccept () function.

  1. First, a new socket is created, which will be used to connect to the client from whom the request to establish a connection came. To establish the connection, the client uses uv_tcp_connect () .

  2. Called uv_accept (), which sets up the connection between the server and the client. The uv_accept () function calls on the client the triggering of the call-back function specified in uv_tcp_connect () as the last parameter. In the example above, this was the OnConnect () function.

  3. If the connection is successfully established, the server should allow the created socket to read data from this connection. The uv_read_start () function enables data reading for the newly created tcp_socket socket. Please note that “reading data” and “listening to a socket” are different operations. “Reading data” is literally “reading” by analogy with byte reading from a file, and “listening to a socket” is waiting for a CONNECT request to establish a connection.

    The uv_read_start () function uses as many as two callback functions:

    - OnAllocBuffer () is called before reading the data and asks the user to specify the memory for receiving data.

    The function itself is defined as:

     void OnAllocBuffer(uv_handle_t* handle, size_t suggested_size, uv_buf_t* buf) 

    The first parameter uv_handle_t * handle is a pointer to a socket that requires memory.

    The second parameter size_t suggested_size is the required buffer size.

    The third parameter, uv_buf_t * buf , is the structure through which the user returns the buffer information (size and address) to the libuv library.

     buf->len=; buf->base=; 

    In short, the buffer itself must be created by the user and deleted also by the user. And libuv will only accept data into this buffer. The same buffer can be used for multiple sockets.

    In my case, for some reason, libuv always requested a buffer of 65536 bytes in size. As for me, it's a bit strange, but since I allocate this memory 1 time, it seems that there is nothing wrong with that.

    - OnReadTCP () is called after OnAllocBuffer () to transfer to the user the data that the socket has received into the buffer.

    The function itself is defined as:

     void OnReadTCP(uv_stream_t* stream, ssize_t nread, const uv_buf_t* buf) 

    The first parameter uv_handle_t * handle is a pointer to the socket that received the data.

    The second parameter ssize_t nread is the number of bytes received. If nread is less than or equal to zero, then this is a sign that the connection has been broken. Those. this is not a data reading, but information about a communication breakdown, which may occur due to the fact that the other party has deleted a socket related to this connection or for hardware reasons such as cable breakage. This situation must be monitored and responded to accordingly.

    The third parameter const uv_buf_t * buf - contains the address of the buffer with the read data. This will be the same address that the user specified in the OnAllocBuffer () function.

    The most important moment when reading data. As I wrote earlier, TCP can deliver a sent message in parts, for example, if the sender sent the phrase “Hello,” and then the phrase “Server”, it is not necessary that when receiving these two messages, the information will look exactly the same and as sent. First, both messages can stick together in one and then the receiving party will receive one integral message “Hello Server”, or maybe even this: first “At”, then “slow, Ser”, and then “ver”. Those. the message in theory can be fragmented into any number of parts. Therefore, every message transmitted over the network should always have a header that will allow to separate one message from another in a stream of bytes arriving on a socket. From all this follows a very unpleasant feature of reading data from a TCP socket . Practically, each such socket should have its own buffer, where it will add incoming bytes of the next message, because if the message is received only partially, then you have to wait until it is received completely and then you can somehow react to it.

  4. If the connection could not be established, then the socket intended for it should be removed. To do this, use the uv_close () function. But ... pay attention that this function also takes the OnCloseSocket () call-back function as the last parameter. And just at the moment of calling this function, libuv informs the user that the socket can now be physically removed from memory.

     void OnCloseSocket(uv_handle_t* handle) { free(handle); //     malloc(),      free() } 

Sending messages


To send a message through a socket, use the uv_write () function.

 int uv_write(uv_write_t* req, uv_stream_t* handle, const uv_buf_t bufs[], unsigned int nbufs, uv_write_cb cb); 

The first parameter uv_write_t * req is some kind of variable that libuv needs to transfer data. You shouldn’t understand the meaning of its presence in the function parameters, but you need to create it, for example, like this:

 uv_write_t write_data; 

Now this variable can be used repeatedly to send messages sequentially, but you cannot use it at the same time to send multiple messages.

The second parameter of uv_stream_t * handle is the sending socket.

The third parameter const uv_buf_t bufs [] is an array of messages to send. It consists of uv_buf_t elements that have len and base fields, which, respectively, must contain SIZE and ADDRESS .

The fourth parameter unsigned int nbufs is the number of elements in the message array. In my case, I always used only one message to send.

The fifth parameter, uv_write_cb cb , is a callback function that is called when a message is sent. Why is it even needed? The fact is that the messages that the user sends should be kept in memory until they were sent. Those. when this callback function is triggered, it means that the data buffer that contained the message being sent is now no longer needed by libuv . And now this buffer again goes under the control of the user and it can be filled with new data and send a new message.

In my case, I put both the data buffer and the incomprehensible, but uv_write_t write_data, in one structure. Therefore, they work in pairs as part of the same structure.

Moving from libuv structures to more convenient data types


Imagine that you have a set of uv_tcp_t sockets that accept messages. As I said a little higher, due to the fact that data is read according to the flow principle and messages can be arbitrarily divided into parts, we will additionally need a buffer for each socket to store and analyze incoming data. And now let's take another look at the OnReadTCP () callback function:

 void OnReadTCP(uv_stream_t* stream, ssize_t nread, const uv_buf_t* buf) 

and note that it shows the socket that received the data through the parameter uv_stream_t * stream . But ... how do you bind the socket to the desired data buffer? Suppose you have 1000 sockets and everyone accepts something there. You need to put received data for each socket in its own buffer. But after all, its buffer is not determined by the socket in any way - inside the structure of the socket there will be a set of some nonsense without any indication of which one of the thousands of available buffers it corresponds to.

So the principle of networking should be completely different, and now I will try to describe how to solve this issue.

For now , let's temporarily forget about the existence of libuv and try to create a common server structure.

Suppose we have a server class called GNetServer . An object of this class always exists in a single instance and completely assumes the functions of a server. In my particular case, the server should have two main arrays:


Close connections should be established between these two arrays, i.e. any player must know which session he belongs to (if he has already joined the session), and also any session must know which players belong to it.

What are the requirements for players and sessions? The main requirement is quick access to the desired player or session by his personal unique identifier. And nothing is faster to search than simply making this identifier a player index or a session in an array, it’s probably impossible to come up with. So, every player in our country has an ID that is simply equal to its number in the general array of players. And now if another player sends data to players with identifiers 5, 10, 21 and 115, then the server can immediately identify these recipients simply by using their identifiers as indices.

Now let's define what the “player” is from the point of view of the server. In fact, the “player” is the “socket”, with only some additional information. Additional information includes the following data:


All this information is stored in the GNetSocket class. But note that there is no libuv nicknames in it. This was done so that with some desire you could replace libuv with something else.

In my case, there is a class GNetSocketLibUV , which is inherited from GNetSocket . Why did I need an intermediate class GNetSocket , if in reality only objects of class GNetSocketLibUV are created ? The fact is that my task was to separate the libuv library from the overall network structure as much as possible. As a result, the main server / client file takes up more than 7 thousand lines, and the file specific to libuv - 600 lines. And if you need to replace libuv , then I can do it relatively easily. I also use the principle when objects of key classes are not created directly through new , but through virtual functions, for example, this is how a socket object is created for the server:

 GNetSocket* GNetServerLibUV::NewSocket(GNet* net) { return new GNetSocketLibUV(net); } 

Those. It costs me to replace in one place return new GNetSocketLibUV (net); on some other type of object, how only this type will be created in the program.

The GNetSocketLibUV class overrides all abstract functions of the base class. It looks like this:

 class GNetSocketLibUV : public GNetSocket { public: void* sock; public: GNetSocketLibUV(GNet* net); virtual ~GNetSocketLibUV(); //  UDP  TCP- ,   listaen=true,     virtual bool Create(bool udp_tcp, int port, bool listen); //     TCP-    virtual bool SetConnectedSocketToReadMode(); //   virtual void Destroy(); //  IP-   TCP- // own_or_peer           virtual bool GetIP(CMagicString& addr, bool own_or_peer); //      (  TCP-) virtual bool Connect(NET_ADDRESS* addr); //     (  TCP-) virtual bool Accept(); virtual void SendTCP(NET_BUFFER_INDEX* buf); virtual void SendUDP(NET_BUFFER_INDEX* buf); virtual void ReceiveTCP(); virtual void ReceiveUPD(); }; 

In the GNetSocketLibUV class , only one void * sock variable has been added , which will almost be a pointer to the libuv socket , but with some qualification. We need the ability to quickly use the libuv socket to determine the corresponding socket of the GNetSocket type , in which both the read buffer and the player identifier lie.

How to do it? I added intermediate structures:

 struct NET_SOCKET_PTR { GNetSocket* net_socket; }; struct TCP_SOCKET : public NET_SOCKET_PTR, public uv_tcp_t { }; struct UDP_SOCKET : public NET_SOCKET_PTR, public uv_udp_t { }; 

So ... now we have a new structure for a TCP socket called TCP_SOCKET and a new structure for a UDP socket called UDP_SOCKET . But ... both of these structures have a new field in front of the socket structure, which is a pointer to the parent object of the GNetSocket class .

Now one more important note. The program should not create “native” sockets libuv anywhere , but only sockets like TCP_SOCKET and UDP_SOCKET . Immediately after creating a field net_socket be recorded address of the object GNetSocket , in which structure was createdTCP_SOCKET or UDP_SOCKET .

Practically, creating a socket looks like this:

 //  UDP  TCP- ,   listen=true,     bool GNetSocketLibUV::Create(bool udp_tcp, int port, bool listen) { GNetSocket::Create(udp_tcp, port, listen); uv_loop_t* loop=GetLoop(net); if (udp_tcp) { sock=malloc(sizeof(TCP_SOCKET)); memset(sock, 0, sizeof(TCP_SOCKET)); ((TCP_SOCKET*)sock)->net_socket=this; ... ... ... } 

Now, when we have void * sock is the address of TCP_SOCKET or UDP_SOCKET, and we always know that the first in the structure will always be a pointer to the main socket GNetSocket * net_socket , the task of “quick match” is almost solved.

Add a couple of functions that will help you easily get the right data.

If sock is TCP_SOCKET , then passing the sock address to the following function easily retrieves the TCP socket of libuv :

 uv_tcp_t* GetPtrTCP(void* ptr) { return (uv_tcp_t*)(((char*)ptr)+sizeof(void*)); } 

If sock is UDP_SOCKET , then passing the sock address to the following function easily retrieves the UDP socket libuv :

 uv_udp_t* GetPtrUDP(void* ptr) { return (uv_udp_t*)(((char*)ptr)+sizeof(void*)); } 

And the GetNetSocketPtr function (uv_tcp_t address or uv_udp_t socket address) allows you to get the corresponding socket address of our main socket of the GNetSocket type .

 GNetSocket* GetPtrSocket(void* ptr) { return *((GNetSocket**)ptr); } GNetSocket* GetNetSocketPtr(void* uv_socket) { return GetPtrSocket(((char*)uv_socket)-sizeof(void*)); } 

How to use it in practice? For example, you need to put a TCP socket in read mode:

 //     TCP-    bool GNetSocketLibUV::SetConnectedSocketToReadMode() { if (udp_tcp) { uv_tcp_t* tcp=GetPtrTCP(sock); int r=uv_read_start((uv_stream_t*)tcp, OnAllocBuffer, OnReadTCP); return (r==0); } return false; } 

Please note that our sock turns into uv_tcp_t * tcp using GetPtrTCP (sock) , and you can already pass it to the uv_read_start () function .

Now, in my case, the OnReadTCP () callback function looks like :

 void OnReadTCP(uv_stream_t* stream, ssize_t nread, const uv_buf_t* buf) { GNetSocket* socket=GetNetSocketPtr(stream); if (nread>0) { NET_BUFFER* recv_buffer=socket->net->GetRecvBuffer(); assert(buf->base==(char*)recv_buffer->GetData()); recv_buffer->SetLength(nread); socket->ReceiveTCP(); } else { //  ,    socket->net->OnLostConnection(socket); } } 

The first line:

 GNetSocketLibUV* socket=(GNetSocketLibUV*)GetNetSocketPtr(stream); 

gets the address of the GNetSocket object for which the socket-> ReceiveTCP () function will be executed , which actually receives the messages received by the socket. She will already put this data into the socket's own buffer, check for the fact that the message has been received completely, and then pass it on to the server or client for processing (I have most of the server and client code).

Still, perhaps, I will give an example of removing a socket:

 //   void GNetSocketLibUV::Destroy() { if (sock) { if (udp_tcp) { uv_tcp_t* tcp=GetPtrTCP(sock); uv_close((uv_handle_t*)tcp, OnCloseSocket); ((TCP_SOCKET*)sock)->net_socket=NULL; } else { uv_udp_t* udp=GetPtrUDP(sock); int r=uv_read_stop((uv_stream_t*)udp); assert(r==0); uv_close((uv_handle_t*)udp, OnCloseSocket); ((UDP_SOCKET*)sock)->net_socket=NULL; } sock=NULL; } GNetSocket::Destroy(); } 

Notice here the line

 ((TCP_SOCKET*)sock)->net_socket=NULL; 

i.e.the libuv socket is not deleted here, it becomes generally on its own, since the main GNetSocket object will no longer have a connection with this socket. But when libuv finishes all its affairs with a socket, the OnCloseSocket () callback function will inevitably work , in which free () will be executed . Therefore, memory leaks will not occur.

With this, I think that the conversation about the libuv library can be ended. I tried to clarify the essence of the principles that underlie her work and, I think that these principles are practically the same for most of these decisions, including WinSock. Perhaps in my interpretation there are not enough code examples, but they are not very difficult to find on the Internet by function names. I tried to explain what to do with these functions and how they interact with each other. It is quite possible that in my understanding there are some inaccuracies, since my goal was to finish the game, and not to become an expert in network programming, so I figured out this matter on the principle of "necessary and sufficient".

GNetClient Network Client Structure


The client at different points in time should behave in absolutely different ways, for example, at the very beginning he should connect to the server and create or join the session, and when the game has already begun, he should exchange data from the mouse and keyboard with other players.

You can break the logic of the work of the client at the stage and describe the work of each stage separately.

In my case, there are the following stages:

GNetStadyEnumSession / NET_STADY_ENUM_SESSION - the stage of getting a list of existing gaming sessions.
GNetStadyJoinSession / NET_STADY_JOIN_SESSION - the stage of connecting to the game session and setting up your own player.
GNetStadyCreateSession / NET_STADY_CREATE_SESSION- A stage that allows you to create a session, customize it and your own player.
GNetStadyStartGame / NET_STADY_START_GAME - the stage of launching a network game.
GNetStadyGame / NET_STADY_GAME - the stage of the game.
GNetStadyMigrationHost / NET_STADY_MIGRATION_HOST is the stage of the migration of the host.

Stage indices are declared as follows:

 enum NET_STADY {NET_STADY_ENUM_SESSION, NET_STADY_JOIN_SESSION, NET_STADY_CREATE_SESSION, NET_STADY_START_GAME, NET_STADY_GAME, NET_STADY_MIGRATION_HOST, NET_STADY_NO=-1}; 

The object of each stage is created in advance and stored in an array of stages inside GNetClient .

 // ,     class GNetClient : public GNet { protected: NET_STADY net_stady; //   int k_net_stady; //   GNetStady** m_net_stady; //    ... ... ... } GNetClient::GNetClient() : GNet() { net_stady=NET_STADY_NO; k_net_stady=6; m_net_stady=new GNetStady*[k_net_stady]; m_net_stady[NET_STADY_ENUM_SESSION]=new GNetStadyEnumSession(this); m_net_stady[NET_STADY_JOIN_SESSION]=new GNetStadyJoinSession(this); m_net_stady[NET_STADY_CREATE_SESSION]=new GNetStadyCreateSession(this); m_net_stady[NET_STADY_START_GAME]=new GNetStadyStartGame(this); m_net_stady[NET_STADY_GAME]=new GNetStadyGame(this); m_net_stady[NET_STADY_MIGRATION_HOST]=new GNetStadyMigrationHost(this); ... ... ... } 

The base for all stages is the GNetStady class , from which all other stages are inherited:

 // ,      class GNetStady { protected: GNetClient* owner; //    - GNetClient unsigned int stady_period; unsigned int stady_tick; public: GNetStady(GNetClient* owner); virtual ~GNetStady(){} virtual bool OnStart(NET_STADY previous, void* init); virtual void OnFinish(NET_STADY next){} virtual void OnUpdate(); //     ,    stady_period (    0) virtual void OnPeriod(){} //      virtual bool IsMessageCorrected(int message_type){return false;} }; 

Walk briefly on the functions of the stage.


Network Client Stage Management


Some stage is always current, except for the situation when the client has not yet connected to the server.

To start working with the network, use the function:

 //    virtual bool GGame::StartNet(const char* ip); 

If ip = NULL , then start your own local server, otherwise ip must be the IP address of the Internet server, for example, 234.123.34.18:57.

To complete the work with the network there is a function:

 //    virtual void StopNet(); 

This function is guaranteed to remove all signs of the network, i.e. stops the local network server, if there is one, and destroys the GNetClient client object .

To start working with the network, you need to call the bool GGame :: StartNet function (const char * ip) . If the ip-address is specified, the function initializes the network and tries to establish a connection with the server located at the specified address. If the server responded and allowed the connection, the function returns true , otherwise false . All further calls to the server will be made through the established TCP connection . If ip = NULL , the function does not perform any connection to the server. Instead, it creates a UDP socket., to use it to search for servers on the local network via a broadcast request.

If there are no problems with the execution of the StartNet () function , the network starts to execute the NET_STADY_ENUM_SESSION stage , i.e. search created game sessions. This happens as follows ... in the GNetStadyEnumSession :: OnPeriod () function , which is automatically called once every half second, a message of the MESSAGE_TYPE_ENUM_SESSION type is sent. The message is sent either only to the Internet server via a pre-established connection, or by broadcasting a request to the local network. In any case, if the Internet server or some server on the local network receives this message, then they react the same way, namely: they send the questioning answer as a message MESSAGE_TYPE_ENUM_SESSION_REPLY , which lists all the sessions available on the server. Each session has a brief description in the form of session creation time , host ID , card name , the number of players and their characteristics , the password sign in the session , and the server IP address, through which the client can connect - it is very important for the local network, as the client in this case sends a request to the broadcast and he must know the address of the server that sent him the answer to make the connection.

After receiving information about the sessions found, the client calls the virtual function virtual void GGame :: OnEnumSession (GSessionList * sessions, int count_general_session) . This function is not defined in the GGame class , since its task, in practice, is to display a list of sessions to the user. Since GGame is a universal base class, it does not undertake such tasks, since it knows nothing about a particular game where it can be used. Therefore, this function in my case is redefined in the classGGameOnimodLand and it is she who sorts the resulting sessions according to the time of creation and shows them to the user by adding lines to the GListBox component .

If the user chooses a session and clicks " Join ", the following happens. The client simply starts the NET_STADY_JOIN_SESSION stage , which, in its function GNetStadyJoinSession :: OnStart () , attempts to connect to the session. This attempt may be unsuccessful for various reasons, for example, the game was started or another player connected, and there is no more room in the session. In any case, the connection permission is issued by the host (in no case is the server).

How does this happen in practice?

In the case of a local network, you must first connect to the server. For this, the bool GNetClient :: ConnectToServer (const char * ip) function is used , which returns true if successful . The IP address of the server is taken from the session information. Before establishing a connection, a UDP socket is first killed , since it is no longer needed, and a TCP socket is created instead , through which the connection will be established. The client accesses the server using CONNECT , and the server must accept the request and respond via ACCEPT . If the connection is established, then you need to shake hands .

What is it for?The fact is that anyone from another program can connect to the server. The “ handshake ” identifies the client in the eyes of the server and it is after the successful “ handshake ” that the server allows the client to begin exchanging messages. The “ handshake ” itself is performed via the message MESSAGE_TYPE_HELLO , which is additionally supplied with any service information such as client version , license key , etc. The server verifies this information and returns a MESSAGE_TYPE_HELLO_REPLY message to the client , which contains a unique client ID . All further communication with the server and other players, the client performs through thisID . If the server returned ID = 0 , then this means that the client is not accepted by the server. In this case, the server additionally sends the text, which explains to the client the reason for the refusal. This text can be displayed on the client in the form of, for example, GMessageBox . Please note that the connection to the Internet server is similar.

If the client is connected to the server, then it should immediately send a request for connection to the session to the host. However, the host can protect the session with a password so that only " your " are connected . From the session information, the fact of having such a password is determined. In this case, the user must first be prompted to enter a password. Next, the function is executedbool GNetClient :: ConnectSession (NET_JOIN_DATA * join) , which in turn sends a MESSAGE_TYPE_CONNECTING message to the host . The message is sent to the host ID , which is again taken from the session information. First, the message is received by the server, which analyzes the list of recipients and sends the message to the host.

Upon receiving the message, the host looks to see if it is possible to accept the player in the session, checks the password (if any) and makes a decision. If the answer is positive, the host first selects a slot for the new player using the virtual int virtual function GGame :: GetIndexOfConnectedPlayer () , and includes the player in the session. Additionally, the host function is triggered.virtual void GGame :: OnNewPlayer (int index) , which allows you to perform some actions due to the appearance of a new member. Next, the host sends the client a response in the form of a MESSAGE_TYPE_CONNECT_REPLY message , where, in the event of a positive response, it sends the slot index in the session to the client, all information about the status of other players in the session, as well as some additional information. Upon receiving the message MESSAGE_TYPE_CONNECT_REPLY , the client remembers its slot and initializes its session variables with the data received from the server.

Now a new player can customize their characteristics in the session and exchange chat messages. In order for the host to start the game, each participant in the session must click the " I am ready", without this, the host button" Start "will be in an inactive state.

Game session information


Session information should be organized so that it does not depend on the particular game. For example, I have no desire to change the structure of my shell, if I suddenly want to say so, “remember the golden childhood” and start playing games again. So you need to make sure that the fields in the session description are arbitrary and only a specific game, but never a server or a client, deals with their analysis. However, some fields still need to be clearly defined, since something the server still needs to know about the session, for example, its name. You also need to uniquely interpret some information about the players entering the session, for example, the number and status of " who is it " ( person , computer , open slot , closed slot).

I put all this information in a struct NET_SESSION_INFO structure . As part of this structure there is an array with players NET_PLAYER ** m_player; and their number is int k_player;

But the most important thing is that there are fields that can store arbitrary information:

 int length_info; //  .  char* info; //  .  

The same fields are available in the NET_PLAYER structure and they also allow you to store arbitrary information for any player.

Next, add the following functions and operators to the NET_SESSION_INFO structure :

 virtual void Serialize(CMagicStream& stream); // /    NET_SESSION_INFO& operator=(const NET_SESSION_INFO& si); //   bool operator==(const NET_SESSION_INFO& si); //     bool operator!=(const NET_SESSION_INFO& si) //     

Why is all this necessary?

I will try to clarify one important thing, which, I think, is far from being understood by everyone. The use of operators, in my opinion, is a rather dangerous matter. The problem is that very often new variables are added to the structure in the course of development. And often you can even forget to initialize them in the constructor. And forgetting that these variables should also be added to the comparison and equality operators — this happened to me more than once, after which I was sometimes very angry with my “t head.”For forgetfulness. So ... as a result, I came to the following decision. Usually important structures have serialization in their structure, or, more simply, they can read their data from a stream and write them to a stream. Most often, a stream is a regular file where bytes are written, but it will be much better if the stream can also be a memory area. In my case, I wrote the CMagicStream class and spawned the CMagicStreamFile and CMagicStreamMemory classes from it . Therefore, the virtual void Serialize function (CMagicStream & stream); able to work with files and with RAM, depending on which class object is in reality stream .

By the way, I have one more class typeCMagicStreamVirtualFile , which is included in my "shell" and is designed to work with a virtual disk. A virtual disk is a couple of files that contain their own file system. I used my virtual disk to place game resources inside it. A virtual disk can be opened via CMagicString GPlatform :: OpenVirtualDrive (const char * path), specifying a virtual disk file as the path. As a result, a path of the following type will be returned: 0 \ which can then be used in the shell to work with files inside a virtual disk. It is important here that the functions of the "shell" working with the file system will understand this way and redirect requests to the files where necessary, for example, the function CMagicStream * GPlatform :: OpenStream (const char * file, int mode)works according to this universal principle and correctly access the virtual disk if file has a corresponding path pointing to it. The same applies to the situation with the " current folder " - no one bothers to make the current folder on the virtual disk.

So ... back to the point. I talked about the fact that for me, operators can be dangerous because of my forgetfulness. To minimize the risk, I do this: I make the serialization function for the structure in binary form, then I pass all operators through this function. Those.For example, if I need to write an assignment statement, then instead of copying the structure fields one by one, I create a stream of type CMagicStreamMemory in RAM for writing and execute Serialize () for the copied structure object for it , then I create the same stream for reading and of the resulting structure object, I also perform Serialize () from the same memory location. It turns out this Save + Loadthrough ram. Similarly, you can do with the comparison operator - write the data of the compared objects in two different streams, and then begin to compare them byte-by-bye. This method is, of course, slower than if the operator compared each field with each one. But for bottlenecks you can always use the traditional version. The advantage of serialization is that it is also used for regular Save / Load data. And here the forgetfulness emerges quickly enough, at least in my case, since the corrupted data or unsaved fields are much more active.

In addition, serialization is great for copying your data through the clipboard. And in the case of the network ... note that the network interaction is reduced to the transmission of messages, which are the type , size and arbitrary data fields . And using binary serialization just allows you to turn any type of data into a stream of bytes that can be transmitted over the network, and then turned back into original data on the receiving side.

Now I finally got to the point of configuring a network session. The network client should not know anything about the game and should not know about the majority of the players' settings. For example, the flag " I am ready"he certainly doesn’t need it - the game needs it, and the network client serves only to send messages over the network.

I did this. In GGame, I announced 4 empty virtual functions that should be defined already in the GGameOnimodLand class .

 virtual bool GGame::GetSessionInfoStruct(NET_SESSION_INFO* si); //    NET_SESSION_INFO* si   . virtual void GGame::SetSessionInfoStruct(NET_SESSION_INFO* si); //      NET_SESSION_INFO* si  . virtual bool GGame::GetSessionPlayerStruct(int index, NET_PLAYER* np); //     NET_PLAYER* np      .  index   . virtual void GGame::SetSessionPlayerStruct(int index, NET_PLAYER* np); //       NET_PLAYER* np    . 

In practice, these functions universally transfer data from the network client to the game and back. In this case, the GetSessionInfoStruct / SetSessionInfoStruct functions work with the data of the entire session, including the data about the players. And the GetSessionPlayerStruct / SetSessionPlayerStruct functions work with data for a specific player. Naturally, the functions for players are used by the session functions, since NET_SESSION_INFO contains an array of NET_PLAYER objects .

After this approach, the game itself for the network client turns into a “black box”, from which “something” comes and which “something” accepts. And now the important point that is needed in order to simplify the process of configuring the player. Just imagine that a player has many settings and that they can change, for example, you can change the color of your team or choose a race. But you never know what you can think of in the future and it is desirable that then the structure of the program should not be corrected.

I did this: on the NET_STADY_JOIN_SESSION stage , the void GNetStadyJoinSession :: OnUpdate () function is called by the timer and it executes a couple of lines that solves the problem of universality.

The GNetStadyJoinSession stage has a variableNET_SESSION_INFO * copy_session; which contains a copy of the session state for the last clock. I still give the significant part of the code in its entirety.

 void GNetStadyJoinSession::OnUpdate() { GNetStady::OnUpdate(); //       NET_PLAYER* current_player=current_session->m_player[index_player]; NET_PLAYER* copy_player=copy_session->m_player[index_player]; owner->game->GetSessionPlayerStruct(index_player, current_player); if (*copy_player!=*current_player) { //        *copy_player=*current_player; if (current_player->type==PLAYER_MAN) { GMemWriter* wr1=owner->wr1; wr1->Start(); (*wr1)<<index_player; current_player->Serialize(*wr1); MEM_DATA buf; wr1->Finish(buf); //    ,    int k_receiver=owner->RefreshReceiverList(); NET_BUFFER_INDEX* result=owner->PrepareMessageForPlayers(MESSAGE_TYPE_PLAYER_INFO, buf.length, buf.data, k_receiver, owner->m_receiver); owner->GetMainSocket()->SendMessage(result); } } } 

Practically, the following happens here. The variable index_player is the slot number that belongs to the player in the session.

The string owner-> game-> GetSessionPlayerStruct (index_player, current_player); takes the current settings of the same player from the game and then simply compares them with those remembered by the client:

if (* copy_player! = * current_player)
and if there is a discrepancy, then the copy is first matched * copy_player = * current_player; and then all participants in the session sends a message of type MESSAGE_TYPE_PLAYER_INFO , in which the new player settings are transmitted.

What is the great advantage of this approach? The fact is that the game itself should not follow the sending of the configuration to other players at all. It is worth changing in the configuration at least 1 byte, as GNetStadyJoinSession :: OnUpdate () will immediately notice this change and automatically send out new data to all participants in the session. At the same time, GNetStadyJoinSession :: OnUpdate () does not know anything about real data that can be configured, because the comparison operator works through the serializer, and the length of the byte stream and the stream itself is compared with the case of equal length.

In the example of the article, the structure with the player's configuration looks like this:

 struct PlayerCfg { int type; CMagicString name; unsigned int player_id; unsigned int color; bool ready; void Serialize(CMagicStream& stream) { if (stream.IsStoring()) { stream<<type; stream<<name; stream<<player_id; stream<<color; stream<<ready; } else { stream>>type; stream>>name; stream>>player_id; stream>>color; stream>>ready; } } }; 

And in the game the fields are completely different and much more. However, this approach works great in terms of versatility.

Creating a session


However, before joining a session, it is necessary that someone create this session. To do this, there is a stage NET_STADY_CREATE_SESSION . The class of this stage is inherited from the NET_STADY_JOIN_SESSION stage :

 class GNetStadyCreateSession: public GNetStadyJoinSession 

This is due to the fact that these stages are in many ways similar and seriously different except by the OnStart () function , which performs either the creation of a session or joining a session. In the NET_STADY_CREATE_SESSION stage , the configuration change check also works in the same way, but there is an additional check and on changing each player in the session, because the host controls the entire session and can, for example, delete other players.

By the way, the network client is still a little involved in the analysis of the player configuration data. The configuration is answered by the message MESSAGE_TYPE_PLAYER_INFO , which the client analyzes as follows. At the time of receiving the message, he remembers whether the player for whom the new configuration came was a living person (type = PLAYER_MAN ). After receiving the message, the current configuration is replaced with a new one. But the client only checks the type field to see if it is still PLAYER_MAN . And if the field suddenly changed to PLAYER_OPENED , this could mean, for example, that the host removed the player from the session and the slot is now open. The client is involved in handling this situation and as a result, the bool GNetClient :: LostPlayer (unsigned int player_id) is called , which means that it is lost to the player. Then it all comes to the game in the form of one of the options:

 //    (    ) virtual void GGame::OnCancelSession(); //        virtual void GGame::OnDeletingFromSession(); 

The bool GNetStadyCreateSession :: OnStart (NET_STADY previous, void * init) function is called when the player presses the "Create Game" button. If this is a game over a local network, you must first start your own server and join it:

 //    bool GNetClient::CreateSession() { bool is=false; if (!internet) { //    if (StartLocalServer()) { is=ConnectToServer("127.0.0.1"); } else is=false; } else { is=true; } return is; } 

In the ConnectToServer function (“127.0.0.1”), the UDP socket is destroyed and a T CP socket is created . Next, a connection is established with the server, which is launched on the same computer by the StartLocalServer () function . The server’s IP address is “127.0.0.1”, which means “the same computer”.

Then the host calls the virtual void GGame :: OnCreateSession function (int index_player) , which in my case only sets its slot for the host, which is always 0.

Next, the host uses the void GNetStadyCreateSession :: OnPeriod () functionstarts to periodically inform the server about the session state. This function is automatically called once every half second. It sends to the server a message of type MESSAGE_TYPE_SESSION_INFO . This message is not always sent, but only when the session settings have been changed. Here, the same principle is used as with changing the player’s configuration.

The server, having received the message MESSAGE_TYPE_SESSION_INFO , first checks whether there is already such a session in the list of its sessions and, if it does not, then adds a new session. The sender of the message is added to the new session as the first participant and host.
Further, the server will send information about available sessions in response to a request from the client MESSAGE_TYPE_ENUM_SESSION .

The NET_STADY_CREATE_SESSION stage lasts until the player presses the " Start " button to initiate the start of the game. Practically, at this moment the stage NET_STADY_START_GAME is established through a call net-> SetStady (NET_STADY_START_GAME, NULL); .
The message MESSAGE_TYPE_START_GAME is sent automatically from the
void function GNetStadyCreateSession :: OnFinish (NET_STADY next) , which is called when the NET_STADY_CREATE_SESSION stage is completed . Here the host informs the server that the session is now closed for connection and there is no need to inform other players about it.

The message MESSAGE_TYPE_START_GAME is sent by the server to all participants in the session. After receiving it, they all also switch to the NET_STADY_START_GAME stage .

Further, the main work of the game launch stage is performed in the function:
void GNetStadyStartGame :: OnPeriod (), which performs a countdown from 5 to 1 through the functionvirtual void GGame :: OnStartNetCounter (int counter); Practically, the numbers 5,4,3,2,1 are output to the chat area. Next, virtual void GGame :: OnStartNetGame () is called , which starts the process of preparing the game session for launch. At this point, the game card should load and players should be placed on it. Note that this whole process is performed on each computer independently. When all data is initialized, the virtual bool GGame :: IsNetGameLoaded () function should return true . This function is called continuously from GNetStadyStartGame :: OnPeriod () and while it returns false , the network client assumes that the game is being initialized. As soon as it returnstrue , the message MESSAGE_TYPE_PLAYER_STARTED is sent to all other players . At that moment, when the network client detects that it received the message MESSAGE_TYPE_PLAYER_STARTED from all participants in the session, it goes to the NET_STADY_GAME stage and this means the start of the game.

The bool GNetStadyGame :: OnStart (NET_STADY previous, void * init) function immediately calls virtual void GGame :: OnLaunchNetGame () , and this is the launch. Everything. Then the game begins.

Online game


The NET_STADY_GAME stage controls the entire gameplay through the void GNetStadyGame :: OnUpdate () function . In practice, this stage sends commands that the player has entered using the mouse and keyboard for a certain period of time. Also this stage expects exactly the same data from other players.

User commands are transmitted via message MESSAGE_TYPE_PLAYER_GAME . The GNetStadyGame stage has fields:

 int k_player; PLAYER_MESSAGE* m_player; 

which serve to receive commands from other players. The PLAYER_MESSAGE structure has a buffer for receiving NET_BUFFER next_message messages; However, its purpose is not at all what it may seem at first glance. The fact is that there is a concept of a network clock number - this is such a variable that counts clock cycles increasing from 0 to infinity until the game ends. The network clock is increased only if the network client received commands from all players for the current network clock . Otherwise, waiting happens and the game stops. But since the quality of communication usually allows delivering messages fairly stably, these delays occur unnoticed by the player.

If the client starts to wait for a long time, then he reports this game through the virtual void GGame :: OnWaitingPlayers function (unsigned int dtime, int k_player_id, unsigned int * m_player_id) and the game’s task is to bring this list of players to the screen. The waiting time is limited and the client ensures that the wait is not infinite. When the time limit is exhausted, the client begins to cut off problem players, declaring them losers, which immediately leads to the continuation of the game or its completion due to victory.

If the client received commands from another player for the current clock, they are immediately transferred to the game using the virtual void function GGame :: SetPlayerNetMessage (unsigned int sender, MEM_DATA & message). But if the received message is immediately transferred to the game, then probably it is not very clear, why do we need another buffer NET_BUFFER next_message ? But why?

As I said, network interaction is very much like multithreading, when performing some actions can be postponed indefinitely due to synchronization of flows. So ... in a network game, a situation can easily form when one computer began to overtake another by 1 network clock . In this case, the message that came from the overtaking computer may be marked as a message for the next clock cycle, to which our computer has not yet reached. And then our computer should do the following ... it just saves this message in its own buffer.NET_BUFFER next_message , and while temporarily will no longer perform any action on this issue, but he will know that the message for the next network clock from such a player has already been received. And when this next network clock begins , first of all our computer will take these commands from its own buffer and immediately transfer them to the game via virtual void GGame :: SetPlayerNetMessage (unsigned int sender, MEM_DATA & message) . This is a very important point that it is desirable to understand to build network interaction in similar games.

You also need to understand that overtaking is no longer possible for 2 bars, since the process " waiting for other players", therefore, the maximum advance can only be by 1 network clock .

But in order to accept commands, you need to start someone to send them. The network client does not need to know anything about the specifics of the commands used in the game, so it simply calls the virtual function MEM_DATA GGame :: GetPlayerNetMessage () , which returns to it a ready buffer with commands in the form of MEM_DATA . The received buffer is sent simultaneously to itself to all other players to the players.

 MEM_DATA message=owner->game->GetPlayerNetMessage(); GNetSocket* socket=owner->GetMainSocket(); owner->game->SetPlayerNetMessage(socket->player_id, message); //     ,       int k_receiver=owner->RefreshReceiverList(); owner->m_receiver[k_receiver]=takt; //      ,     NET_BUFFER_INDEX* result=owner->PrepareMessageForPlayers(MESSAGE_TYPE_PLAYER_GAME, message.length, message.data, k_receiver, owner->m_receiver, 1); socket->SendMessage(result); //        

The network clock is controlled by the game, and it returns it to the client via the virtual int function GGame :: GetNetTakt () . However, the client controls the completion of the network tact - this is the moment when all teams are received from all players. The client immediately reports this game by calling the virtual bool GGame :: OnNextNetTakt () . This function, in my case, checks for network desynchronization and returns true if everything is in order. If it returns false , the network client will automatically begin the process of correcting out of sync, for which the host will write all the data to the file and transfer this file to all other players, and other players will read this file and continue the game with this data. Practically, the host will make save, and the rest of the players - Load . I control the network desynchronization through counting the sum of random numbers generated in one network cycle . Random numbers are generated constantly and on all computers they must be the same, otherwise it is a sign that the network has become out of sync.

If the virtual bool GGame :: OnNextNetTakt () returns true , then the client notes this fact in the on_next_net_takt variable for itself - this means that the network clock is completed. The game must, in its main loop, periodically call the client function bool GNetClient :: IsNextNetTakt () {return on_next_net_takt;}and when true returns , the game increases the network clock by 1 and executes all commands received over the network for the last network clock for each player. Then the arrays of commands are cleared and everything starts in a new way, but with an increased value of the network clock .

Commands that the player enters through the mouse and keyboard, get into the network is not immediately. In fact, it happens that the commands that were collected during the last network clock are transmitted to the current network clock cycle , and at this time new commands from the mouse and keyboard are collected. Those.There is a delay in the reaction to 1 network clock . This delay is called Network Latency . However, there is no point in doing so that the network clocks coincide with the tact of the game. For example, if the game is updated 60 times per second, then it is quite possible to make sure that 10 network games correspond to one network clock . It is unlikely that the user will be very annoyed by the response delay of 1/6 second.

Traffic encryption


I have to admit that I am not strong in the subject of data protection and I want only to pay attention to it at the end. It is not necessary to connect to the server from the game, as the developer expects, i. In practice, you can connect from any program that has the ability to connect to an arbitrary IP address and port. Then you can start sending anything to the server. The server should try at least to check if the messages are not correct, otherwise it will simply collapse, having received a portion of “nonsense” and become entangled in it.

Also, messages from both sides are usually encrypted.

I will not particularly argue on this topic, I will just say that I used the minimum encryption of traffic in my own. I do not think that my defense is difficult to crack, because at this stage I have neither the desire nor the strength to do it. I hope that for the time being I will be defended by the small fame of my project, and then it will be seen ...

About one error that exists in Windows for a long time.


In the first part of the article, I described the network game in RTS only as a general idea. But there I pointed out the biggest problem that the network is fraught with in the RTS- the need to have completely identical calculations on all computers. If a computer starts to do something a little bit wrong, then in a couple of minutes everything will be very different on your computers. And the game will simply go into a stupor when a player tries to control a unit on his computer, which on another computer has long been "killed in a shootout." I consider such errors the most terrible of those I have seen, since it is practically impossible to catch such bugs with logic. The reasons for such errors are usually some insignificant trifle of the type “I forgot to re-initialize the variable when the network game was restarted”. As a result, this variable is guaranteed to collapse network synchronization, and very long before the game itself finally collapses.

If anyone is interested in my reasoning on this topic, then refer to the first part of the article . Now I want to talk about one, in my opinion, a very dirty glitch that has been present in Windows since time immemorial. It is guaranteed to produce errors in floating-point calculations, which in turn once killed my network.

I discovered this problem supposedly in 2003-2004, even on Windows 98 , from there it successfully migrated to Windows XP , and recently I discovered that nothing has changed in Windows 8 either.

The main essence of the error is that the corresponding Windows functions change (and do not return back) the FPU control word.. And, of course, there is no mention of such behavior anywhere in the documentation.

Here is my old code that proves the existence of a problem on Windows XP . On Windows 8, I did not try it, but on Windows 8 I also “flew into a situation” when my well-established network game suddenly began to work ugly for no apparent reason. It turned out that I accidentally removed a piece of code that compensates for this problem.

So an example of the function:

 int Error1() { double step=66.666664123535156; double start_position_interpolation=0; double position_interpolation=199.99998474121094; double vdiscret=(position_interpolation-start_position_interpolation)/step; int discret=(int)vdiscret; return discret; } 

The numbers, of course, are strange, but they show an error.

If you call the Error1 () function and walk through it with a debugger, then the number 2 will fall into the discret variable . And now we do this:

 int result=Error1(); // result=2 ok=direct_3d->CreateDevice( D3DADAPTER_DEFAULT, D3DDEVTYPE_HAL, hwnd, D3DCREATE_HARDWARE_VERTEXPROCESSING, &d3d9pp, device_3d ); result=Error1(); //    result=3 

Those.if between calls of the Error1 () function interpose the DirectX function that creates the DirectX device , then the second function call will give an unexpected result of discret = 3 . This is due to the fact that for the first time the vdiscret will be equal to 2.99999 ......., and for the second time already 3. The difference is minimal in practice, but since in my game , double variables are used , this is quite enough to kill the whole network game. And besides, without knowing the reasons, there is nothing impossible to fix there, because formally the code itself is correct, just somewhere, some kind of processor state flag did not return to the desired value.

In Windows 98This problem manifested itself even more fiercely than in Windows XP . There, this glitch occurred just when trying to list the available monitor resolutions, and using WinAPI , i.e. even without directx . In Windows 8, I did not investigate this issue, since I already knew what to do with it.

I know of 2 solutions that "cure" this attack. Once I just created a separate stream, switched the screen resolution in it and then just killed the stream along with the error. This problem did not affect the main thread in this case.

The second method is simpler.

 unsigned int status=_controlfp(0,0); //    // ... // ... // ... _controlfp(status,_MCW_DN | _MCW_IC | _MCW_RC | _MCW_PC); 

This kills the glitch that appears after switching the screen resolution, and the math continues to work correctly.

The current state of the game or release


I decided that the game is already quite ready for release. Yes, it is possible that it is necessary to correct the balance or correct some minor errors, but, in fact, it is time to release the game. In any case, I see sense for further improvement only if there are regular players.

Recently, I was surprised to find that, in addition to the well-known domains like com , org , net , etc., there is also a land domain . And since my game in English is called Onimod land , I immediately took the domain onimod.land for the game , so now the game, like it used to be in the past, has its own personal site - onimod.land .

Before entering Steam, I think it will come a little later, but for now I am releasing the game through my website. Those who want to support my project financially, can do it on the site with the game. However, I myself live in Russia and I understand that people here have much more pressing items of expenditure than buying software. Therefore, if you like the game and you don’t have the financial means to support me financially, you can ask me for the key for free using the feedback form on the site. Please do not be lazy to introduce yourself, and even letters like “give me the key” from wertwq@mail.ru cause mostly negative emotions in me.

The game has become commercial and, probably, soon I will find out if someone other than me needs it.

Perhaps, on this note of philosophy, I will complete my story. I thank everyone for the strong willpower shown during the reading of this article, as well as for the condescending attitude towards my “literary talent”.

Sincerely, Alexey Sedov (aka Odin_KG)

PS
I have a desire to translate articles about the game into English, at least the first part and the path finding algorithm .

If someone has a good knowledge of English, at least some understanding of what I'm trying to talk about, and a desire to help me in this matter, then I will be very happy. I tried to somehow hire a translator, but he gave me such grammatically correct sense nonsense that I have no particular desire to make another similar attempt.

I will remove this request from the article if someone responds and really gets down to business.

→ Beginning of the article: Resurrection of the game
→ Continuation of the article: GUI

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


All Articles