Meta Crush Saga: a compilation game

image

In the process of moving to the long-awaited title of Lead Senior C ++ Over-Engineer , last year I decided to rewrite the game that I develop during working hours (Candy Crush Saga), using the quintessence of modern C ++ (C ++ 17). And so Meta Crush Saga was born: a game that is executed at the compilation stage . I was very inspired by the game Nibbler by Matt Byrneer, in which to recreate the famous “Snake” with the Nokia 3310, pure template metaprogramming was used.

“What kind of game is performed at the compilation stage ?”, “What does it look like?”, “What functionality did you use in C ++ 17 in this project?”, “What did you learn?” - such questions can come to your mind . To answer them, you will have to either read the entire post, or come to terms with your inner laziness and watch the video version of the post - my report to the Meetup event in Stockholm:


Note: for the sake of your mental health and due to the fact that errare humanum est , this article provides some alternative facts.

A game that runs at compile time?



I think that in order to understand what I mean by the “concept” of a game that is executed at the compilation stage , you need to compare the life cycle of such a game with the life cycle of a regular game.

The life cycle of an ordinary game:


As an ordinary game developer with a normal life, working in a normal job with a normal level of mental health, you usually start by writing game logic in your favorite language (in C ++, of course!), And then run the compiler to convert this, too often similar to spaghetti , logic to executable file . After double clicking on the executable file (or running from the console), the operating system spawns the process . This process will perform the game logic , which in 99.42% of the time consists of a game cycle . The game cycle updates the state of the game in accordance with certain rules and user input , renders a new calculated game state in pixels, again, and again, and again.


The life cycle of the game being executed during the compilation process:


As an over-engineer, creating your own awesome compilation game, you still use your favorite language (still C ++, of course!) To write game logic . Then the compilation phase is still going on , but here the plot rotates: you execute your game logic at the compilation stage. You can call this “compilation”. And here C ++ is very useful; it has functions such as Template Meta Programming (TMP) and constexpr , which allow you to perform calculations in the compilation phase . Later we will consider the functionality that can be used for this. Since at this stage we are performing the logic of the game, at this moment we also need to insert the player’s input . Obviously, our compiler will still create an executable file on output. What can it be used for? The executable file will no longer contain a game loop , but it has a very simple mission: output a new computed state . Let's call this executable file a renderer , and the data it displays is rendering . In our rendering, neither beautiful particle effects nor ambient occlusion shadows will be contained; it will be ASCII. Rendering in ASCII of a newly computed state is a convenient feature that can be easily demonstrated to the player, but in addition, we copy it to a text file. Why a text file? Obviously, because it can somehow be combined with the code and re-execute all the previous steps, thus obtaining a cycle .

As you can already understand, the game executed during the compilation process consists of a game cycle , in which each frame of the game is a compilation stage . Each compilation step calculates a new game state that can be shown to the player and inserted into the next frame / compilation step .

You can contemplate this magnificent diagram as long as you like until you understand what I have just written:


Before we go into the details of the implementation of such a cycle, I am sure that you want to ask me a single question ...

“Why bother to do this at all?”



Do you really think that ruining my C ++ metaprogramming idyll is such a fundamental question? No way in life!


Well, how? Are you satisfied with my answers? Then maybe your question should be: “How do you do it at all?”

In fact, I really wanted to experiment with the functionality added in C ++ 17 . Quite a few features are designed in it to improve the efficiency of the language, as well as for metaprogramming (mainly constexpr). I thought that instead of writing small code examples, it would be much more interesting to turn it all into a game. Pet projects are a great way to explore concepts that are not so often used in work. The ability to perform the basic logic of the game at compile time again proves that the templates and constepxr are Turing-complete subsets of the C ++ language.

Meta Crush Saga: Game Review


Match-3 game:


