The development of an MMO RPG is a practical guide. Server (part 2)
- AI implementation: how to make it as simple as possible?
- RPC client-server: json or binary "self-righteousness"?
- Asynchronous sockets or multi-threaded architecture?
- Caching objects at the application level or more memory for the DBMS?
- Working with a database without the Reflection API: is it really that difficult?
Today we will continue to consider the architecture and implementation features of the C ++ game backend for an online game using 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.
Usually, AI implementation is a rather complicated process. But we managed to make it a "little blood" primarily through the use of Actions. In fact, AI represents a state machine that can fly somewhere, collect resources, attack other spaceships and move between systems. At the time of the creation of AI, the implementation of all these actions was already in Actions to control the player’s ship. That is, the whole spelling of TBaseAI is the creation of loading data from the database for the state machine and this machine itself of several actions, which is quite simple to implement.
A little difficulty appeared only after the introduction of such monsters as "Boss", "Golden Boss" and "Queen Swarm". They have specific skills that are only available to them. The implementation of these skills is entirely in their AI classes (TBossBaseAI, TGoldenBossAI and TMotherOfNomadAI).
Also, for AI, we had to create the TAISpaceShip class, a descendant of 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 it was attacked. To do this, we made him a descendant of ISpaceShipNotifyReciver and TSpaceShip sends him the necessary data. In other words, the right architectural solution allowed us to completely unify the communication of the Space module with the owner of the ship, whether it be a player or AI.
In conclusion, I give the class diagram in Fig. 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 such as LUA, which would allow "to write anything." But to work with LUA, you still need 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 to be a mediator. Considering that our quest system is very simple (as a result, we have only 25 teams), at the same time there can be no more than one quest, there are no branches in quests, we decided to create our quest parser. Here is an example from the quest file for the prolog 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 clip 1, fly to a point with coordinates 1000; -1900 in the start system, then show clip 2, etc. The file is so simple that even a game designer managed to teach him how to edit and add quests and balance the parameters he needed.
Architecturally, it looks like this. There is a TQuestParser class that actually parses quest files and contains a factory for TQuestCondition descendant classes. Each team has its own 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. 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 already killed nomads of a given type). It also simplifies the storage of quest data in the database - they are all collected in one place.
The owner object of the TQuest object must implement the IQuestUser interface (which contains commands such as AddQuestMoney, for example) and should 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 whether this is the ship that needed to be destroyed). TQuest forwards this event directly to the quest team and if the quest team has ended, it continues to the next team.
In general, this module is so simple that it does not even make sense to present its class diagram :).
RPC client <-> server (and Packets module).
When designing the 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 the TCP connection is used, so it is necessary to minimize the packet size so that the chance of packet loss (and its retransmission, TCP is still :) minimal. Losing a packet in TCP and relaying it is a rather long process that can lead to lags. Of course, I wanted to avoid this. The second, no less important point is the limited bandwidth of the network card. This may seem ridiculous, but I had to audit one game in which, due to the use of json and the polling-based system, rather than event-based, the developers ran into the bandwidth of the network card. And after that, all readable and beautifully named fields in json had to be called in the style of A, B, etc. in order to minimize packet size. As for AMF, this is a closed format from Adobe, so we decided not to mess with it - you never know if they decide to change it, and then look for a problem for us.
As a result, we 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 also need a code that will pack / unpack to / from the binary form the data structures themselves, as well as signal about incoming packets. And to do it the same way both on the server and on the client. Writing all this bunch of code with your hands in two languages (client and server), and then maintaining it is too troublesome. Therefore, we wrote a script in PHP that accepts XML with a description of all the packages and generates the necessary classes for the client and server from them. In addition to generating the actual package classes themselves and serializing them, another special additional class TStdUserProcessor is also generated for the server. This class contains callbacks for each type of package (which allows you to centrally control the types of received packages at this stage of work), and each callback creates an instance of the package class and loads binary data into it, after which it calls its handler. 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 TStdUserProcessor descendant class, a transparent bridge is implemented “client <-> server”, where sending a packet from the client is a simple method call in TUserProcessor.
And who calls these callbacks? TStdUserProcessor is a descendant of the TBaseUserProcessor class, which performs m_xSocket.Recv and also splits the binary stream into packets, finds the packet type in the header and finds the necessary callback by 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 "<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 multithreaded. The first one is, in general, faster than multi-threaded (because there is no switching of the context of the stream) and there are fewer problems with it: all in one stream and no problems with data desync or dead locks. The second gives a faster response to user actions (if not all resources were eaten by a large number of threads), but it brings a bunch of problems with multi-threaded data access. None of these approaches suited us, so we chose a mixed model - asynchronous-multithreaded. 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, movement between systems occurs with the help of hyper-gates. This division of the game world prompted us with an architectural solution - a separate stream is allocated for each solar system, work with sockets in this stream is performed asynchronously. It also creates several streams for servicing planets and transition states (hyperspace, loading / unloading data, etc.). In fact, a hyper-jump is a movement of a user's object from one thread to another. Such an architectural solution allows not only to easily scale the system (up to the allocation of a separate server for each solar system), but also greatly simplifies user interaction within the same solar system. But it is the flights and battles in space for the game that are critical. 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 the player’s data (and indeed all the data) are stored in memory until the end of the session. And saving to the database occurs only at the time of the end of the session (for example, the player exits the game). This allows you to significantly reduce the load on the database. Also, the TSQLObject object checks for changes in fields and makes UPDATE only for really changed fields. It is implemented as follows. When loading from the database, a copy of all downloaded data in the object is created. When SaveToDB () is called, a check is performed to see which fields are not equal to the values originally loaded, and only they are added to the request. After executing UPDATE in the database, copies of the fields are also updated with new values.
MySQL executes the INSERT command longer than the UPDATE command, so we tried 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 the unchanged objects. In addition, new objects that need to do INSERT immediately try to find a place in the signed for deletion, so as not to call the INSERT / DELETE pair, but to perform UPDATE.
Separately, I need to say about writing the value "NULL" to the database. Due to the peculiarities of the implementation of our TSQLObject, we cannot write and read “NULL” to / from the database. If the field in the class is of type “int”, then “NULL” is written to it as “0” and, accordingly, it is “0” that will be in the UPDATE query in the database (and not “NULL”, as it should be). And this can lead to problems - or the data will be incorrect, or, if this database field is foreign key, then the request will be completely erroneous. To solve this problem, we had to add TRIGGERS BEFORE UPDATE to the necessary tables, which would turn “0” into “NULL”.
Saving / loading objects to / from the database.
One of the problems with C ++ is the inability to recognize the string names of fields at run time and access them by string name. For example, in ActionScript, you can easily find out the names of all fields of an object, call any method, or call any field. This mechanism allows you to greatly simplify the work with the database - you do not need to write separate code for each class, list the fields that you need to save / load to / from the database, and in which table to do this. Fortunately, C ++ has such a powerful mechanism as template, which, together with
Using our library Reflection (how it works, we will analyze below) looks like this:
- Must be inherited from the TSQLObject class.
- It is necessary to write in the public descendant class 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 are displayed on which fields of the table, as well as the name of the class itself and the name of the table in the database. Using the TDevice class as an example, 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 void LoadFromDB (int id), void SaveToDB (), and void DeleteFromDB () methods. When called, the corresponding SQL queries to the device database table will be generated and the data from the fields specified in paragraph 3 will be loaded / saved.
All Reflection work is not based on the following ideas:
- By pointer to field using
, you can get a string name for the type of this field. And also 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 a field from this pointer, then we get the offset of this field relative to the pointer to the object. Of course, this may not work if virtual inheritance is applied, therefore Reflection should be applied to such classes with caution.
- Using one template class, it is possible, for any type for which the operators new, delete, =, and == are defined, to create a factory that can create, delete, assign and compare objects of this type. Add to this factory an ancestor with virtual methods that take in a pointer to an object, but not typed, but of type void *, and static_cast in the template itself, which will cast the passed void * to a pointer to the type with which the factory operates. And we will get the opportunity to operate on objects without knowing their type.
Now look inside the macros.
The DECL_SQL_DISPATCH_TABLE () macro does the following:
- virtual const string & SQLTableName (); - overloading the corresponding method from TSQLObject
- static void InitDispatchTable (); - a 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 a static class TCallFunctionBeforMain, which in the constructor calls InitDispatchTable. The purpose of this construction is to initialize the data necessary for Reflection before entering int main (), as well as to get rid of the need to manually register in Int main () a call to all InitDispatchTable from all classes.
- Creates an object factory
- Declares a variable ClassType * class_ptr = 0; (used in ADD_SQL_FIELD macros).
Macro ADD_SQL_FIELD (VisibleName, InternalName); does the following:
- Calculates offset fields from the start of an object
- Adds field offset to the list of fields of this object, the visible (external) name for it, and the string name of the field type.
Behind the scenes is the creation of actual type factories and the creation of converters string <-> object, as well as the storage location for all Reflection data. For storage there is a singleton class TGlobalDispatch. The same class in its constructor initializes factories and string converters for most simple types.
TSQLObject is based on the idea that using
DB and item ID.
All items in the game have a unique ID that allows you to identify the item. But data is saved in the database only at the end of the session, and the item can be created at any time. What to do 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. Firstly, you will not be able to add / collect / view players' items through the admin panel on PHP, you will need to write some additional code for this in C ++. And, secondly, the probability of an error in your server is significantly higher than in a DBMS. As a result of an error, data can lose integrity (after all, integrity is not controlled by the database now). And then this integrity will have to be restored manually, under the general howl of the players “I lost super-clothes, which I knocked out today. ” In general, the ID of the stored object in the database should be equal to the unique ID of the item in the game. And again we return to the question: where to get this ID from the newly created item? You can, of course, immediately save the item to the database, but this contradicts the idea of “everything is in memory, saving at the end of the session” and, most importantly, it will stop the stream, which can contain more than one user, until the saving is completed. And the stop can exceed the very maximum server response time to player actions (50ms), which is specified in the ToR. Asynchronous storage leads to other problems: for some time we will have an object without an ID. of course, immediately save the item to the database, but this contradicts the idea of “everything is in memory, saving at the end of the session” and, most importantly, it will stop the stream, which can contain more than one user, until the end of the save. And the stop can exceed the very maximum server response time to player actions (50ms), which is specified in the ToR. Asynchronous storage leads to other problems: for some time we will have an object without an ID. of course, immediately save the item to the database, but this contradicts the idea of “everything is in memory, saving at the end of the session” and, most importantly, it will stop the stream, which can contain more than one user, until the end of the save. And the stop can exceed the very maximum server response time to player actions (50ms), which is specified in the ToR. Asynchronous storage leads to other problems: for some time we will have an object without an ID.
The idea to solve the problem with ID came up quickly enough. ID is set to int. All items stored in the database have IDs greater than zero, and all newly created items have less than ID. Of course, this means that at some point in time, the object ID changes and this could lead to problems. It could be if the subject lived on. But preservation occurs at the end of the session, when items 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 a player has a character that he can wear, have skills, have magic, you can make 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 Ghosts server to create the required?
Rename TShip to TBody and it will be any HP carcass that can be attacked, be it PC or NPC. Let’s leave TDevice - it will be an item that can be put on a carcass (ring, cloak, dagger, etc.). We will rename the micromodules into runes, and with them it will be possible to strengthen the worn gear. Let’s leave TAmmoPack as well - after all, an elf may have a bow, and a bow needs arrows. TGoodsPack is also unchanged - these are any resources, but the witchers need all kinds of flowers and roots of mandrake for cooking potions. It remains only to solve the issue of magic. Add one dynamic parameter Mana to the carcass (TBody), create the class TSpellProto and TSpell, as well as the class TSpellBook, a descendant of TCargoBay. In TSpellBook, you can put only TSpell, and add TSpellBook to TBody. The ability to cast spells is a method similar to TShip :: FireSlot. Now we can create an elf or a dragon (or a boar or whoever we need there), dress him and “write” spells to his spell book. In fact, all the changes in this module come down to renaming classes and a little editing to add magic.
Rename the Space module to World, and the TSystem class to TLocation. We will move between locations using teleporters. Teleport, you guessed it, is a former TStarGate. Next, rename TSpaceObject to TWorldObject, and TSpaceShip to TWorldBody. Now our elf (or dragon) has the current coordinates and you can give him a command to move. True, he does not check the obstacles, but when turning, he cuts circles and strives to make a “barrel”. The logic of TSystem’s behavior and movement commands will have to be completely redone, and this will be the most expensive and difficult part of adapting the server to the game about elves.
If the Actions module was redone in the Space module, the AI module will work almost immediately, though without using magic. If NPCs must use magic, then you will have to add a code similar to using special weapons at the TBaseAI level.
The Quest module will be slightly modified, provided that the quest system is not a betrayal. Maximum - you have to change something in the spawn of objects in space (or rather, already in the location).
The Main module and Packets will remain virtually unchanged. Some packages for using magic will be added there, the hangar will be removed and that, in fact, is all. Replacing crafting recipes, types of buffs, etc. - this is all game design work, it is performed through the admin panel and has nothing to do with programming.
That's all, the server for the game about elves is ready. It remains only to wait six months until they draw the schedule and make the client :).