Lessons from the first game, and why I want to write my own engine

Original author: SSYGEN
  • Transfer
image

I recently released my first game, BYTEPATH, and it seemed to me that it would be useful to write down my thoughts about what I learned in the process of its creation. I will divide these lessons into “soft” and “hard” ones: by soft I mean ideas related to software development, hard ones are more technical aspects of programming. In addition, I will talk about why I want to write my own engine.

Soft lessons


I’ll inform you for the sake of context that I started making my own games about 5-6 years ago and I have 3 “serious” projects that I worked on before the release of the first game. Two of these projects are dead and completely failed, and I temporarily suspended the last one to work on BYTEPATH.

Here are the gifs from these projects



The first two projects failed for various reasons, but from a programming point of view they failed (at least as I see it) because I tried too often to be too smart and generalized too much in advance. Most of the soft lessons are related to this failure, so it was important to say so.

Premature generalization


So far, the most important lesson I have learned from this game is that if there is a behavior that repeats for several types of entities, it is better to copy-paste it by default rather than abstraction / generalization too early.

In practice, this is very difficult to achieve. We programmers are accustomed to notice repetitions and strive to get rid of them as quickly as possible, but I noticed that usually this impulse creates problems much more often than it solves. The main problem that he creates is that generalizations are often incorrect, and when the generalization is erroneous, it ties the code structure to itself, and it is much more difficult to fix and change than in the absence of generalization.

Consider an example of an entity that performs actions ABC. At first we codeABCdirectly in essence, because there is no reason to do otherwise. But when it comes to another entity that performs ABD, we analyze everything and think "let's take ABthese two pith, and then each of them will handle yourself alone Cand D" it seems quite logical, because we abstract ABand can be reused them in other places. If new entities are used ABin the same way as they are defined, then this is not a problem. Suppose we have ABE, ABFand so on ...


But sooner or later (and this usually happens earlier), an entity appears that requires AB*, almost similar to AB, but with a small and incompatible difference. Then we can either change ABtaking into account AB*, or create a completely new part in which the behavior will be contained AB*. If we repeat this exercise several times, then in the first case we will come to a very difficult one ABwith all kinds of switches and flags for various behaviors, and in the second case we will return to the first cell of the field, because all slightly different versions ABwill still contain a bunch of repeating code .


At the heart of this problem is the fact that every time we add something new or change the behavior of something old, we must do this taking into account existing structures. To change something, we should always think, “will it be in ABor in AB*?”, And this seemingly simple question is the source of all the problems. This is because we are trying to insert something into an existing structure, and not just add a new one and make it work. It is impossible to overestimate the difference in just doing what is needed and by having to take into account the existing code.

Therefore, I realized that at first it was much easier to choose copy-paste code by default. In the example above, we have ABC, and to add, ABDwe just copy ABCand paste and delete partCreplacing it with D. The same applies to ABEs ABF, and when we need to add AB*, we just copy-paste again ABand replace it with AB*. When we add something new to this scheme, it’s enough for us to simply copy the code from where the similar action is already performed and change it without worrying about how it will be embedded in the existing code. It turned out that this method is much better in implementation and leads to fewer problems, although it seems counterintuitive.


Most tips are not suitable for single developers.


There is a mismatch between the majority of tips for Internet programmers and what I actually have to do as a single developer. The reason for this is as follows: firstly, most programmers work in a team with other people, so advice is usually given with this assumption; secondly, most of the software created by people must exist for a very long time, but this does not apply to the indie game. This means that most of the advice to programmers is almost useless for solo-development of indie games, and because of this I can do a lot of things that are impossible for other people.

For example, I can use global values, because very often they are useful, and as long as I can keep them in my head, they do not pose a problem (more about this can be found in part 10 of the BYTEPATH tutorial) Also, I can not comment too much on my code, because I keep most of it in my head, because the code base is not very large. I can create scripts that work only on my machine, because no one will need to build the game, that is, the complexity of this step can be greatly reduced and I will not need special tools to complete the work. I can have huge functions and classes, and since I create them from scratch and know exactly how they work, their huge volume is not a problem. And I can do all this because, as it turns out, most of the problems associated with them are manifested only in teams working on software with a long lifespan.

