
- Implementing AI: how to make it as simple as possible?
- RPC client-server: json or binary "self-patch"?
- Asynchronous sockets or multi-threaded architecture?
- Caching objects at the application level or more memory for the DBMS?
- Working with a database without Reflection API: Is it really that difficult?
Today we will continue to look at the architecture and features of the implementation of the game backend in C ++ for online games on the example of MMO RPG
"Star Ghosts" . This is the second part of the article about the server, the beginning can be read
here .
AI module.
An AI implementation is usually a rather complicated process. But we managed to do it with “little blood” primarily due to the use of Actions. In fact, AI represents a finite state machine that can fly somewhere, collect resources, attack other spacecraft and move between systems. At the time AI was created, the implementation of all these actions was already in Actions to control the player’s ship. That is, all TBaseAI writing is the creation of loading data from a database for a finite state machine and this automaton from several actions, which is quite simple to implement.
Some difficulties appeared only after the introduction of monsters such as "Boss", "Golden Boss" and "Queen Roy". They have specific skills that are available only to them. The implementation of these skills is entirely in their AI classes (TBossBaseAI, TGoldenBossAI, and TMotherOfNomadAI).
Also for AI, I had to create a TAISpaceShip class, a descendant from TSpaceShip, which contains an instance of TBaseAI and calls TBaseAI :: Update from its Update.
AI should receive messages about what is happening, for example, that he was attacked. To do this, we made it a descendant of ISpaceShipNotifyReciver and TSpaceShip sends it the necessary data. In other words, the right architectural solution allowed us to completely unify the communication of the Space module with the ship owner, be it a player or an AI.
In conclusion, I cite the class diagram in Figure 4 (for greater clarity, the diagram is somewhat simplified).