Meta Crush Saga is a tile mix game similar to Bejeweled and Candy Crush Saga . The core of the rules of the game is to combine three tiles with the same pattern to get points. Here's a quick look at the state of the game , which I “dumped” (a dump in ASCII is pretty darn simple):

 R "(Meta crush saga ------------------------ | | | RBGBBYGR | | | | | | YYGRBGBR | | | | | | | RBYRGRYG | | | | | | | RYBY (R) YGY | | | | | | BGYRYGGR | | | | | | | RYBGYBBG | | | ------------------------ > score: 9009> moves: 27) " 


By itself, the gameplay of this game Match-3 is not particularly interesting, but what about the architecture on which it all works? So that you understand it, I will try to explain every part of the life cycle of this game compilation time in terms of code.

Injection of game state:



If you are a passionate C ++ amateur or a pedant, you may have noticed that the previous dump of the game state begins with the following pattern: R "( . In fact, this is a raw C ++ 11 string literal , meaning that I do not need to escape special characters, for example, strings .The raw string literal is stored in a file called current_state.txt .

How do we inject this current state of the game into a compilation state? Let's just add it to the loop inputs!

// loop_inputs.hpp constexpr KeyboardInput keyboard_input = KeyboardInput::KEYBOARD_INPUT; //       constexpr auto get_game_state_string = []() constexpr { auto game_state_string = constexpr_string( //       #include "current_state.txt" ); return game_state_string; }; 

Whether it is a .txt file or a .h file, the include directive from the C preprocessor will work the same way: it copies the contents of the file to its location. Here I am copying the raw game state literal string in ascii to a variable called game_state_string .

Note that the header file loop_inputs.hpp also reveals the keyboard entry for the current frame / compilation step. Unlike the state of the game, the state of the keyboard is quite small and can be easily obtained as a definition of a preprocessor.

Calculating the new state at compile time:



Now that we have collected enough data, we can calculate the new state. Finally, we have reached the point where we need to write the main.cpp file:

 // main.cpp #include "loop_inputs.hpp" //   ,   . // :    . constexpr auto current_state = parse_game_state(get_game_state_string); //      . constexpr auto new_state = game_engine(current_state) //    , .update(keyboard_input); //  ,    . constexpr auto array = print_game_state(new_state); //      std::array<char>. // :    . //  :   . for (const char& c : array) { std::cout << c; } 

Strange, but this C ++ code doesn't look so confusing, considering what it does. The main part of the code is executed in the compilation phase, but follows the traditional paradigms of OOP and procedural programming. Only the last line - rendering - is an obstacle to fully perform calculations at compile time. As we will see below, by throwing a little constexpr in the right places, we can get a rather elegant metaprogramming in C ++ 17. I find the delightful freedom that C ++ gives us when it comes to mixed execution at runtime and compilation.

You will also notice that this code executes only one frame; there is no game loop here. Let's solve this question!

We glue everything together:



If you are disgusted with my tricks with C ++ , I hope you will not mind to see my skills Bash . In fact, my game loop is nothing more than a bash script that constantly compiles.

 #  !  ,    !!! while; do : #      G++ g++ -o renderer main.cpp -DKEYBOARD_INPUT="$keypressed" keypressed=get_key_pressed() #  . clear #   current_state=$(./renderer) echo $current_state #    #     current_state.txt file       . echo "R\"(" > current_state.txt echo $current_state >> current_state.txt echo ")\"" >> current_state.txt done 

In fact, I had a little trouble getting keyboard input from the console. Initially, I wanted to get in parallel with the compilation. After a lot of trial and error, I managed to get something more or less working with the read command from Bash . I never dare to fight with the wizard Bash in a duel - this language is too ominous!

So, I have to admit that in order to control the cycle of the game I had to resort to another language. Although technically nothing prevented me from writing this part of the C ++ code. In addition, this does not negate the fact that 90% of the logic of my game is executed within the g ++ compilation command, which is quite amazing!

A bit of gameplay to give your eyes a rest:


Now that you have experienced the torment of explaining the architecture of the game, it is time for eye-catching pictures:


This pixelated gif is a recording of how I play Meta Crush Saga . As you can see, the game runs smoothly enough to be playable in real time. Obviously, she is not so attractive that I could stream her Twitch and become a new Pewdiepie, but it works!

One of the fun aspects of storing game status in a .txt file is the ability to cheat or very conveniently test marginal cases.

Now that I’ve briefly introduced you to architecture, we’ll delve into the C ++ 17 functionality used in this project. I will not look at the game logic in detail, because it applies exclusively to Match-3, and instead I will talk about aspects of C ++ that can be applied in other projects.

My lessons on C ++ 17:



Unlike C ++ 14, which mostly contained minor fixes, the new C ++ 17 standard can offer us a lot. There were hopes that the long-awaited opportunities (modules, modules, concepts ...) would finally emerge, but ... well ... they did not appear; it upset many of us. But removing the mourning, we found many small unexpected treasures, which still fell into the standard.

I dare say that loving metaprogramming kids this year too spoiled! Separate minor changes and additions to the language now allow you to write code that looks very similar at compile time and after run time.

Constepxr to all fields:


As Ben Dean and Jason Turner predicted in their C ++ 14 report , C ++ allows you to quickly improve the compile value computation thanks to the all-powerful keyword constexpr . By placing this keyword in the right places, you can tell the compiler that the expression is constant and can be calculated directly at compile time. In C ++ 11, we could already write such code:

 constexpr int factorial(int n) //    constexpr       . { return n <= 1? 1 : (n * factorial(n - 1)); } int i = factorial(5); //  constexpr-. //      : // int i = 120; 

Although the constexpr keyword is very powerful, it has quite a few restrictions on usage, making it difficult to write expressive code in a similar way.

C ++ 14 greatly reduced the requirements for constexpr and became much more natural to use. Our previous factorial function can be rewritten as follows:

 constexpr int factorial(int n) { if (n <= 1) { return 1; } return n * factorial(n - 1); } 

C ++ 14 got rid of the rule that the constexpr function should consist of only one return statement, which made us use the ternary operator as the main building block. Now C ++ 17 brings even more constexpr keyword scopes that we can explore!

Compile branching:


Have you ever been in a situation where you need to get different behavior depending on the parameter of the template that you are manipulating? Suppose we need a parameterized serialize function that will call .serialize() if the object provides it, and otherwise resorts to to_string it to_string . As explained in more detail in this post about SFINAE , you will most likely have to write such alien code:

 template <class T> std::enable_if_t<has_serialize_v<T>, std::string> serialize(const T& obj) { return obj.serialize(); } template <class T> std::enable_if_t<!has_serialize_v<T>, std::string> serialize(const T& obj) { return std::to_string(obj); } 

Only in a dream could you rewrite this ugly trick with SFINAE trick in C ++ 14 into such magnificent code:

 // has_serialize -  constexpr-,  serialize  . // .    SFINAE,  ,    . template <class T> constexpr bool has_serialize(const T& /*t*/); template <class T> std::string serialize(const T& obj) { //  ,  constexpr    . if (has_serialize(obj)) { return obj.serialize(); } else { return std::to_string(obj); } } 

Unfortunately, when you woke up and started writing real C ++ 14 code , your compiler spewed an unpleasant call message about serialize(42); . It explained that the object obj type int does not have a member function serialize() . No matter how infuriated you are, the compiler is right! With this code, it will always try to compile both branches - return obj.serialize(); and
return std::to_string(obj); . For int branch return obj.serialize(); it may well turn out to be some kind of dead code, because has_serialize(obj) will always return false , but the compiler will still have to compile it.

As you probably guessed, C ++ 17 relieves us of such an unpleasant situation, because it has the ability to add constexpr after an if statement to “forcefully execute” branching during compilation and discard unused constructs:

 // has_serialize... // ... template <class T> std::string serialize(const T& obj) if constexpr (has_serialize(obj)) { //     constexpr   'if'. return obj.serialize(); //    ,    ,  obj  int. } else { return std::to_string(obj);branch } } 