From working on this project, I learned that nothing particularly bad happened when I did all these “bad” things. Somewhere on the edge of consciousness, I always remembered that I didn’t need super-high-quality code to create indie games, given the fact that many developers created great games using very bad code writing practices:


casenpai: What terrifies me is that you seem to have 864 case constructions in your code.

Toby Foxx (Undertale author): I can't program, lol.


And this project was supposed to confirm this opinion to me. It is worth noting - this does not mean that you can relax and write junk code. In the context of the development of indie games, this means that it is most likely worth fighting this impulse of most programmers, with the almost autistic need to do everything right and clean, because it is an enemy that slows down your work.

ECS


The Entity Component System pattern is a good real example of the contradiction to everything said in the previous two sections. After reading most of the articles, it becomes clear that indie developers consider inheritance a bad practice, and that we can use components to create entities as from the Lego constructor, and that thanks to them we can use reusable behavior much easier, and literally everything in creating a game becomes easier.

By definition, the desire of programmers for ECS speaks of premature generalization, because if we consider things as Lego bricks and think about how to collect new things from them, then we think in terms of reusable fragments that can be combined with some useful way. And for the reasons I listed in the section on pre-compilation, I think this is TOTALLY WRONG! This exact scientific graph explains my position well:


As you can see, the principle of “yolo-coding” that I protect is at first much simpler and gradually more complicated: the complexity of the project increases and the yolo-technicians begin to demonstrate their problems. On the other hand, ECS is much more complicated at first - you have to create components, and this is by definition more difficult than creating just working elements. But over time, the usefulness of ECS becomes more and more obvious, and at some point he defeats yolo coding. My point is that in the context of MOST indie games, the moment at which ECS becomes the best investment never comes.

Speaking of the inconsistency of the context: if this article gains popularity, then some AAA developer will surely appear in the comments and say something like “I’ve been working in this industry for 20 years, and this nonsense carries complete BAD !!! ECS is very useful, I have already released several AAA games that have earned millions of dollars, played by billions of people around the world !!! Stop carrying this bullshit !!! ”

And although this AAA developer will be right that ECS is useful for him, this is not always true for other indie developers: due to the mismatch of contexts, these two groups solve very different problems.

Be that as it may, I think I, as I could, conveyed my point of view. Does this mean that using ECS ​​are stupid or dumb? Not. I think that if you are already used to using ECS ​​and it works for you, you can use it without hesitation. But I believe that indie developers in general should be more critical of such solutions and their shortcomings. I think Jonathan Blow’s thought is very appropriate here (I by no means believe that he would agree with me regarding ECS):


Avoid splitting behavior into multiple objects


One of the patterns that I didn’t seem to be able to avoid was splitting one behavior into several objects. In BYTEPATH, this was mainly manifested in the way I created the “Console” room, but in Frogfaller (the game I did earlier) this is more obvious:


This object consists of the main body of the jellyfish, from the separate legs of the jellyfish and the logical object that binds everything together and coordinates the behavior of the body and legs. This is a very clumsy way of coding such an entity, because the behavior is divided into three different types of objects and their coordination becomes very difficult, but when I have to code the entity in this way (and there are many multi-component entities in the game), then I naturally choose this method of solution .

One of the reasons why I choose this separation by default is that each physical object must be contained in the code in one object, that is, when I want to create a new physical object, I also need to create a new instance of the object. Actually, this is not a strict rule or a restriction that is mandatory, it’s just very convenient for me because of the way I designed the architecture of my physics API .

In fact, I thought for a long time about how to solve this problem, but I still could not find a good solution. Simple coding in just one object looks terrible because you need to coordinate between different physical objects, but dividing physical objects into correct objects with their subsequent coordination also seems unacceptable and incorrect. I don’t know how other people solve this problem, so I’m waiting for your advice!

Hard lessons


Their context is that I wrote my game in Lua and with LÖVE . I wrote 0 lines of code in C and C ++, everything was written in Lua. Therefore, many of these lessons are related to Lua itself, although most of them apply to other languages.

nil


90% of bugs received from players are related to access to variables nil. I did not track the statistics of which types of access are more / less frequent, but most often they are associated with the death of an object, when another object stores a link to this dead object and tries to do something with it. I think this belongs to the category of “life expectancy” problems.

The solution to this problem in each case is usually implemented very simply, it is enough to check whether the object exists and only after that perform actions with it:

