Game Science Part 1: Fundamentals

Loops, Timing, and how to design a good framework for video games

written by Brett | 2025-01-17

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:

... 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:

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:

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:

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!

© 2025