What methods / algorithms for the application / game state exist?

I have an interest in studying the real-time multiplayer client-server game and related algorithms. Many well-known multiplayer games, such as Quake 3 or Half-Life 2 , use delta compression techniques to save bandwidth.

The server must constantly send the latest snapshots of the game state to all clients. It would be very expensive to always send a full snapshot, so the server just sends the differences between the last snapshot and the current one.

... easy, right? Well, the part that is very difficult for me to think about is how to actually calculate the differences between the two states of the game.

Game states can be very complex and have entities allocated on the heap, referencing each other through pointers, can have numerical values, the presentation of which depends on the architecture to another and much more.

It’s hard for me to believe that every type of game object has handwritten serialization / deserialization / calculation function.


Back to the basics. Let's say I have two states represented by bits, and I want to calculate their difference:

state0: 00001000100 // state at time=0 state1: 10000000101 // state at time=1 ----------- added: 10000000001 // bits that were 0 in state0 and are 1 in state1 removed: 00001000000 // bits that were 1 in state0 and are 1 in state1 

Ok, now I have added and removed diff tags - but ...

... the size of the diff is still exactly the size of the state . And I really need to send diff two over the network!

Is a valid strategy actually building some kind of rare data structure from these bits? Example:

 // (bit index, added/removed) // added = 0 // removed 1 (0,0)(4,1)(10,0) // ^ // bit 0 was added, bit 4 was removed, bit 10 was added 

Is this a possible valid approach?


Let's say I managed to write serialization / deserialization functions for all my types of game objects from / to JSON .

Can I somehow have two JSON values, calculate the difference between them automatically in terms of bits?

Example:

 // state0 { "hp": 10, "atk": 5 } // state1 { "hp": 4, "atk": 5 } // diff { "hp": -6 } // state0 as bits (example, random bits) 010001000110001 // state1 as bits (example, random bits) 000001011110000 // desired diff bits (example, random bits) 100101 

If something like this were possible, then it would be reasonably easy to avoid architecture-dependent problems and the functions for calculating handwriting differences.

Given two lines A and B that are similar to each other, we can compute line C , which is smaller than A and B , which is the difference between A and B and can be applied > A to get B as a result?

+5
source share
3 answers

Since you used Quake3 as an example, I will focus on how they are done there. The first thing you need to understand is that the “game state” in relation to client-server games does not apply to the entire internal state of the object, including the current state of the AI, collision functions, timers, etc. The game server actually gives the client less. Just the position of the objects, orientation, model, frame in the animation of the model, speed and type of physics. The last two are used to make the movement smoother, allowing the client to simulate a ballistic movement, but more on that.

Each game frame that occurs approximately 10 times per second, the server runs physics, logic, and timers for all objects in the game. Each object then calls the API function to update its new position, frame, etc., and also to update whether it has been added or deleted in this frame (for example, a deleted image because it hit the wall). In fact, Quake 3 has an interesting mistake in this regard - if the shot moves during the physics phase and hits the wall, it becomes deleted, and the only update that the client receives is deleted, not the previous flight to the wall, so the client sees the shot disappears in the air 1/10 second before striking the wall.

With this little information about an object, it is fairly easy to distinguish between new information and old information. In addition, only objects that really change call the update API, so objects that remain unchanged (for example, walls or inactive platforms) do not even require such a diff. In addition, the server can additionally save the sent information without sending objects to the client that are not visible to the client until they appear. For example, in Quake2, a level is divided into viewing areas, and one area (and all objects inside it) is considered “out of sight” from another if all the doors between them are closed.

Remember that the server does not need the client to have the full state of the game, only the scene graph, and this requires much simpler serialization and absolutely no pointers (in Quake it is actually stored in one array of static size, which also limits the maximum number of objects in Game).

In addition, there is also user interface data for things like player health, ammunition, etc. Again, each player receives their own health and ammunition sent to them, and not those that are on the server. There is no reason for the server to share this data.

Update: To make sure that I get the most accurate information, I double-checked the code. This is based on Quake3, not Quake Live, so some things may vary. The information sent to the client is essentially encapsulated in a single structure called snapshot_t . This contains a single playerState_t for the current player and an array of 256 entityState_t for visible game objects, as well as a few extra integers and an array of bytes representing a bit mask of the "visible areas".

entityState_t , in turn, consists of 22 integers, 4 vectors, and 2 trajectories. A “trajectory” is a data structure used to represent the movement of an object through space when nothing happens to it, for example. ballistic movement or direct flight. These are 2 integers, 2 vectors and one enumeration, which can be conceptually stored as a small integer.

playerState_t slightly larger, contains approximately 34 integers, approximately 8 integer arrays ranging in size from 2 to 16 each and 4 vectors. It contains everything from the current frame of the weapon animation, through the player’s inventory, to the sound the player makes.

Since the structures used have the given sizes and, well, the structure, creating a simple manual function "diff", comparing each of the members, is simple enough for each. However, as far as I can tell, entityState_t and playerState_t are only sent in whole, not in parts. The only thing that is "delta" ed is which objects are dispatched as part of the entity array in snapshot_t .

While a snapshot can contain up to 256 objects, the game itself can contain up to 1024 objects. This means that only 25% of objects can be updated from the point of view of the client in one frame (any other will lead to the shameful error "packet overflow"). The server simply keeps track of which objects had significant movement and sends them. This is much faster than executing the actual diff - just sending any object that is called “updating” by itself, and is inside the bit mask of the player’s visible area. But theoretically, a handwritten analysis of the structure would not be much more difficult to do.