if self.other_object then
    doThing(self.other_object)
end

However, the coding problem in this way is that I refer to another object too reinsured, and since Lua is an interpreted language, such rare bugs with code branches occur. But I can not think of any other way to solve this problem, and since it is a serious source of bugs, it seems to me that it is right to have a strategy for handling them correctly.

In the future, I consider never referring from one object to another directly, but instead referring to them through their id. In such a situation, when I want to do something with another object, I will first have to get it by its id, and then do something with it:

local other_object = getObjectByID(self.other_id)
if other_object then
    doThing(other_object)
end

The advantage of this approach is that it forces me to receive an object every time I want to do something with it. Also, I will never do anything like this:

self.other_object = getObjectByID(self.other_id)

This means that I never keep a permanent link to another object in the current one, that is, errors cannot occur due to the death of another object. This does not seem to me a very desirable solution, because every time I want to do something, it adds a lot of redundancy. Languages ​​like MoonScript help a bit with this because you can do something similar there:

if object = getObjectByID(self.other_id) 
    doThing(object)

But since I will not use MoonScript, I seem to have to come to terms with this.

Greater control over memory allocation


Although I will not argue that garbage collection is bad, especially since I'm going to use Lua for my next games, I still really don't like some of its aspects. In C-like languages, the occurrence of a leak is annoying, but in them we can usually roughly understand where it occurs. However, in languages ​​like Lua, the garbage collector looks like a black box. You can look into it to get hints about what is happening, but this is not an ideal way of working. When you have a leak in Lua, it turns out to be a much bigger problem than in C. This is complemented by the fact that I use the C ++ code base, which I do not own, namely the LÖVE code base. I don’t know how the developers set up the memory allocation for their part, so it’s much harder for Lua to achieve predictable memory behavior.

It is worth noting that in terms of speed, I have no problems with the Lua garbage collector. You can control it so that it works with certain restrictions (for example, so that it does not start for n ms), so there is no problem. The only problem is that you can tell him not to run for more than n ms, and he will not be able to collect all the garbage that you generated per frame. Therefore, maximum control over the amount of allocated memory is desirable. There is a very good article on this topic: http://bitsquid.blogspot.com.br/2011/08/fixing-memory-issues-in-lua.html , and I will tell you more about it when I get to the engine in this article .

Timers, Input and Camera


These are three areas in which I am very pleased with the decisions I have received. For these common tasks, I wrote three libraries:


All of them have APIs that seem very intuitive to me and make my life very easy. So far, Timer has turned out to be the most useful for me, because it allows me to implement all kinds of solutions in a simple way:

timer:after(2, function() self.dead = true end)

This code kills the current object (self) after 2 seconds. This library also allows you to implement tween transitions very conveniently:

timer:tween(2, self, {alpha = 0}, 'in-out-cubic', function() self.dead = true end)

This line allows you to smoothly change the (tween) attribute of an alphaobject to 0 for 2 seconds using the tween mode in-out-cubic, and then destroy the object. This allows you to create the effect of gradual dissolution and disappearance. It can also be used to make objects flicker upon impact:

timer:every(0.05, function() self.visible = not self.visible, 10)

This code switches the value self.visiblebetween true and false 10 times every 0.05 seconds . This means that it creates a flicker effect for 0.5 seconds. As you can see, the library can be used almost endlessly. This was made possible thanks to the way Lua works with its anonymous functions.

Other libraries have an equally trivial API, which is powerful and useful. The camera library is the only one that turned out to be too low level, but this can be improved in the future. Its meaning is to be able to implement something similar to what is shown in this video:


But in the end, I created something like an intermediate layer between the very basics of the camera module and what is shown in the video. Because I wanted the library to be used by people using LÖVE, I had to make fewer assumptions about which types of attributes might be available. That is, some of the features shown in the video cannot be implemented. In the future, when I create my own engine, I will be able to admit everything I want about my game objects, that is, I will be able to implement the correct version of the library that can do everything that is shown in this video!

Rooms and areas


For me, the concept of rooms (Rooms) and areas (Area) turned out to be a very suitable way of working with objects. Rooms are an analogue of a "level" or a "scene." All action takes place in them, there can be many of them and you can switch between them. An area is a type of object manager that can be located inside rooms. Some people call such Area objects "spaces." Room Area and work together around this (in the real versions of these classes will be many more features, for example, Area will be addGameObject, queryGameObjectsInCircleand so on):