Obviously, this is a huge improvement over the SFINAE trick that we had to use earlier. After that, we started to have the same addiction as Ben and Jason - we started using constexpr everywhere and always. Alas, there is another place where the constexpr keyword is appropriate , but not yet used: constexpr-parameters .

Constexpr parameters:


If you are attentive, you might have noticed a strange pattern in the previous code example. I'm talking about loop inputs:

 // loop_inputs.hpp constexpr auto get_game_state_string = []() constexpr // ? { auto game_state_string = constexpr_string( //       #include "current_state.txt" ); return game_state_string; }; 

Why is the game_state_string variable encapsulated in a constexpr-lambda? Why doesn't it make it global constexpr ?

I wanted to pass this variable and its contents deep into some functions. For example, my parse_board needs to be passed to it and used in some constant expressions:

 constexpr int parse_board_size(const char* game_state_string); constexpr auto parse_board(const char* game_state_string) { std::array<GemType, parse_board_size(game_state_string)> board{}; // ^ 'game_state_string' -   - // ... } parse_board(“...something...”); 

If we go this way, then the grumbling compiler will start complaining that the game_state_string parameter is not a constant expression. When I create my tile array, I need to directly calculate its fixed capacity (we cannot use vectors during compilation, because they require memory allocation) and pass it as an argument to the value template in std :: array . Therefore, the expression parse_board_size (game_state_string) must be a constant expression. Although parse_board_size is explicitly marked as constexpr , game_state_string is not, and cannot be! In this case, two rules bother us:


It all boils down to the fact that constexpr-functions MUST be applicable in calculations and runtime, and compile time. If we assume the existence of constexpr-parameters , then this will not allow using them at runtime.


Fortunately, there is a way to level this problem. Instead of accepting a value as a normal function parameter, we can encapsulate this value in a type and pass this type as a template parameter:

 template <class GameStringType> constexpr auto parse_board(GameStringType&&) { std::array<CellType, parse_board_size(GameStringType::value())> board{}; // ... } struct GameString { static constexpr auto value() { return "...something..."; } }; parse_board(GameString{}); 

In this code example, I create a GameString structured type that has a static constexpr value () member function that returns a string literal that I want to pass to parse_board . In parse_board, I get this type through the GameStringType template parameter using the template argument extraction rules. Having GameStringType , due to the fact that value () is constexpr, I can simply call the static member function value () at the right time to get a string literal even in those places where constant expressions are needed.

We managed to encapsulate the literal to somehow pass it to the parse_board using constexpr. However, it is very annoying to define a new type every time you need to send a new parse_board literal: "... something1 ...", "... something2 ...". To solve this problem in C ++ 11 , it was possible to use some kind of ugly macro and indirect addressing using an anonymous union and lambda. Mikael Park explained this topic well in one of his posts .

In C ++ 17, the situation is even better. If we list the requirements for passing our string literal, we get the following:


These requirements should give you a hint about something. What we need is constexpr-lambda ! And in C ++ 17 , the possibility of using the constexpr keyword for lambda functions was added quite naturally. We can rewrite our sample code as follows:

 template <class LambdaType> constexpr auto parse_board(LambdaType&& get_game_state_string) { std::array<CellType, parse_board_size(get_game_state_string())> board{}; // ^      constexpr-. } parse_board([]() constexpr -> { return “...something...”; }); // ^    constexpr. 

Believe me, this already looks much more convenient than the previous hacking on C ++ 11 using macros. I discovered this amazing trick thanks to Björn Fachler , a member of the C ++ mitapest group in which I participate. Read more about this trick in his blog . It is also worth considering that in fact the constexpr keyword is not necessary in this case: all lambdas with the possibility of becoming constexpr will be their default. Explicit adding constexpr is a signature that simplifies our search for errors.

Now you need to understand why I was forced to use constexpr- lambda to send down a line representing the state of the game. Look at this lambda function, and you will have another question again. What is the constexpr_string type that I also use to wrap the stock literal?

constexpr_string and constexpr_string_view:

When working with strings, you should not process them in the C style. You need to forget all these annoying algorithms that perform raw iterations and check for zero completion! The alternative proposed by C ++ is the almighty std :: string and STL algorithms . Unfortunately, heap memory may be required to store its contents in std :: string (even with Small String Optimization). One or two standards back, we could use constexpr new / delete or we could pass constexpr distributors to std :: string , but now we need to find another solution.

My approach was to write a fixed-capacity constexpr_string class. This capacity is passed as a value template parameter. Here is a brief overview of my class:

 template <std::size_t N> // N -    . class constexpr_string { private: std::array<char, N> data_; //  N char   -. std::size_t size_; //   . public: constexpr constexpr_string(const char(&a)[N]): data_{}, size_(N -1) { //   data_ } // ... constexpr iterator begin() { return data_; } //    . constexpr iterator end() { return data_ + size_; } //     . // ... }; 

My class constexpr_string aims to imitate the std :: string interface as closely as possible (for the operations I need): we can query the start and end iterators , get the size (size) , access the data (data) , delete (erase) their part, get substring using substr and so on. This makes it very easy to convert a code snippet from std :: string to constexpr_string . You may wonder what will happen when we need to use operations that usually require a selection in std :: string . In such cases, I was forced to convert them to immutable operations that create a new instance of constexpr_string .

Let's take a look at the append operation:

 template <std::size_t N> // N -    . class constexpr_string { // ... template <std::size_t M> // M -    . constexpr auto append(const constexpr_string<M>& other) { constexpr_string<N + M> output(*this, size() + other.size()); // ^    . ^     output. for (std::size_t i = 0; i < other.size(); ++i) { output[size() + i] = other[i]; ^     output. } return output; } // ... }; 


You do not need to have a Fields premium to assume that if we have a string of size N and a string of size M , then a string of size N + M will be enough to store their concatenation. We may waste part of the “compile-time store”, since both lines may not use all the capacity, but this is a rather small price for convenience. Obviously, I also wrote a duplicate std :: string_view , which called constexpr_string_view .

Having these two classes, I was ready to write elegant code to parse my game state . Think of something like this:

 constexpr auto game_state = constexpr_string(“...something...”); //          : constexpr auto blue_gem = find_if(game_state.begin(), game_state.end(), [](char c) constexpr -> { return c == 'B'; } ); 

It was quite simple to circumvent iteratively the jewels on the playing field - by the way, did you notice another precious possibility of C ++ 17 in this sample code?

Yes! I did not have to explicitly specify the constexpr_string capacity when constructing it. Previously, when using a class template , we had to explicitly specify its arguments. To avoid these torments, we create functions make_xxx , because the parameters of the function templates can be traced. Take a look at how tracking class template arguments is changing our lives for the better:

 template <int N> struct constexpr_string { constexpr_string(const char(&a)[N]) {} // .. }; // ****  C++17 **** template <int N> constexpr_string<N> make_constexpr_string(const char(&a)[N]) { //      N ^   return constexpr_string<N>(a); // ^    . } auto test2 = make_constexpr_string("blablabla"); // ^      . constexpr_string<7> test("blabla"); // ^      ,    . // ****  C++17 **** constexpr_string test("blabla"); // ^    ,  . 

In some difficult situations you will need to help the compiler to correctly calculate the arguments. If you encounter such a problem, then study the manuals on user-defined argument calculations .

Free food from STL:


Well, we can always rewrite everything on our own. But perhaps the committee members generously prepared something tasty for us in the standard library?

New auxiliary types:

In C ++ 17 , std :: variant and std :: optional have been added to standard dictionary types with a reference to constexpr . The first one is very interesting, because it allows us to express type-safe unions, but the implementation in the libstdc ++ library with GCC 7.2 has problems when using constant expressions. Therefore, I abandoned the idea of ​​adding std :: variant to my code and use only std :: optional .

T std::optional std::optional<T> , T , . , C# . find_in_board , , . . optional:

 template <class Predicate> constexpr std::optional<std::pair<int, int>> find_in_board(GameBoard&& g, Predicate&& p) { for (auto item : g.items()) { if (p(item)) { return {item.x, item.y}; } //   ,     . } return std::nullopt; //      . } auto item = find_in_board(g, [](const auto& item) { return true; }); if (item) { // ,   optional. do_something(*item); //    optional, ""   *. /* ... */ } 

, « » , boolean . , !

constexpr : tuple pair . , , . , tuple pair . , structured binding, uses parentheses to specify which variables need to store partitioned tuple or pair :

 std::pair<int, int> foo() { return {42, 1337}; } auto [x, y] = foo(); // x = 42, y = 1337. 

Very clever! But it is a pity that the members of the committee [could not, did not want, did not find the time, forgot] make them friendly to constexpr . I would expect something like this:

 constexpr auto [x, y] = foo(); // OR auto [x, y] constexpr = foo(); 

Now we have complex containers and auxiliary types, but how is it convenient for us to manipulate them?

Algorithms:

constexpr — . , constexpr . , C++17 , C++20 . , std::find constexpr .

! , constexpr , ( ); cppreference. , constexpr std::find :

 template<class InputIt, class T> constexpr InputIt find(InputIt first, InputIt last, const T& value) // ^ !!!    constexpr. { for (; first != last; ++first) { if (*first == value) { return first; } } return last; } //  http://en.cppreference.com/w/cpp/algorithm/find 

! , constexpr , cppreference , . , . , , .

:


AAA- , ?

Speed:


When I managed to create a half working version of Meta Crush Saga , the work went smoother. In fact, I managed to achieve a little more than 3 FPS (frames per second) on my old laptop with i5 overclocked to 1.80 GHz (the frequency is important in this case). As in any project, I quickly realized that previously written code was disgusting, and began to rewrite the parsing of the game state using constexpr_string and standard algorithms. Although this made the code much more convenient to maintain, the changes seriously affected the speed; new ceiling steel 0.5 FPS .

Despite the old C ++ proverb, "zero-head abstractions" does not apply to compile-time calculations.. This is quite logical if we consider the compiler as an interpreter of some kind of “compile time code”. Improvements for various compilers are still possible, but there are also opportunities for growth for us, the authors of this code. Here is an incomplete list of observations and hints I found, possibly specific to GCC:


Bugs:



, . , ? printf . « » ( , ), , , templight .

static_assert , boolean . , constexpr , :

 #define CONSTEXPR constexpr //      //  #define CONSTEXPR //     

With this macro, we can make the logic work at runtime, which means we can attach a debugger to it.

Meta Crush Saga II - we strive to complete the gameplay during execution:


, Meta Crush Saga The Game Awards . , . … bash, - ( !). , :


saarraz GCC , static_print . - . , , static_assert , -.

C++17 . — ! - , . , obsolete attribute :

 template <char... words> struct useless { [[deprecated]] void call() {} // Will trigger a warning. }; template <char... words> void output_as_warning() { useless<words...>().call(); } output_as_warning<'a', 'b', 'c'>(); // warning: 'void useless<words>::call() [with char ...words = {'a', 'b', 'c'}]' is deprecated // [-Wdeprecated-declarations] 

Although the output is obviously present and it can be parsed, unfortunately, the code is not playable! If by coincidence you are a member of a secret society of C ++ programmers who can perform output at compile time, I’m happy to hire you on my team to create the perfect Meta Crush Saga II !

Findings:


. , - . , .

SwedenCpp team , . , , Meta Crush Saga .

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


All Articles