Prologue
I meant to write a sequel about how C++ is used in game engines, but my thoughts wandered off in an unexpected direction. The language has been evolving rapidly in recent years — except that getting, and especially applying, the features of C++20/23 in game and engine development works out well only with a delay of, oh, about five years.
The paradox is that studios are bolted to C++ by gigantic legacy codebases — they're too big to fall. Rewriting all of it in some trendy alternative language is impossible, no matter what theoretical advantages it promises. So let's look at the levels C++ actually lives on inside an engine — there are roughly three of them, and each has its own priorities.
Hardware / Baremetal / Hardcore C++
The lowest level is number-crunching and working with large volumes of computation. It's precisely this code that eats up around 80% of a big game's runtime, and it's what lets you win tens — even hundreds — of times in performance compared to "ordinary" C++.
The code here is deliberately restricted: minimal function calls, aggressive inlining, careful handling of the branch predictor, precise packing of data structures. It looks more like C than modern C++ — readability is consciously sacrificed for raw speed. And not just anyone can write it: this isn't the level of an ordinary "general" mid-level dev; it takes top-tier developers.
Middleware / Common C++ / Templates
Climbing higher through the layers of the architecture, we arrive at the level of "ordinary" C++. This code is written using the classic "technologies" and algorithms invented over the language's lifetime. This is where 80% of the code used in software lives. Hundreds of libraries in different languages that, in one form or another, expose their capabilities through a "C interface". Various bindings to the OS's core language — for example, Java via JNI, Objective-C++, the virtual machines of scripting languages.
It's here, too, that the language reveals itself as a high-level design tool — note, not a language for writing code, but specifically a means of describing an application's architecture (OOD, DOD, DDD). It lets you not only squeeze every drop out of the hardware, ignoring all the rules of good code, but also show that very good code — resilient to bugs, leaks, bound-check access issues, and protected from the junior. Unfortunately, in many game engines there are still leftovers from the "roaring" 2000s here, when C++ was used heavily for writing game logic; you can spot this, for instance, in the available sources of Unreal or Dagor, where player-related core logic is partly present at the very lowest object level.
And of course, the language provides access to libraries' APIs. And with some hacks like privablic access — to most of the functionality hidden from the end user as well. But if you think this is the real C++, then no: the ghosts of "plain C" still live here; here and there you can see deliberately simplified functionality so that as many people as possible can use this level.
If you estimate the rough compute performance depending on the level of technology used, ordinary C++ taps into less than 10% of the hardware's capabilities — so it's no surprise when developers are willing to trade productivity in person-hours for execution speed.
Compute performance depending on the level of technology used
"We'd gladly sacrifice 10% of productivity in order to get 10%"
— Tim Sweeney
In case anyone forgot what he looks like
This results in virtual machines for second- and third-level languages coming into the engine — which, on the one hand, allow you to write fast algorithms at the engine level, and on the other, fence designers off from C++ in favor of something slower, more convenient, and more understandable. First it was the fashion for dragging in scripting languages (Lua / JS / Squirrel / "write your own"), a little later came the time of visual programming. Scripts and visual scripts (blueprints) are also not an invention of gamedev — they came from the world of robotics, where the cost of a mistake is much higher and the mistake itself can lead not just to a crash to the desktop, but to real damage to equipment. The downside of this approach is that what you can write in 10 lines of code will take 1000 lines because of all the wrapping, checks, tooling, and so on.
There's no need to even mention the drop in performance: even the most advanced Lua VM, whatever its developers claim, sags the perf by — if you're lucky — only a factor of two. Maybe on some synthetic tests the performance drop is ten percent or less, but in a real game the code from that test runs 0.1% of the time. It's not as critical as it seems at first glance, because all of it is offset by the growing speed of memory, CPUs, and GPUs. But the drop in performance is measured not only in teraflops — the Lua language itself is much simpler than C++. And people — programmers and designers — also start thinking and writing in the paradigm of a simplified language, simply because writing something more complex isn't necessary, and doesn't always work out anyway.
In my experience, code rewritten from scripting languages back into C++ will be 5+ times faster. That's usually what happens when game profiling identifies the slow spots. Other scripting languages haven't gone far from Lua, which development has focused on for at least ten years, and over that time it's been sped up quite decently. Since the language first appeared back in 1993, the performance of the virtual machine itself — irrespective of hardware performance — has grown almost tenfold.
Benchmarks of algorithm implementations across Lua VM versions; the C reference time is in red
The need to create bindings from C++ into the scripting language is yet another bottleneck when using C++ ↔ script bindings, often because of the need to copy data between the representation levels. The loss at every stage is incurred to give everyone the chance to program — from the artist to the AI and systems-mechanics designer — to let them make mistakes and write utter nonsense without bringing down the editor with one careless move of their mischievous little hands.
But of course, the main payoff for which game-engine developers accept a substantial slowdown is the ability to hot-reload game logic. You won't get it out of the box; moreover, it'll require reworking half of the existing code, but it'll let you speed up game development by tens of times. Judge for yourself: editing code in the IDE, compiling, restarting the level, setting up a game situation to work with — that's all minutes of real time; hot-reloading a script is seconds, and the programmer and designer don't fall out of the context of the game situation.
Unity and Unreal stepped even further here, providing the ability to visually script and edit objects and logic right during the simulation, which lowers the requirements for basic development knowledge in general and programming in particular even more. That's probably how games should be developed — when you just change the game's state right while playing it. As with the move from native code to scripts, and from scripts to visual programming, this slows the overall game code down even more, but gives the team even more protection from mistakes. Now scripts and the VM act as the low-level framework, and at the visual-scripting level you're 95% protected from being able to crash the game, while still being given access to the entire engine — from shaders to animations and NPC behavior.
This, however, doesn't guarantee development will be easier — I'd say the opposite: development becomes harder overall, but that complexity is smeared across hundreds and thousands of game elements. And of course, you can mess up worse, and far faster, than in code. Let's call that kind of real-project complexity WTF/s. Honestly — no one will review something like that, they'll approve it without looking; just pray that the game designer drags their monster all the way to release.
Never like this!
Meta / Highlevel C++
We're getting to the juiciest part. Besides ordinary C++ code, there are small parts of a game engine that require the fanciest language features. These are RTTI, reflection, compile-time computations, and code-generation tools, where the game's code grows out of a set of configs according to given sets of rules.
RTTI, for obvious reasons, is disabled in 99% of cases, but the need to cast to the right type hasn't gone anywhere, so people almost always write their own contraption.
Because of the absence of reflection in the language itself, every other studio "invents" it — each as best they can. There's no ready-made, proven scheme and technology for reflection — every framework offers its own methods of annotating code, serialization, and bindings.
Generating types and code from configs — so that scripts can process them and the engine-game has access to those types. This task is usually solved with macros, templates, and black magic, which ultimately results in fairly non-trivial code, or even a separate virtual machine with its own language.
Among the well-known "good" code generators, I'd note the following:
- A data schema in a separate portable language — FlatBuffers.
- A separate language for generating data and the code to work with it (Racket, by Naughty Dog) — the GDC Vault talk and video.
- CppHeaderParser — a single-file Python library that can read headers.
- RTTR — lets you create and modify types, classes, methods, and properties of objects in C++ at runtime.
Thoughts afterward…
Coming back to the real world after watching examples from the new language standards on YouTube or CppCon — where a lambda wrapped in a memfunction glides over coroutines — once again, after a sleepless night, staring into the debugger and a notebook covered in scribbles, I discover some strange line of code that makes it unclear how all of this ever worked at all. And for the hundredth time I wonder: if they wrote something like that back in C++11, then how intricately can they do it the new way? And how long will they hunt for that bug afterward. Games are, after all, written with some goal in mind, and just shuffling code back and forth for the sake of refactoring is a bad idea. Maybe it's a good thing that we live in our own little C++ world, guarded by the holy trinity of Sony, Microsoft, and Nintendo, who don't let the dragons from the committee in here?
← All articles