Area = Class()
function Area:new()
    self.game_objects = {}
end
function Area:update(dt)
    -- update all game objects
end

Room = Class()
function Room:new()
    self.area = Area()
end
function Room:update(dt)
    self.area:update(dt)
end

The advantage of separating these concepts is that the rooms do not have to have areas, that is, the way to control the objects in the room is not fixed. In one room, I can decide that the objects should be managed in some other way, and then I can just write the code, instead of adapting my Area code to the new functionality.

However, one of the advantages of this approach is that it is easy to mix the local object control logic with the Area object control logic if there are both in the room. This can be very confusing and when developing BYTEPATH it became a serious source of errors. Therefore, in the future I will try to make sure that Room uses strictly either Area, or its own procedure for managing objects, but never both at the same time.

snake_case and camelCase


Right now I'm using snake_case for variable names and camelCase for function names. In the future, I am going to use snake_case everywhere except for class / module names that will still remain CamelCase. The reason for this is very simple: in camelCase, it is very difficult to read long function names. The ability to mix up variable and function names in snake_case is usually not a problem due to the context of using the name, so everything will be fine.

Engine


The main reason that I want to write my own engine after this game is over is control. LÖVE is a great framework, but when it comes to the release of the game, it gets too rude. Things like Steamworks support, HTTPS support, testing other physical engines like Chipmunk, using C / C ++ libraries, packing my game for distribution on Linux, and a bunch of other things that I will soon mention are too complicated.

This does not mean that the task is unsolvable, but to solve it I would have to go down to the C / C ++ level and work there. I program in C, so I have no problems with this, but initially I decided to use the framework because I wanted to use Lua and not worry about anything else, and such work contradicts my aspirations. Therefore, if in any case I have to work at a low level, then I would rather own this part of the code base by writing it myself.

However, here I want to present a more general point of view on engines, and for this I will have to start scolding Unity instead of LÖVE. There is a game that I like and which I played for quite some time - Throne of Lies:


This is a clone of the "Mafia", which had (and perhaps still has) a very healthy and good community. I learned about it from the streamer that I’m watching, so there are many people in the game with a similar way of thinking, which is very cool. In general, I really liked the game. Once I found on / r / gamedev a post- mortem of this game from one of its developers. This guy was one of the programmers, and he wrote one comment that caught my attention:

I have many pages of bugs that I meet daily. I started to keep a bug log because they are so bad and I have to record screenshots and screencasts to prove that I'm not crazy. Just because there are so many of them and nobody corrects them. I will no longer report bugs unless they pay for it, because for all these years I have not seen at least one of them being fixed. Two years of bug reports, and they still exist; developers just keep adding features, and that’s not even funny. Unity seems so awesome, so we are deceived by excellent marketing. But two years later, I already see a pattern of their work: the top leadership obviously did not develop and did not create a game even once in my entire life. So they’re doing a new feature, release it in an alpha-like state and forget about it forever, moving on to the next feature, which will earn them money. The same story happens with each version. Then they constantly mark the hanging bugs as fixed, as if no one would notice anything. Recently, it has been actively discussed. Unity is great for miniprojects, but try to do something more advanced and quickly start to detect bugs.

Bugs include persistent brakes, problems with Async, added and abandoned Vulkan support, they completely broke FB standalone for the whole version and pretended that there was nothing, switching to new features (we had to remove the login via FB, etc.) , UI glitches like garbled text, a bug with coloring everything in pink, a bug with a black screen, a bug with disappearing text, Scroll Rect's contain more bugs than I can list (even plugins that improve them fail, because they are put on top of Unity )

For example, scrollers ... At random times, they can begin to shift to the left, although you did not do anything. If you change their position, then sometimes the UI element is minimized and becomes negative. You have to click on Play and then Stop to see it again, if you're lucky, or reboot the system.