For the health of the team, while Quake3 does not seem to do this, so I can only guess how it is done in Quake Live. There are two options: either send all playerState_t structures, since there are no more than 64 of them, or add another array to playerState_t to store the HP command, since it will be only 64 integers. The latter is much more likely.

To maintain an array of objects in synchronization between the client and server, each object has an entity index from 0 to 1023 and is sent as part of the message from the server to the client. When a client receives an array of 256 objects, it passes through the array, reads the index field from each and updates the object by the read index in its locally stored array of objects.

+3
source

I would suggest stepping back for a second and looking around to find a potentially better solution.

As you already mentioned, the state of the game can be really very complex and huge. Therefore, smart compression (diffs, compact serialized forms, etc.) is unlikely. In the end, it will be necessary to transfer a really big diff, so the gaming experience will suffer.

To keep the story short, I would suggest looking at two numbers (a link to the source will be provided).

You can translate a user action into a function call that changes the state of the game:

enter image description here

Or you can create an appropriate command / action, and let your team executor process this asynchronously, changing the state:

enter image description here

It may seem that the difference is quite small, but the second approach allows you to:

  • to avoid any race condition and update status safely by entering a queue for user actions
  • to handle actions from multiple sources (you just need to sort them correctly)

I just described a Command Pattern that can be very useful. This brings us to the idea of ​​a computed state . If the behavior of the commands is deterministic (as it should be) to get a new state, you just need the previous one and the command.

So, having the initial state (the same for each player or transferred at the very beginning of the game), only other teams will be required to perform the state increment.

As an example, I will use write-to-write and command logging (suppose your game state is in the database), because almost the same problem is being solved, and I have already tried to provide a detailed explanation :

enter image description here

This approach also makes it easier to restore state, etc., since the execution of actions can be really faster compared to the sequence generate new state, compare to the previous one, generate diff, compress diff, send diff, apply diff .

In any case, if you still think it's better to send diff, just try to make your state small enough (because you will have at least two snapshots) with a small and easily readable memory size.

In this case, the diff procedure will generate sparse data, so any stream compression algorithm will easily give a good compression ratio. BTW, make sure that your compression algorithm does not require even more memory. It is better not to use any dictionary-based solution.

Arithmetic coding , Radix Tree , most likely, Help. Here is the idea and implementation you can start with:

enter image description here

  public static void encode(int[] buffer, int length, BinaryOut output) { short size = (short)(length & 0x7FFF); output.write(size); output.write(buffer[0]); for(int i=1; i< size; i++) { int next = buffer[i] - buffer[i-1]; int bits = getBinarySize(next); int len = bits; if(bits > 24) { output.write(3, 2); len = bits - 24; }else if(bits > 16) { output.write(2, 2); len = bits-16; }else if(bits > 8) { output.write(1, 2); len = bits - 8; }else{ output.write(0, 2); } if (len > 0) { if ((len % 2) > 0) { len = len / 2; output.write(len, 2); output.write(false); } else { len = len / 2 - 1; output.write(len, 2); } output.write(next, bits); } } } public static short decode(BinaryIn input, int[] buffer, int offset) { short length = input.readShort(); int value = input.readInt(); buffer[offset] = value; for (int i = 1; i < length; i++) { int flag = input.readInt(2); int bits; int next = 0; switch (flag) { case 0: bits = 2 * input.readInt(2) + 2; next = input.readInt(bits); break; case 1: bits = 8 + 2 * input.readInt(2) +2; next = input.readInt(bits); break; case 2: bits = 16 + 2 * input.readInt(2) +2; next = input.readInt(bits); break; case 3: bits = 24 + 2 * input.readInt(2) +2; next = input.readInt(bits); break; } buffer[offset + i] = buffer[offset + i - 1] + next; } return length; } 

The final word: do not set the state, do not transfer it, but calculate. This approach will be faster, easier to implement and debug.

+3
source

When comparing bits, whether it is efficient to save the location of each changed bit depends on how many bits are changed. In a 32-bit system, each place takes 32 bits, so it is only effective when less than 1 in 32 bits is changed. However, since modified bits are usually contiguous, it would be more efficient if you compared larger units (e.g. bytes or words).

Note that your approach for comparing bits only works if the absolute locations of the bits do not change. If, however, some bits are inserted or deleted in the middle, your algorithm will see almost every bit after the inserted / deleted position as changed. To mitigate this, you would calculate the longest common subsequence, so only bits in A or B will be inserted / deleted.


But comparing JSON objects should not happen in different ways. (If you need, this is the same as comparing two bit strings.) Comparison can take place at a higher level. One way to calculate the difference between two abritrary JSON objects is to write a function that takes A and B and:

  • If the arguments are of different types, return "A" to B ".
  • If the arguments are of the same primitive type, do not return anything if they are the same, otherwise return "A changed to B".
  • If both arguments are dictionaries, the elements whose keys are only in A are deleted, and the keys only in B are those that are added. For items with the same key, compare recursively.
  • If both arguments are arrays, calculate the longest common subsequence A and B (can I check two elements of an equal recursive function) and find elements only in or B. So, elements only in are deleted, elements only in B are those that are added. Or perhaps elements that are not equal can also be compared recursively, but the only effective way that I can think of requires a method (e.g., record identifiers) to determine which element in corresponds to that element in B and compare element with the corresponding element.

The difference between the two lines can also be calculated using the longest common subsequence.

+1
source

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


All Articles