Quest module.
At the stage of choosing the implementation of the quest system, the first thought was to use some kind of scripting language like LUA, which would allow “to write anything.” But to work with LUA, it is still necessary to export methods and callbacks to the LUA machine itself, which is very inconvenient and leads to writing a lot of additional code that does nothing except as a mediator. Considering that the quest system is very simple (as a result, we have only 25 teams), there can be no more than one quest at the same time, there are no branches in the quests, we decided to do our own parser of quests. Here is an example from the quest prolog file tutor.xmq:
')
show reel 1 then flyto 1000 -1900 insys StartSystem then show reel 2 then flyto 950 -1450 insys StartSystem then show reel 3 then spawn_oku ship 1 000_nrds near_user -400 200 insys StartSystem
Offhand, everything is clear: show movie 1, fly to a point with coordinates 1000; -1900 in the launch system, then show movie 2, etc. The file is so simple that we even managed a game designer to teach him to edit and complete quests and balance the parameters he needs.
Architecturally it looks like this. There is a TQuestParser class that actually parses the quest files and contains a factory for the descendant classes of TQuestCondition. For each team there is a descendant from TQuestCondition, which implements the necessary functionality. At the same time, the quest command classes themselves do not contain any data (except for those loaded directly from the quest file), all their methods are declared as const. The data is contained in the TQuest class. This allows you to contain only one copy of the quest teams, "slipping" their necessary data specific to a particular user (for example, how many he has killed nomads of a given type). It also simplifies saving quest data in the database - they are all collected in one place.
The TQuest object owner object must implement the IQuestUser interface (which contains commands such as AddQuestMoney, for example) and must report events to TQuest (for example, when any ship is destroyed, a message with its signature is sent to TQuest so that the quest team can compare Is this the ship that needed to be destroyed). TQuest, however, forwards this event directly to the quest team, and if the quest team has ended, it proceeds to the next team.
In general, this module is as simple as it even makes no sense to give it a class diagram :).
RPC client <-> server (and Packets module).
When designing a network subsystem, the first desire was to use json or AMF (because the client is on a flash, and this is the native binary flash format). Almost immediately both of these ideas were discarded: the game is in real time and a TCP connection is used, so it is necessary to minimize the size of the packet so that the chance of packet loss (and its retransmission, TCP is after all :) minimal. Packet loss in TCP and its retransmission is a rather long process that can lead to lags. Of course, this wanted to be avoided. The second, equally important point is the limited bandwidth capacity of the network card. This may seem ridiculous, but I had to do an audit of one game, in which, because of the use of json and the polling-based system, rather than event-based, the developers rested precisely on the bandwidth of the network card. And after that, all the readable and beautifully named fields in json had to be called in the style of A, B, etc. in order to minimize the size of the package. As for AMF, this is a closed format from Adobe, so we decided not to get involved with it - you never know what they decide to change, and then look for the problem.
As a result, we have implemented a very simple package format. It consists of a header containing the full length of the packet and the type of packet. But you still need the code that will package / unpack the data structures themselves to / from the binary type, as well as signal incoming packets. And do it the same way on the server and on the client. Writing the whole bunch of code with your hands in two languages ​​(client and server), and then maintaining it is too troublesome. Therefore, we wrote a script on PHP that takes XML describing all the packages and generates the necessary classes for the client and server. In addition to generating the actual classes of the packages themselves and serializing them, another special additional class TStdUserProcessor is generated for the server. This class contains callbacks for each type of packet (which allows you to centrally manage the types of packets received at this stage of work), and each callback creates an instance of the package class and loads binary data into it, after which the handler calls it. In code, it looks like this:
virtual void OnClientLoginPacket(TClientLoginPacket& val)=0; void OnClientLoginPacketRecived(TByteOStream& ba); void TStdUserProcessor::OnClientLoginPacketRecived(TByteOStream& ba) { TClientLoginPacket p; ba>>p; OnClientLoginPacket(p); }
That is, for the descendant class from TStdUserProcessor, a transparent “client <-> server” bridge is implemented, where sending a packet from a client is a simple method call to TUserProcessor.
And who causes these callbacks? TStdUserProcessor is a descendant from the TBaseUserProcessor class, which executes m_xSocket.Recv, as well as splits the binary stream into packets, finds the packet type in the header and finds the necessary callback for this type. It looks like this:
void TStdUserProcessor::AddCallbacks() { AddCallback( NNNetworkPackets::ClientLogin, &TStdUserProcessor::OnClientLoginPacketRecived ); } void TBaseUserProcessor::RecvData() { if( !m_xSocket || m_xSocket->State()!=NNSocketState::Connected ) return; if( !m_xSocket->AvailData() ) return; m_xReciver.RecvData(); if( !m_xReciver.IsPacketRecived() ) return; // , "" int type = m_xReciver.Data()->Type; if( type>=int(m_vCallbacks.size()) ) _ERROR("NoCallback for class "<<type); SLocalCallbackType cb = m_vCallbacks[type]; if(cb==NULL) _ERROR("NoCallback for class "<<type); TStdUserProcessor* c_ptr = (TStdUserProcessor*)this; const uint8* data_ptr = (const uint8*)m_xReciver.Data(); data_ptr += sizeof(TNetworkPacket); TByteOStream byte_os( data_ptr, m_xReciver.Data()->Size-sizeof(TNetworkPacket) ); if( m_xIgnoreAllPackets==0 ) { (*c_ptr.*cb)( byte_os ); } m_xReciver.ClearPacket(); }
Socket model
Now we will talk, perhaps, about the most interesting - about the used socket model. There are two "classic" approaches to working with sockets: asynchronous and multi-threaded. The first one is generally faster than multi-threaded (because there is no context switch for the stream) and there are fewer problems with it: everything is in one stream and no problems with data out-of-sync or dead locks. The second gives a faster response to a user action (if not all resources are eaten by a large number of threads), but carries a lot of problems with multi-threaded data access. None of these approaches did not suit us, so we chose a mixed model - asynchronous-multi-threaded. I will explain in more detail.
"Star Ghosts" - a game about space, so the game world is divided into locations initially. Each solar system is a separate location, moving between systems takes place with the help of hyper-gates. This division of the game world and we suggested an architectural solution - a separate stream is allocated to each solar system, work with sockets in this stream is performed asynchronously. Also, several threads are created for servicing the planets and transition states (hyperspace, data loading / unloading, etc.). In fact, hyper-jump is the movement of a user object from one thread to another. Such an architectural solution makes it possible not only to scale the system easily (up to the separation of a separate server for each solar system), but also greatly simplifies the interaction of users within the same solar system. But it is flying and fighting in space for the game are critically important. The bonus is the automatic use of multi-core architecture and the almost complete absence of synchronization objects - players from different systems practically do not interact with each other, and, being in the same system, they are in the same thread.
Work with the database.
In
"Star Ghosts" all player data (and indeed all data) are stored in memory until the end of the session. And saving to the database takes place only at the moment when the session ends (for example, when a player leaves the game). This can significantly reduce the load on the database. Also, the TSQLObject object checks the change of fields and makes UPDATE only to actually changed fields. It is implemented as follows. When loading from the database, a copy of all the loaded data in the object is created. When you call SaveToDB (), it checks which fields are not equal to the values ​​originally loaded, and only they are added to the query. After performing UPDATE in the database, copies of the fields are also updated with new values.
MySQL runs the INSERT command longer than the UPDATE command, so we sought to reduce the number of INSERTs. In the first implementation of storing user data in the database, data on all items in the database was erased and re-entered. Very quickly, players accumulated hundreds (and some thousands) of items, and such an operation became very expensive and long. Then the algorithm had to be changed - do not touch unchanged objects. In addition, new objects that need to do INSERT, immediately try to find a place in the subscribed to delete, in order not to call the INSERT / DELETE pair, but to execute an UPDATE.
Separately, you need to say about writing the value "NULL" in the database. Due to the implementation features of our TSQLObject, we cannot write and read "NULL" to / from the database. If a field in the class is of type “int”, then “NULL” will be written into it as “0” and, accordingly, exactly “0” will appear in the UPDATE query in the database (and not “NULL”, as it should be). And this can lead to problems - either the data will be wrong, or, if this is a foreign key database field, the request will be generally erroneous. To solve this problem, we had to add BEFORE UPDATE TRIGGERs to the required tables, which would make “0” NULL.
Saving / loading objects to / from the database.
One of the problems with C ++ is the inability to find out at runtime the string names of the fields and refer to them by the string name. For example, in ActionScript, you can easily find out the names of all the fields of an object, call any method, or access any field. This mechanism allows you to significantly simplify work with the database - you do not need to write a separate code for each class, the maximum is to list the list of fields that you need to save / load to / from the database, and in which table to do it. Fortunately, C ++ has such a powerful mechanism as a template, which, together with <cxxabi>, allows us to solve the problem of the lack of Reflection API applied to the task of working with the database.
Using our Reflection library (we will analyze it below) looks like this:
- It is necessary to inherit from the class TSQLObject.
- It is necessary in the descendant class itself to write in the public section DECL_SQL_DISPATCH_TABLE (); (this is a macro).
- In the .cpp file of the descendant class, list which fields of the class to which fields of the table are displayed, as well as the name of the class itself and the name of the table in the database. Using the example of the TDevice class, it looks like this:
BEGIN_SQL_DISPATCH_TABLE(TDevice, device) ADD_SQL_FIELD(PrototypeID, m_iPrototypeID) ADD_SQL_FIELD(SharpeningCount, m_iSharpeningCount) ADD_SQL_FIELD(RepairCount, m_iRepairCount) ADD_SQL_FIELD(CurrentStructure, m_iCurrentStructure) ADD_SQL_FIELD(DispData, m_sSQLDispData) ADD_SQL_FIELD(MicromoduleData, m_sMicromodule) ADD_SQL_FIELD(AuthorSign, m_sAuthorSign) ADD_SQL_FIELD(Flags, m_iFlags) END_SQL_DISPATCH_TABLE()
- Now, at run time, you can call the methods void LoadFromDB (int id), void SaveToDB () and void DeleteFromDB (). During the call, the corresponding SQL queries will be generated for the database device table and the data from the fields specified in subclause 3 will be loaded / saved.
All Reflection work is not based on the following ideas:
- Using the pointer to the field, using <cxxabi>, you can get a string name for the type of this field. As well as for the class - a list of its ancestors.
- If we create a pointer to an object, equate it to 0 and take a pointer to the field from this pointer, we get the offset of this field relative to the pointer to the object. Of course, this may not work if you use virtual inheritance, so you should apply Reflection to such classes with caution.
- Using the same template class, for any type that has the new, delete, =, and == operators defined, create a factory that can create, delete, assign, and compare objects of this type. Add an ancestor to this factory with virtual methods that take a pointer to an object, but not typed, but of type void *, and static_cast in the template itself, which will lead the transferred void * to a pointer to the type with which the factory operates. And we will get the opportunity to operate with objects without knowing their type.
Now look inside the macro.
The macro DECL_SQL_DISPATCH_TABLE () does the following:
- virtual const string & SQLTableName (); - overload of the corresponding method from TSQLObject
- static void InitDispatchTable (); - the method of initializing the data needed by Reflection to work with this object
Macro BEGIN_SQL_DISPATCH_TABLE (ClassType, TableName); does the following:
- Implements the SQLTableName () method;
- Declares the static class TCallFunctionBeforMain, which in the constructor calls InitDispatchTable. The task of such a construction is to initialize the data necessary for Reflection before entering int main (), and also to get rid of the need to manually register in the int main () call of all InitDispatchTable from all classes.
- Creates an object factory
- Declares the variable ClassType * class_ptr = 0; (used in ADD_SQL_FIELD macros).
Macro ADD_SQL_FIELD (VisibleName, InternalName); does the following:
- Calculates the offset field from the beginning of the object.
- Adds to the list of fields of this object the offset of the field, the visible (external) name for it and the string name of the field type.
Behind the scenes, the creation of the actual type factory and the creation of converters string <-> object, as well as the storage location of all the Reflection data. For storage there is a class singleton TGlobalDispatch. The same class in its constructor initializes factories and string converters for most simple types.
The work of TSQLObject is based on the idea that using <cxxabi> you can get the actual string name of the object at run time. By this name, request from TGlobalDispatch a list of all published fields of this class and its ancestors. The table name can be obtained by calling SQLTableName (). String converters for fields will also provide TGlobalDispatch. Now it is not difficult to create the necessary SQL query and load / unload the object.
DB and ID items.
All items in the game have a unique ID that allows you to identify the item. But saving data to the database occurs only at the end of the session, and the item can be created at any time. How to deal with the item ID? You can remove the data integrity check at the database level (disable AUTO_INCREMENT and PRIMARY KEY) and generate unique keys at the C ++ level. But this is a bad way. First, you will not be able to add / pick up / view player items through the PHP admin panel; you will need to write some additional code in C ++ to do this. And, secondly, the probability of an error in your server is significantly higher than in a DBMS. As a result, data errors may lose integrity (after all, integrity is now not controlled by the database). And then this integrity will have to be restored manually, under the general howl of the players, “I have lost a super-gear, which I knocked out today”. In general, the ID of the saved object in the database must be equal to the unique ID of the item in the game. And back to the question: where to get this ID from the newly created item? You can, of course, immediately save the item in the database, but this is contrary to the idea of ​​“everything in memory, saving at the end of the session” and, most importantly, will cause the flow to stop, in which more than one user can be, before the end of the save. And the stop can exceed the very maximum response time of the server to the player's actions (50ms), which is specified in the statement of work. Asynchronous saving pulls other problems behind itself: for some time we will have an object without an ID.
The idea of ​​solving the problem with the ID appeared fairly quickly. ID is set to type "int". All items stored in the database have an ID greater than zero, and all newly created items have less. Of course, this means that at some point in time the object ID will change and this could lead to problems. Could, if the subject lived on. But the preservation occurs at the moment when the session ends, when the objects are already in the queue for destruction and nothing can be done with them.
The game "about the elves."
Suppose we need to make a game in which the player has a character that he can dress, have skills, have magic, you can brew potions, a character can run around locations and kill other characters, get levels and trade with other characters. What needs to be done with the current
Star Ghost server to create the required?
Rename TShip to TBody and this will be any bird with HP that can be attacked, be it a PC or NPC. TDevice and leave - it will be a subject that can be put on the carcass (ring, cloak, dagger, etc.). We will rename micromodules into runes, and they will be able to enhance the clothing they wear. TAmmoPack, too, so we leave - after all, an elf can have a bow, and the bow needs arrows. TGoodsPack is also unchanged - these are any resources, and after all, the witches need all sorts of flowers and roots of mandrake for cooking potions. It remains only to resolve the issue with magic. Add one dynamic parameter Mana to the carcass (TBody), create a TSpellProto and TSpell class, as well as a TSpellBook class, descendant from TCargoBay. Only TSpell can be put into TSpellBook, and TSpellBook itself can be added to TBody. The ability to cast spells is a method similar to TShip :: FireSlot. Now we can create an elf or dragon (or a boar or whoever we need), dress him up and “write” spells in his spell book. In fact, all changes in this module are reduced to renaming classes and a small revision to add magic.
Rename the Space module to World, and the TSystem class to TLocation. Moving between locations will do with teleports. Teleport, you guessed it, is a former TStarGate. Next, rename TSpaceObject to TWorldObject, and TSpaceShip to TWorldBody. Now our elf (or dragon) has current coordinates and can be given a command to move. True, he does not check the obstacles, but when turning, he cuts circles and tries to make a “barrel”. The logic of the behavior of TSystem and movement teams will have to be completely redone, and this will be the most costly and difficult part of the work of adapting the server to the game about elves.
If the Space module has been reworked including the Actions, the AI ​​module will work almost immediately, but without using magic. If NPCs must use magic, then they will have to add a code similar to the use of special weapons at the TBaseAI level.
Quest module will be slightly modified, provided that the quest system is not changed. Maximum - you have to change something in the spawn of objects in space (or rather, already in the location).
Module Main and Packets will remain virtually unchanged. There will be added some packages for the use of magic, the hangar will be removed and that’s all. Replacing crafting recipes, buff types, etc.
- this is all game design work, performed through the admin panel and has no relation to programming.That's all, the server for the game about elves is ready. It remains only to wait six months, until they draw the graphics and make the client :).