Game Science Part 1: Fundamentals
Loops, Timing, and how to design a good framework for video games
Welcome and happy new year! "Game Science" is a series of blog posts we will be writing here on our website detailing our design process, and the thoughts that programmers and artists must take into account when deciding how a game works under the hood.
This first chapter will focus mostly on the technical aspects of "game engines". A game engine is a framework that handles multimedia, world simulation, controller input, and other important features needed to support the "game logic", which handles the gameplay part. Our first game, TIME FALCON, is still very early in development and we have not started on that game logic yet, so we unfortunately cannot reveal the fun side of what we're working on at this time. But more will come as we approach 2026. For now, we're going to reveal some of the computer science aspects of how games work for those who are new to the subject and perhaps want to get started on their own.
Coincidentally I just had a discussion with someone on Twitter this week about the correct way to learn game engines programmatically, and what resources were available, and it seems like there is demand out there from people who have never worked on one before to learn how to make their own. Especially with the recent acquisition of Unity 3D by IronSource, and the frustration many gamers have towards Unreal Engine's death grip on the triple-A games industry ruining performance in a world where luxury computer parts are only becoming more expensive. Our goal at Canithesis Interactive is to bring back the old-fashioned knowledge that developers have forgotten about. We aren't into any of that publicly traded, big-suit investors, crunch-culture politically correct nonsense. We do things the right way here.
Why even make a game engine anymore?
These days, custom game engines are usually reserved for large studios who have hundreds or thousands of employees and can afford to cram lots of tech into them. 3D virtual worlds are getting more and more complicated, and pressure from graphics card manufacturers like NVIDIA means many game studios want to impress by making their games as much of an over-achievement as possible. However, doing this in-house, let alone doing it correctly, is extremely hard. Spending money also means less profit. As such, most developers have been moving to generic engines created by third parties, like Unreal Engine 5. This is especially true for indie developers or very small companies that literally do not have the resources to develop an equivalent engine on their own within a reasonable time frame and budget.
However, this is not always how it has to be.
There are many reasons why it may actually be worthwhile to write your game entirely from scratch rather than use a complete engine:
- Licensing. Many of the biggest, most powerful game engines are proprietary software. Unity has a very hostile reveneue model where they will literally harass their customers if they suspect you have made more than a certain amount of profit off of a game, even if you have never released your work. Epic is a lot friendlier with their UE5 license, even allowing you full access to the engine's source code, but they request a 10% share of your revenue and ultimately they still own UE and all forks you make of it, meaning you cannot liberate your game at a later date and they can choose to roofie the EULA with more draconian terms in the future.
- Flexibility. You do not need to work within the confines of the engine. Instead, you build the engine around what your game needs. This allows you the power to achieve the desired result with whatever programming technique is best suited for the job.
- Efficiency. You don't always need a powerful, universal engine for many games. You may only use a fraction of the features that UE5 or Unity would have provided to you, and since many of those features are a deep part of the engine's code, they still waste memory and CPU/GPU time. You will often have much less bloat and make more performant use of the computer's hardware if you only choose an engine that has the features you absolutely need. Or better yet, make one by yourself so that you know the performance cost of every feature. This goes hand-in-hand with the flexibility part mentioned above.
... just to name a few. These were the big three that motivated us to start from scratch.
When making your own game engine, or when making a game that is it's own engine, the most important thing to do from the very beginning is PLAN AHEAD! If you don't you will make things harder for yourself in the future. In the software engineering world, this is called "technical debt". Technical debt is when a poor design decision early in the life of software makes it much harder to upgrade or change in the future, without using workarounds or introducing bugs in the code.
These were the factors we had to consider when designing an engine for our upcoming title TIME FALCON, and each had a major influence on the direction we took when writing our code:
- Our resources. We aren't Rockstar, EA or Bethesda. We can't throw money at our game and expect to wake up tomorrow to a new Elder Scrolls VII.
- The genre. We're making a classic arcade shoot-em-up. This is something that does not require complex technology. Compared to an open world adventure game or a sandbox first-person shooter, which would require many deep and intricate systems to function.
- Our game will be entirely in 2D. As mentioned before, we're only a small doujin circle of just two people, looking to commission volunteers. While using a 3D engine allows for more flexibility even in a 2D game, and is required for a lot of different gameplay genres and visual styles, lowering the complexity of the graphics & physics by sacrificing the third dimension will make development easier and quicker, provided we know how to work within those limitations. Since we're making a scrolling shooter, we can afford to make this cut.
- Our game will be designed for very, very old computers. We are intentionally targetting the low-income market. We want to be able to compile to operating systems at least as old as Windows XP, and graphics cards as far back as 2004-ish, so we cannot go above DirectX 9 feature level. This is fine for a 2D game with just sprites like ours, but for a more complex 3D title this might be too limiting. We chose this target anyway because honestly DX9 graphics level is still very flexible and modern, and yet it's still so old that any computer you pull out of a trash can in a third-world country will fully support it. The number of games available on slower computers is also limited, and we can get more players (customers!) to enjoy our game by making it more accessible to those people. There is one caveat to this though: we need to be super careful about how we use the computer's resources. To help with this, I occasionally run tests on very old computers, ranging anywhere from a Dell Vostro 1700 w/ a GeForce 6800 GT, to a Thinkpad T540p with weak business graphics. We also keep a careful eye on how our engine uses memory and how much CPU time we spend on processing expensive game data, whenever that comes up. Finally, we stay away from certain frameworks or runtimes that are too slow, such as Python or Javascript. Our engine is written entirely in C++, though for future games we may or may not decide to place logic inside of a Java VM. Some games like Cave Story do this and still run very fast.
- We need to be able to reproduce identical gameplay using a replay system. Many modern danmaku shooters like Ikaruga, Eschatos, and Raiden IV Overkill have a replay system, where each high score on the online leaderboard is tied to a demo file that other players can watch. To make this system as efficient as possible, and limit certain ways of cheating, we want this replay system to rely only on recording the controller buttons. This requires that we use a custom pseudo-RNG system, as well as be able to easily track input over time at a constant rate, without needing anything crazy like the sub-tick system in Valve's Source 2. This will come up later when we talk about how to process time in our virtual world.
We are designing a system that is tailor-made to our game, with these exact requirements in mind. In the next section, I'll go over how each part of our game engine works, and give some code examples to help you practice at home. TIME FALCON uses the Allegro 5.2 library to power it's game engine. Most games use SDL2+, or don't use a framework at all and simply talk to the OS's multimedia APIs directly. But we chose Allegro because it was slightly lighter than SDL and suited very well to our game while still doing a lot of heavy lifting for us, and this is important because it means we don't have to do any extra work to get our game working between Windows & Linux operating systems. For other platforms like the Nintendo Switch, this Allegro dependency might need to be ported to SDL instead, but doing so is not too difficult.
Nerd Warning
From here on, I'll be writing this assuming that you have basic knowledge of C++ and polymorphism, and some understanding of modern computer graphics concepts like "framebuffers". Similar principles can be applied to other languages, though. If you don't know C++ but you know a higher-level language like Java or C#, you shouldn't have too much trouble understanding. Just remember that we are working directly with the computer's memory management at times, so things like "pointers" and "bits" may come up. If you don't know any of these things, that's okay! Come along for the ride, maybe you will still learn something.
We stay away from vanilla C or x86 Assembly because OOP makes game engines and especially game logic a little more intuitive to understand. Assembly is also hardware-specific and bad for portability. Making games in that way is still possible though.
Header files not included. There will be a lot of variable names that are not declared in the code shown so don't try to copy-paste compile anything you see. The code is provided to help demonstrate what your own homebrew version of this could look like.
Happy Little Accidents
(How to Draw)
Computer software is executed sequentially, from start to finish. When you ask a computer to do something, it only does that thing once. What we want to do is make it do something continuously. So when we make any interactive program, we use a while loop to recieve and process inputs, so that the computer can continue to process them.
Our engine boots up by setting some default values, loading the user's settings, and then creating a new "renderer" object which we remember a pointer to globally so that all objects can access it. Of course, we could just have the renderer be a part of our entry point main() and make all it's data a part of the global variables, but this object-oriented approach helps keep us a little more organized.
Inside of the renderer class, we have a constructor which sets some graphics options, creates the game window on your desktop, and makes a function call to load our graphics into VRAM. The real code is way larger than this in order to accomodate all of the game's functions and how the graphics behave at different resolutions, but these are just the most important parts in our case:
renderer::renderer() {
// create event handlers
r_inputQueue = al_create_event_queue();
// create new window
al_set_new_window_title("TIME FALCON");
r_display = al_create_display(resX * resScale, resY * resScale);
r_backbuffer = al_get_target_bitmap(); // the default framebuffer, keep track of this for when we swap it out
// set up audio mixers;
al_set_default_mixer(a_mixer_master);
al_attach_mixer_to_voice(a_mixer_master, a_voice);
al_attach_mixer_to_mixer(a_mixer_music, a_mixer_master);
al_attach_mixer_to_mixer(a_mixer_sounds, a_mixer_master);
al_set_mixer_gain(a_mixer_music, a_vol_music);
al_set_mixer_gain(a_mixer_sounds, a_vol_sounds);
al_set_default_mixer(a_mixer_sounds);
al_reserve_samples(64); // 64 simultaneous voices
// listen for events
al_register_event_source(r_inputQueue, al_get_keyboard_event_source());
al_register_event_source(r_inputQueue, al_get_display_event_source(r_display));
// load bitmap graphics & create fonts
al_set_new_bitmap_flags(ALLEGRO_VIDEO_BITMAP); // use VRAM instead of RAM
r_reloadFonts();
r_reloadBitmaps(); // C++ compilers read top-to-bottom, this only works because these are defined in a header file!
// reset animation timer
stateBirthTime = std::chrono::steady_clock::now();
totalBirthTime = std::chrono::steady_clock::now();
}
void renderer::r_reloadBitmaps() {
al_set_new_bitmap_flags(ALLEGRO_MIN_LINEAR | ALLEGRO_MAG_LINEAR); // filtered
g_glyphs = r_loadBitmap("resource/graphics/ui_glyphs.png");
g_buttons = r_loadBitmap("resource/graphics/ui_buttons.png");
al_set_new_bitmap_flags(0); // unfiltered
g_missing = r_loadBitmap("resource/graphics/sp_missing2.png");
g_publogo = r_loadBitmap("resource/graphics/ui_canithesis.png");
g_clock = r_loadBitmap("resource/graphics/sp_clock.png");
}
void renderer::r_reloadFonts() {
g_font = al_create_builtin_font();
g_conTTF = al_load_ttf_font("resource/typeface/mono.ttf", 9 * resScale, 0);
g_medTTF = al_load_ttf_font("resource/typeface/small.ttf", 24 * resScale, 0);
g_smallTTF = al_load_ttf_font("resource/typeface/small.ttf", 16 * resScale, 0);
g_smallerTTF = al_load_ttf_font("resource/typeface/small.ttf", 12 * resScale, 0);
}
If you want your entire game to fit into memory and load just once, then this is enough. But we also have an std::vector of bitmap pointers which are used by objects inside of the game world. This allows us to dynamically load and unload textures to a list, and not clutter our code with dozens of extra variables, or have a gigantic spritesheet. We only use these g_ variables above to store some common UI graphics used throughout the game.
Next, we have a function startLoop() that begins the game's title screen. You'll notice one of the very first things we do is declare and define some data related to inputs, then at the start of the loop we always calculate how much time has passed between the last frame. There's a clue... extra credit if you can guess why!
Notice that it is an infinite loop, however if the global variable "done" (in namespace 'globals') is true then we break the loop. This allows the game to close gracefully without us having to crash it or use exit(0):
renderer::startLoop() {
#define KEY_SEEN 1
#define KEY_RELEASED 2
unsigned char key[ALLEGRO_KEY_MAX];
memset(key, 0, sizeof(key));
while(1) {
if(done) break;
ALLEGRO_EVENT event;
auto now = std::chrono::steady_clock::now();
const double delta = std::chrono::duration<double>(now - lastUpdate).count();
if(isVsync || isFramerateUnlocked || (!isFramerateUnlocked && delta >= (1 / framerate))) {
// record inputs, draw the frame
}
}
}
I've left out the meat of the function for now so it's more digestible, but I will explain everything inside of it piece by piece.
Let's look at that last if() again.
if( isVsync || isFramerateUnlocked || ( !isFramerateUnlocked && delta >= ( 1 / framerate ) ) )
The purpose of the if() statement above is to limit the framerate, by checking that a frame is elligible to be drawn before we begin processing. If the criteria is not met, it skips the entire rest of the loop and tries again repeatedly until it is finally time to draw something. To do this, it checks for a few things:
- Is Vertical Sync enabled? We can ask the graphics driver to block further execution of our code every time we finish a frame, until the player's screen sends a vertical sync signal at the end of a display refresh. This prevents a visual artifact called "screen tearing". If VSync is turned on then we do not need to limit the framerate because it will already be limited by the graphics card. This doesn't have quite so much benefit when we're not in exclusive fullscreen mode, though, since the window manager and compositor on the player's desktop will ultimately have the final say.
- Do we want to cap the framerate? If the player asks for the FPS to be unlocked, we barrel on ahead.
- How much time has passed since the last frame was drawn? If the FPS is capped at a maximum speed, we divide one second by the desired framerate cap to get an expected frame delta, which is the amount of latency between frames. We use the real time clock on the computer's motherboard (steady_clock() from chrono.h in the C++ standard library) to measure how much time has passed between now and lastUpdate. If that number is larger than our target, we know it's time to render a frame.
Every frame, the first thing we do is collect the user's input. This is actually a lot harder than you might think. We won't cover gamepad support here so this is just with a keyboard.
Allegro provides a couple of ways for us to interpret keyboard events. It sorts keyboard events into categories: UP, DOWN, and CHAR. UP is sent when a key is released, and DOWN is set as soon as it is pressed.
CHAR is special because it behaves like DOWN at first, but then repeats in accordance with the player's key repeat rate in their operating system settings. This repeat rate has a short delay before it begins and it's not synced to every frame though, this is only useful when you want to handle text input i.e. so they can enter their name, or type to other players in a chat box.
Nowhere does Allegro provide for us a way to see if a key is being currently held down right now!
The secret sauce is to create our own place to track what keys are pressed, and then alter the data there whenever Allegro sends an event that a key has been pressed or released. This is the purpose of the "key" array we created at the beginning of startLoop(). Inside the framerate limiter if() we covered earlier, create another loop and a switch statement to ensure every event that Allegro throws at us is processed ASAP before we do anything else:
// handle inputs
for(int i = 0; i < ALLEGRO_KEY_MAX; i++)
key[i] &= KEY_SEEN;
menuButtons = 0; // reset
while (!al_event_queue_is_empty(r_inputQueue)){
al_get_next_event(r_inputQueue, &event);
switch(event.type) {
case ALLEGRO_EVENT_DISPLAY_CLOSE:
done = true;
break;
case ALLEGRO_EVENT_KEY_DOWN: // New keypresses- as long as the command shell is not open
if (!console) {
key[event.keyboard.keycode] = KEY_SEEN | KEY_RELEASED;
if(event.keyboard.keycode == ALLEGRO_KEY_SPACE
|| event.keyboard.keycode == ALLEGRO_KEY_C
|| event.keyboard.keycode == ALLEGRO_KEY_L)
menuButtons = menuButtons + 16;
if(event.keyboard.keycode == ALLEGRO_KEY_SEMICOLON
|| event.keyboard.keycode == ALLEGRO_KEY_X)
menuButtons = menuButtons + 32;
}
if (event.keyboard.keycode == ALLEGRO_KEY_TILDE // Open/close the Quake-style command shell
&& debug) console = !console;
break;
case ALLEGRO_EVENT_KEY_UP: // Remove old keypresses
key[event.keyboard.keycode] &= KEY_RELEASED;
break;
case ALLEGRO_EVENT_KEY_CHAR:
if (!console) { // Menu navigation - we want these to repeat after some time
if(event.keyboard.keycode == ALLEGRO_KEY_UP) menuButtons = menuButtons + 1;
if(event.keyboard.keycode == ALLEGRO_KEY_DOWN) menuButtons = menuButtons + 2;
if(event.keyboard.keycode == ALLEGRO_KEY_LEFT) menuButtons = menuButtons + 4;
if(event.keyboard.keycode == ALLEGRO_KEY_RIGHT) menuButtons = menuButtons + 8;
} else {
switch (event.keyboard.keycode) { // Command shell input
case ALLEGRO_KEY_TILDE: break; // ignore, already processed in KEY_DOWN
case ALLEGRO_KEY_TAB: c_autocomplete(); break; // search for commands
case ALLEGRO_KEY_ENTER: c_executeInput(); break; // submit command
case ALLEGRO_KEY_BACKSPACE: // erase
if (!c_input.empty()) c_input.pop_back();
break;
default: // typing
ALLEGRO_USTR* unicodeStr = al_ustr_new("");
al_ustr_append_chr(unicodeStr, event.keyboard.unichar);
c_input += al_cstr(unicodeStr);
break;
}
}
}
}
You'll notice we save the results of CHAR events to a variable called menuButtons, which is used for menu navigation. We also do this with two of the UP/DOWN events, those are from the confirm and cancel buttons which we do NOT want to repeat.
Here's the definition of menuButtons, as well as some related enums to help out the programmer. I mapped these to virtual Playstation controller buttons but this is all up to you:
uint8_t menuButtons = 0; // 8 bits
uint16_t gameButtons = 0; // 16 bits
enum keyMasks { // 16,383 needs 16-bits to fit - used only for gameButtons, menu binds are hard-coded
CROSS = 1, CIRCLE = 2, SQUARE = 4, TRIANGLE = 8, LBUMP = 16, RBUMP = 32, LTRIG = 64, RTRIG = 128,
DUP = 256, DDOWN = 512, DLEFT = 1024, DRIGHT = 2048, MENU = 4096, SELECT = 8192
};
This is a bitwise map of inputs. We use a technique called bitmasking to XOR (turn on/off) each 1-bit slot for each button. We'll do this again for gameButtons later for the game logic. Bitmasking inputs is not always used by every game, especially on keyboards or for analog inputs, but it is an efficient and effective way to go about storing digital button presses. This method is much more common on retro video game consoles like the Super Nintendo, where bitmasks are actually how the hardware communicates the input to the game rather than with keycodes.
We're still storing the entire keyboard state in an array of hundreds of 8-bit integers, using key[]. So this isn't really a space-saving tactic, but rather personal preference on behalf of the programmer in our case.
Here we make a blank 16-bit integer that's devoid of any input data, and then use the OR operator to apply our bitmasks to it. Then we copy it to gameButtons. Take note of the inline comment:
uint16_t g = 0; // buffer the inputs to avoid microscopic race conditions when w_ is multithreaded
if(key[ALLEGRO_KEY_UP] || key[ALLEGRO_KEY_W]) g = g | DUP;
if(key[ALLEGRO_KEY_DOWN] || key[ALLEGRO_KEY_S]) g = g | DDOWN;
if(key[ALLEGRO_KEY_LEFT] || key[ALLEGRO_KEY_A]) g = g | DLEFT;
if(key[ALLEGRO_KEY_RIGHT] || key[ALLEGRO_KEY_D]) g = g | DRIGHT;
if(key[ALLEGRO_KEY_SPACE] || key[ALLEGRO_KEY_C] || key[ALLEGRO_KEY_L]) g = g | CROSS;
if(key[ALLEGRO_KEY_SEMICOLON] || key[ALLEGRO_KEY_X]) g = g | CIRCLE;
if(key[ALLEGRO_KEY_QUOTE] || key[ALLEGRO_KEY_Z]) g = g | SQUARE;
if(key[ALLEGRO_KEY_LSHIFT] || key[ALLEGRO_KEY_RSHIFT]) g = g | TRIANGLE;
if(key[ALLEGRO_KEY_LCTRL] || key[ALLEGRO_KEY_RCTRL]) g = g | LBUMP;
if(key[ALLEGRO_KEY_ALT] || key[ALLEGRO_KEY_MENU]) g = g | RBUMP;
if(key[ALLEGRO_KEY_ESCAPE]) g = g | MENU;
if(key[ALLEGRO_KEY_TAB]) g = g | SELECT;
gameButtons = g;
Now for some fun stuff! We get to DRAW GRAPHICS now!
// clear the backbuffer
al_clear_to_color(al_map_rgb(0, 0, 0));
// render the screen
if (stateFrame == 0) { // snap beginning of state to the time the frame is rendered
stateBirthTime = std::chrono::steady_clock::now(); // fixes some animation bugs especially on slower PCs
stateTick = 0;
}
if (state == INTRO) r_drawIntroSequence(); else
if (state == TITLE) r_drawTitleSequence(); else
if (state == TRANSITION) r_drawTransitionSequence(); else
if (state == LOADING) r_drawLoadingSequence(); else
if (state == INGAME_PLAYING) {
r_drawWorld(simulation);
r_drawWorldUI(simulation);
}
al_flip_display();
lastUpdate = now;
stateFrame++;
totalFrames++;
stateTick = std::chrono::duration<double>(now - stateBirthTime).count() / 0.016666667;
totalTicks = std::chrono::duration<double>(now - stateBirthTime).count() / 0.016666667;
That right there is a state machine. We switch between different rendering modes when we want to do a certain thing.
Most game engines don't do this now. Almost every game engine in the 3D era uses a scene graph (more on that later when we talk about world simulation), and then all logic is processed inside of that scene graph by an object, including menu navigation, with only rare exceptions. So in lamen's terms, the "menu" in most modern games is actually just another level or map loaded in at startup, just like any other video game environment. Menus built inside of the scene system can make for some cool flexibility, but it is not a requirement when building a game from the ground up.
Using engine-level state machines was more common during the 8-bit and 16-bit era of console video games. But it works well for us!
Notice that we split each routine into it's own function. This probably is not necessary if you use the aforementioned "scenes only" method, but since we use a state machine inside of the renderer to switch between menus and gameplay, the renderer actually gets a bit bloated. Not "bad performance" bloated, thankfully. We just need to compartmentalize so that the code is clean and easy to read, and the problem's solved.
There is a possible performance penalty in splitting these up into separate functions, but it is so miniscule it won't matter on any computer made in the last 35~ some years. If you care about it anyway you can choose to inline these functions, if the compiler does not do it for you automatically.
After we call the subroutine to draw the graphics to the screen, we flip the display. This takes the backbuffer we just drew all over and swaps it with the frontbuffer that the graphics card is currently showing to the screen. On modern PCs and consoles, we don't draw to the frontbuffer directly because otherwise the desktop compositor or the monitor may try to display an unfinished frame. When you see screen tearing with VSync turned off, you are actually seeing the buffers get swapped mid-refresh, not an unfinished frame!
Next we reset the lastUpdate timestamp so that we can delay the next frame if the framerate is capped. Then we increment stateFrame (how many frames have been rendered in the current state in our state machine), add the frame delta to stateTick (how much time has passed since the start of the current state - converted to 60 fake "frames" per second), and the rest is self-explanatory. These values are used by the animations on the startup screen and the title menu.
Hey, are you still with me?
Let's recap:
At the start of each frame, we check how much time has passed. If we're past the scheduled time, we get every input in the Allegro event queue and store it as a 16-bit number, which we treat like a bitmask, and then we draw the graphics to the screen.
After all of that setup, r_drawWorld() is refreshingly simple, we just find the object containing our virtual world and step through all of it's contents. This would be way harder in a 3D game, since Allegro's built-in 3D capabilities are limited only to drawing individual texture-mapped polygons and doing some camera transforms. But we're not making a 3D game. So it's as simple as this:
void renderer::r_drawWorld(world* w) {
w->w_singleThreaded_updateAll();
int i = 0;
while(!w->w_content.empty() // prevent out-of-bounds crash
&& i <= w->w_content.size() - 1) { // get every object
int i2 = 0;
while(i2 <= w->w_content[i]->sprites.size() - 1) { // get all sprites for an object
objects::o_worldObjectGeneric* o = w->w_content[i];
objects::o_sprite spr = w->w_content[i]->sprites[i2];
if (w->w_content[i]->sprites[i2].spritePtr == nullptr)
spr = objects::o_sprite {g_missing, w->w_content[i]->sprites[i2].spriteBounds};
al_draw_scaled_rotated_bitmap(spr.spritePtr, // draw the sprite
spr.spriteBounds.pivotX*al_get_bitmap_width(spr.spritePtr),
spr.spriteBounds.pivotY*al_get_bitmap_height(spr.spritePtr),
(spr.spriteBounds.posX+o->transform.posX)*resScale,
(spr.spriteBounds.posY+o->transform.posY)*resScale,
(al_get_bitmap_width(spr.spritePtr)/spr.spriteBounds.sizeX)*resScale,
(al_get_bitmap_height(spr.spritePtr)/spr.spriteBounds.sizeY)*resScale,
spr.spriteBounds.rotation+o->transform.rotation,0);
i2++;
}
i++;
}
}
The real version has more to it but it is specific to our game and probably not that useful to you, so I've once again neutered it to be easier to read.
Pay close attention! Before we make any GPU draw calls in r_drawWorld(), we grab the world object and tell it to "update all". You might be mislead into thinking this is where we process game logic every frame, and you would be half-correct! When running the game on a single thread, we ask the world to move forward in time and ensure the game state is up-to-date before we show the player what they can see:
w->w_singleThreaded_updateAll();
...but it gets a little more complicated than that, because how you time your gameplay updates matters a lot! We'll talk about this in the next section.
I won't cover the title menu or the other stuff here because it's a lot of mumbo-jumbo code that's not very fun to read. But if you're curious, here's the entire (unfinished) renderer.cpp from the current build of the game, go nuts champ. Just beware that it's a mess.
A Glitch in the Matrix
(Simulating a Virtual World)
Before we can fully appreciate r_drawWorld() we need to understand the context it was written for.
Earlier, I brought up the idea of a "scene graph". A scene graph is a hierarchical pattern common in virtual worlds, which goes hand-in-hand with object-oriented programming. Essentially each object can have child objects, and those can have child objects, and so on. This allows us to naturally and intuitively build things that humans can think of as complete objects, break them down into smaller parts, or piece them together as a larger whole and categorize them. In many scene graph structures, objects can also have attributes (such as in markdown document languages, like HTML or XML). Or, they can have special non-world objects applied to a list, such as behavior scripts. A very common example of this is the Entity Component System (ECS) in engines like Unity, Godot and Xenko.
However, this is OUR game! We're only building a 2D arcade shooter, and since our world objects are very simple and mostly consist of enemies and the player character, without very many moving parts, we can actually simplify this down and create our own system.
TIME FALCON uses a one-dimensional list of objects, with no hierarchy of children or additional components. In future versions of the engine we will likely change this to make it more adaptable, but an ECS is just not necessary for our game. We do however want to have multiple hitboxes and multiple textures per-object which can be animated independently, something a hierarchical parent-child system is often required for.
To accomplish this, each object has a list of o_sprite objects which contain texture information, and additionally a list of o_bounds objects that dictate the size and transformation of collision boxes. Finally, we store some transform information in an o_transform which the renderer and collision system will use to slide and rotate our object around. This way we do not need to individually move each piece of the object which would be cumbersome and annoying.
The end result looks something like this. Pay attention to the virtual function updateMe(). This part is pretty bog-standard.
struct o_bounds {
float posX = 0;
float posY = 0;
float sizeX = 0;
float sizeY = 0;
float pivotX = 0; // % of object size,
float pivotY = 0; // from 0.0 to 1.0
float rotation = 0; // deg. from 0 to 360
};
struct o_sprite {
ALLEGRO_BITMAP* spritePtr = nullptr;
o_bounds spriteBounds;
o_bounds o_autoAdjustSpriteBounds() {
o_bounds out;
out.sizeX = al_get_bitmap_width(spritePtr);
out.sizeY = al_get_bitmap_height(spritePtr);
out.posX = 0;
out.posY = 0;
out.pivotX = 0.5;
out.pivotY = 0.5;
out.rotation = 0;
return out;
}
};
struct o_transform {
float posX = 0;
float posY = 0;
float rotation = 0;
};
class o_worldObjectGeneric {
public:
// Do your thang - this needs to be overridden per object type
virtual void updateMe() {};
// how many ticks the object has lived for, incase we need it
// notice that this number is 64-bit, so it should never overflow
// 64-bit numbers have a performance penalty on 16-bit or 32-bit CPUs!
// Use them sparingly if you care about that!
uint64_t timeAlive = 0;
// the stuff we just talked about!
o_transform transform;
std::vector<o_sprite> sprites;
std::vector<o_bounds> hitboxes;
// we might override this too if a certain object type needs special care before being deleted
virtual ~o_worldObjectGeneric() = default;
};
If you've worked in the Unity engine before, updateMe() is identical to the FixedUpdate() function. As part of the ECS, Unity keeps a list of GameObjects, which in turn contain a list of MonoBehaviors (the components) and a list of child GameObjects. Those MonoBehaviors then have a common Update() and FixedUpdate(), which are overridden by the game developer's own classes that inherit the MonoBehavior class. Unity simply calls these overridden functions over and over, and that is the heart of how Unity works. Hope that demystifies things! It's a very simple solution but is very powerful.
We are doing basically the exact same thing here in our custom engine. The only difference is that we inherit the game object directly and skip the component system since we don't use an ECS. When we want to create a new object type, we write something like this in our C++ code:
class o_MyPlayerCharacter : o_worldObjectGeneric {
void updateMe() override {
std::cout << "*screeeeeech!*\n";
transform.posX -= 0.01f; // slide left slowly
}
}
Again, if you have used Unity before, this will look super familiar to you. The derived class syntax is nearly identical to C#. Had we written our game in C or x86 Assembly, this approach using the polymorphism features of C++, C# and Java would unfortunately not be possible (or at least, it would not be as easy as this).
So now, imagine we have loaded our game level (how you do that is very subjective, we won't cover that here). Now we want to process our game logic. We have the object list and we can call updateMe() on every object inside of that list using a for() loop. Simple enough, right? Wrong.
This is where things get a bit complicated again.
If you try to call the for() loop on each object every frame, you'll notice that the game speeds up or slows down with the frame rate:
void world::w_singleThreaded_updateAll() {
for (globals::objects::o_worldObjectGeneric* o : w_content) {
o->timeAlive++;
o->updateMe();
}
w_totalTicks++;
}
This is what many beginner guides suggest you do. Even the Allegro tutorial suggests this. I am going to tell this to you straight, right now, before it bites you in the ass later.
DO NOT DO THIS. PERIOD. I'm dead serious.
This was common on a lot of very old video games from the 8-bit and 16-bit era, when most games were expected to run at a fixed resolution and refresh rate, depending on what part of the world you were in. And it was acceptable back then! Well, unless you were European, in which case you were usually screwed and you had to play in slow-motion.
Nowadays, this practice is absolutely unacceptable. It is a rookie mistake. Yet, many game developers still are guilty of it. Whether it be indie developers like Terry Cavanagh with Super Hexagon (sorry Terry <3), or even industry veterans working on Need for Speed: Rivals! The result is that these games cannot be run at any framerate other than the default, without serious game-breaking bugs. You might be able to get away with this system on a Nintendo console, but PCs have had variable resolutions and refresh rates as a standard feature for decades now. Even Xbox and Playstation are offering 120 FPS modes! You are hence doing a disservice to the gamer with your terrible world update code.
So what is the correct way to handle this, so that our world updates are decoupled from the framerate? Every single solution, in C/C++ anyway, involves using the std::chrono module in the standard library. But how do we use it?
You can read more about the many different ways of tracking time with std::chrono here if you're curious, but I'll get right to the most common ones.
A frequent method for first-time developers is to simply track the amount of time each frame takes, and then multiply the physics calculations by how much time has passed. This way, things are slowed down accordingly to compensate for a faster update rate, and vice versa. This works okay enough for some games, and it is also nice because it means you do not need to interpolate anything in your renderer.
However, there are several caveats, including but not limited to:
- Large time values can cause catastrophic glitches. You have to limit the maximum jump in time allowed between frames, since the timer is constantly running at a hardware level, and any lag spike or additional delay will affect physics.
- It's not really that accurate. Your game will still "feel wrong" at different framerates. For example, jump height is broken in Call of Duty: Black Ops 2 if the framerate is too high because the movement code in that game uses the renderer delta (even though the game already has a more robust system like ours, which we'll cover soon).
- It makes networking and demo playback harder. This one is complicated to explain, but the gist is that trying to keep track of precise timing and communicate that gamestate in a way other computers can synchronize with is not very intuitive. A sub-tick system like in Source 2 is probably a requirement, and even then there are other challenges.
- Different computers may produce slightly different results. There have been lots of different implementations of the high-resolution clock in various PCs and consoles. Some very old devices don't even have one. This problem is somewhat unavoidable, but it is exasperated when the time actually affects the result of your physics rather than just the speed.
This way of doing things just sucks, especially for our game which needs reproducable logic across systems. Instead of solving the problem, we have just introduced tons of extra factors that affect our world simulation, which weren't there when we still had everything tied to the framerate.
But what if we could have a consistent, forced rate of calculations, like with our framerate limit, that is at the same time decoupled from the framerate? This is what we like to call tickrate. Instead of calculating the world logic every frame, we add an additional rate limiter to the world calculations and count the number of updates separately.
The easiest way to do this on a modern computer is to create a new thread, the "world thread", when we start playing, and then add an identical system to our framerate limiter we covered earlier in the first section. Easy enough.
It gets a little bit more complicated when we want to do everything on a single thread. Since we have to wait on the renderer to finish processing, our physics are automatically affected by the framerate in some capacity. So how do we control the speed at which each game tick is processed?
This is what we did:
void world::w_singleThreaded_updateAll() { // todo: w_maxTickBuffer and w_minTickBuffer
const auto now = stdclk::now();
// dumb hack. game will be slow-mo for one frame, doesn't really matter but pay attention incase
if (w_totalTicks == 0) updateWorld();
else {
w_delta += std::chrono::duration<float>(now - w_lastUpdate).count();
while (w_delta >= (1.0f / w_tickrate)) {
updateWorld();
w_delta -= (1.0f / w_tickrate);
}
}
w_lastUpdate = now;
}
The for() loop for updating each object in the world has been moved to a new function, updateWorld(). updateAll()'s job is now to calculate how much time has passed and then measure how many ticks to move forward. This is roughly the same system that the idTech and Source engines use.
The simplest way to implement this is to accumulate time, and then as long as that time is over a certain limit, we subtract that limit from the accumulated time and call updateWorld(). This continues until every tick that was due for processing is complete. It is crucial that the remainder after processing be remembered in between calls to updateAll() every frame, because otherwise we will throw away microscopic points of time and our game will run slower than intended.
We now have a consistent, predictable system. And since our time is organized into ticks, it is trivial to implement input recording and create a demo system.
We're done with the basics of our engine. For most developers, this is enough to begin writing game logic and get a fun prototype going.
However, for our demo system, there's one last moving part.
Predictable RNG
In order to ensure we have a repeatable game session without storing the entire world state inside of our demo file, we want to make our own predictable pseudo-random number generator that we can reset to a certain seed at the start of every game. That way, any random logic that changes between playthroughs can be made non-random at will.
In TIME FALCON, we implemented a bit-shifting system similar to the one used in Atari's POKEY chip, which was common on many arcade boards and home computers manufactured by Atari.
This is the entire thing. It's very barebones and simple. Everything here is subject to change though as we improve the distribution of random numbers later on in our testing.
/// Ideally you want only one thread to read from this at a time,
/// otherwise use Touchless for non-world things.
namespace pokey {
constexpr uint16_t seed = 0b0010110100101110;
inline uint16_t prandv = seed;
/// Reset the pokey.
/// Do this before every new game.
inline void p_reset() {
prandv = seed;
}
/// Get a random number from the POKEY.
inline int p_getRand() {
// read number before randomization
// do some shifting around
int post = prandv >> 1;
bool x1 = post & 0b0000000000100000;//std::cout << x1 << std::endl;
bool x2 = post & 0b0000000000000001;//std::cout << x2 << std::endl;
bool x3 = post & 0b0000010000000000;
if (x1 != x2) post ^= 0b1000000000000000; // flip the highest bit
if ((x1 != x2) != x3) post ^= seed;
// self-explanatory
const uint16_t pre = prandv;
prandv = post;
return pre;
}
/// Get a random number from the POKEY without shifting.
/// Redundant since prandv is public, but here it is anyway.
inline int p_getRandTouchless() {return prandv;}
}
There isn't much else to talk about, so I will end the blog post with a Youtube video by Decino covering another RNG system used by id Software. It's wildly different from our implementation, but was designed to solve the same problem!
Cheers, and happy coding!