More bugs ... Their collaboration feature (collab) had amazing developers, but again the same trouble - the top management, making bad decisions, released it in an almost alpha state, just to make money. Then they abandoned this feature. We abandoned collab in favor of gitlab CE and half of our problems just disappeared. Everything is so wild. One of the biggest bugs is that every 2-3 launches the start button is blocked (the patch never came out, the report was sent LAST YEAR), and Unity launch blocks all threads for two minutes. Combo bug ... The Play button is blocked, and the reboot slows down everything for 2 seconds. Every 2-3 game starts. Now imagine working in this mode for 10 hours.

More bugs ... Unity crashes if you exit when a new scene is preloaded - it looks unprofessional. Everything you do with clipboard features blocks the entire clipboard OUTSIDE UNITY until you restart your computer.

More broken promises ... UNET? This is also a different story. They stated that it would be a feature of the corporate level. As a result, it turned out only for two people, with a broken architecture, still has not been working for a year and a half, there is no documentation, no tutorial, no support. It seems that she was completely outsourced, because no one knows anything. We again bought marketing and lost three months, and then switched to Photon and what I took three months, I did in three days. Even moderators on their own forum say that it does not work at all. I took hilarious screenshots. I had to laugh, so as not to cry ... How much time was spent ... So many broken promises. Again the same pattern: we sell, release in alpha, forget forever.

And there were so many. Here's what I can say: if you are doing 3D, then switch to Unreal. I can’t even begin to describe my disappointment. I used to be a proud Unity developer until I saw the terrible truth behind the mask. We were so ashamed that we even removed the Unity logo from our website. So much has been invested, and I can’t even recommend Unity to other developers.

That is, this person who created the game I liked very much told terrible things about Unity, that it is very unstable, that developers are constantly striving for new features and never implement them correctly, and so on. I was very surprised that someone does not like Unity so much that he writes such a thing. So I decided to push him a bit to find out what else he could say about Unity:


And then he said this:


And such:


I never used Unity, so I don’t know if he is telling the truth, but he wrote a finished game on it and I don’t see the reasons why he could lie. His point of view in all these posts is about the same: Unity is focused on adding new features instead of improving existing ones, and Unity has problems maintaining the stability of many existing functions between versions.

In my opinion, one of the most convincing arguments in his posts is that it applies to other engines, and not just to Unity: the developers of the engine themselves do not make games on it. At least with LÖVE I noticed one important thing - many problems of the framework could be solved if the developers actively made indie games on it. Because in this case, all these problems would become obvious to them, would receive the highest priority and be quickly fixed. xblade724 found out that the same is true for Unity. And many other people I know have found similar things for other engines.

There are very few frameworks / engines on which developers themselves actively make games. The first ones that come to my mind: Unreal, because Epic has created a bunch of super-successful games on its own engine, the last of which is Fortnite; Monogame, because the main developers port games with it to different platforms; and GameMaker, because YoYo Games makes mobile games on its engine.

For all other engines I know, this condition is not fulfilled, that is, all of these engines have very obvious problems and obstacles to creating ready-made games, which most likely will never be fixed. Because there is no incentive, right? If some problems affect only 5% of users because they arise at the end of the game development cycle, then why fix them at all if you don’t do it on your own game engine and you don’t have to deal with these problems yourself?

And all this means that if I am interested in creating games in a reliable and proven way, without encountering a bunch of unexpected problems closer to the end of the game, then I will not use an engine that complicates my life, so I will not use any other engine except of the three above. In my particular case, Unreal is not suitable, because I am mainly interested in 2D games, and Unreal is too much for them, Monogame does not work, because I hate C #, and GameMaker does not work, because I don’t like the idea of ​​visual coding or interface-based coding. That is, I have only one option left - to create my own engine.

So, having understood all these considerations, let's move on to specific tasks:

C / Lua interaction and memory


C / Lua binding can be done in two fundamental ways (at least based on my limited experience): with full user data and with limited user data. When using full user data, when the Lua code asks for the placement of something in C, for example, a physical object, we create a link to this object in Lua and use it. In this way, we can create a complete object with metatables and various parameters that reliably describe object C. One of the problems with this approach is that it creates a lot of garbage from Lua, and as I mentioned in the previous sections, I try to avoid as much as possible memory allocation, or at least want to have full control over it when it happens.

Therefore, it would be more logical for me to use an approach with limited user data. Limited user data is just a regular C pointer. This means that we cannot get much information about the object we are pointing to, but this option provides the most control from Lua. In this scheme, the creation and destruction of objects must be done manually and everything is not going to magically, and that is exactly what I need. A very interesting report by the developer of the Stingray Engine is devoted to this topic:


After reading the documentation , you can see how what he describes happens in the engine.

The point of writing my own engine is that I will have full control over how the C / Lua binding happens and over the choice of compromises that should arise. If I use someone else’s engine on Lua, then the choice is made for me and I may not be completely satisfied with this choice, for example, as happened with LÖVE. Therefore, this is the main way that I can get more control over memory and create fast and reliable games.

External integration


Things like Steamworks, Twitch, Discord and other sites have their own APIs that need to be integrated in order to use their convenient features, and if you do not own a code base in C / C ++, then this task will be much more difficult. Of course, you can do the work to integrate them into LÖVE, for example, but it will require more work than when integrating into your own engine.

If you use very popular engines like Unity or Unreal, which already have other people’s integration with most of these services, then this is not a problem, but if you use another engine with a smaller user base, you will either have to integrate these things yourself, or using someone’s half-implemented and barely working code, which is a bad decision.

And again, owning a part of the C / C ++ code base makes such integrations much easier, because you can just implement only what you need and it will definitely work.

Other platforms


This is one of the advantages that I see in engines like Unity or Unreal compared to writing my own engine: they support all the most common platforms. I don’t know if it’s well implemented, but what impresses me is that they are capable of it, and it will be difficult to do it alone. Although I’m not a supernerde living and breathing in assembler, I don’t think that I will have a lot of problems porting my future engine to the console or other platforms, but I can not recommend everyone to go this way, because most likely it will lead to a lot of labor .


One of the platforms that I really want to support from the very beginning is the web. Once I played a Bloons TD5 game in a browser, and after some time the game suggested that I switch to Steam and buy it for $ 10. So I did. Therefore, I believe that supporting a browser version of the game with fewer features and an offer to upgrade to Steam is a good strategy that I also want to implement. Previously, I studied the question of what it takes to create a C engine, and it seems that Emscripten will be convenient for working with the SDL, with which I can draw on the screen in the browser.

Replays, trailers


Creating a trailer for this game turned out to be a very bad experience. I did not like him at all. I can think through movies / trailers / stories well in my head (for some reason, I do this all the time when I listen to music), so I had a very good idea for the trailer that I wanted to create for the game. But the result was completely wrong, because I did not know how to use the necessary tools (for example, a video editor) and did not have much control over the recording.


I hope to implement a replay system and a trailer system in my engine. The replay system will make it much easier for me to record gameplay clips, because I don’t need third-party programs to record the gameplay. In addition, I believe that I can make sure that the gameplay is constantly recorded during development, so that I can programmatically view all replays and select specific events or sequences of events that can be used in the trailer. If I succeed, then the process of obtaining the records I need will become much easier.

In addition, after implementing this replay system, I want to add a trailer system built into the engine that allows me to connect fragments of different replays together. In fact, I do not see any technical obstacles in this, so the question is only in implementation.

And the reason I need my own engine for creating a replay system is because I absolutely need to work at the byte level so that replay works in a controlled way and takes up less space. I already built a replay system on Lua in this article , but in just 10 seconds the replay creates a 10 MB file. You can add additional optimizations to it, but in the end, Lua has its limits, and it’s much more convenient to optimize things like that in C.

Design Integrity


And the last reason I want to create my own engine is the integrity of the design. One of the principles that I love / hate about LÖVE, Lua (the same goes for Linux philosophy) is their decentralization. There are no standard implementation methods in Lua and LÖVE, people do what they think is right, and if you want to write a library that other people will use, then you should not make too many assumptions. This idea was followed by all the libraries that I created for LÖVE (they can be found in my repository), because otherwise no one would use them.

The advantages of such decentralization are that I can easily take someone’s library, use it in my game, tailor it to my needs, and in general everything will work. The disadvantages of this decentralization are that the amount of time that each library can save me is lower compared to code that is more centralized with respect to some set of standards. I already mentioned this in the example with my own camera library. This is contrary to doing everything fast.

Therefore, one of the things that I really want to do in my engine is the ability to centralize everything exactly as I want and the ability to make many assumptions, which will increase the pace of work (and also, I hope, and increase productivity)!

Also popular now: