It's been 15 years, what have I learned?

Justin Wernick <>
Curly Tail, Curly Braces
2026-05-13

Abstract

15 years ago, for a university assignment, I programmed a small game in C++. In this article, I'm looking back at that source code, and reflecting on what I had right at the time and what I've learned to do better.

I was moving some old personal git repos around, tidying up my archives, and one caught my eye from 2011. Yes, 15 years ago! At the time, I was still in university, and this code was the main project for one of my software development courses.

The project itself was a game. I was tasked with writing a clone of the old arcade game Rally X, in C++, with the only dependency being the Allegro library for abstracting away platform-specific things like creating windows, drawing sprites in those windows, and user input.

I really enjoyed working on this project and put a lot of effort into it. That, combined with the university-style commenting on everything, makes this project a really interesting time capsule into how I was thinking about software at the time.

I thought it would be interesting to look through the code and reflect on how my opinions of software have developed with time and experience. The repository has recently moved to SourceHut if anyone wants to see the whole thing, but I'll also be including the relevant snippets as I talk about them.

I'm separating my insights into things that seemed like a good idea at the time and actually worked out, things that I was wrong about, and things that I clearly had no idea about at the time. Let's dig in!

It still seems like a good idea

These are things that I did in this project that I thought were a good idea at the time, and after many years of professional experience I am still happy with.

RAII - Resource Acquisition Is Initialization

RAII is an idiom that I quite like, that is sadly difficult to achieve in many programming languages, especially those that have garbage collection.

The pattern is that if you have some resource, like a file handle, a database connection, or a window on the screen, you wrap it in a class. In the constructor, you need to get that resource by opening the file, connecting to the database, or opening the window. If you fail to get the resource, the constructor should fail. In the destructor, you release the resource by closing the file, closing the connection to the database or closing the window. As you can see, this relies on the language having destructors that are reliably called when an object goes out of scope, which is why it doesn't work well in garbage collected languages.

In the case of this project, Allegro had an al_init function that needed to be called before doing anything Allegro related. Similarly, you should call al_uninstall_system for a clean shutdown. But you don't want to do either of these twice! So I created an AllegroInit class, and anything that needed to do something with Allegro would just need to initialize one of these. Internally, it had a reference count to avoid calling al_init twice, and to avoid calling al_uninstall_system too early.

int AllegroInit::_initCount = 0;

AllegroInit::AllegroInit()
{
    if (_initCount==0)
    {
        if (!al_init())
        {
            throw InstallFailure();
        }
    }
    ++_initCount;
}

AllegroInit::~AllegroInit()
{
    --_initCount;
    if (_initCount==0)
    {
        al_uninstall_system();
    }
}

As with many things that I still think are a good idea, I could have done this better. Specifically, even though I was using the AllegroInit class to make sure that Allegro was initialized, I didn't actually encapsulate any other Allegro calls behind it.

One of my favourite modern examples of this is how the Rust standard library handles Mutex locks. When you call mutex.lock(), it will construct a MutexGuard and acquire the resource that is the shared data behind the mutex. When the MutexGuard goes out of scope, it will automatically release the lock.

use std::sync::Mutex;
let mutex = Mutex::new(0);
{
    let mutex_guard = mutex.lock().unwrap();
    println!("Here we have access to the data: {}", mutex_guard);
}
// The lock is cleared up when the guard returned by mutex.lock() goes
// out of scope, and its destructor / Drop function is called.

If it can be const, it should be const

When looking at function signatures in this codebase, there's a lot of const. I liked the idea that you should be able to pass around references to objects without allowing the whole codebase to make changes.

class EnemyCar: public Car
{
    public:
        void update(const Maze& maze, const list<PlayerCar>& players, const list<Rock>& rocks);
};

In many languages, this isn't really practical. JavaScript's const keyword confusingly means that you can't change what object the variable points to, but making changes to that object is still completely fine.

A few years, I discovered Rust and functional programming languages, that flip the defaults: immutable data is the default, and if you want to be able to change something it needs to be explicitly flagged as mutable. This example would achieve the same effect of passing everything by immutable reference.

impl EnemyCar {
    fn update(&mut self, maze: &Maze, players: &[PlayerCar], rocks: &[Rock]) {
    }
}

Separate logic from presentation

I had the idea here, but I hadn't quite developed a full understanding.

The code is split up into three layers:

I still think that this separation of concerns is a good idea. Having the game logic being an isolated module makes it much easier to unit test. It also allows you to have multiple different presentation options if necessary. "Presentation" doesn't just need to be GUI interfaces either, it could also mean things like terminal interfaces, web APIs, or test harnesses.

On a high level I had the right idea here, but my execution was problematic. Even though I sorted the code into directories with their layer names, I didn't enforce how they were allowed to interact.

There was chaos where everything was allowed to call anything. These days I would enforce a strict dependency direction between these layers. The presentation layer may call the logic layer, but the logic layer may not call the presentation layer. This is essential if you want to be able to use the same logic layer with different presentation interfaces. I would enforce it by putting the layers into different libraries / crates or packages. I would not let the PlayerCar query the presentation layer directly to get keyboard input! The PlayerCar class should not have to be changed if I later add gamepad support!

Creating simplifying constraints

All projects will have contraints. The constraints of the business domain you're working in, the technical constraints of the computers you can run it on, legal regulations, the time you have to spend on the project.

Sometimes, you can add your own constraints just to simplify your own life.

In the API docs for the BitmapStore, which is in charge of drawing sprites, I have this comment:

/**
* All images are square, to allow easy rotation and placement on the
* screen.
*/

The constructor took in a single unsigned integer, to indicate the block width. No need for a block height, because the block is square.

class BitmapStore
{
    public:
        /**
        * @brief Constructor for creating a BitmapStore with a set
        * image size.
        *
        * @param [in] blockWidth The width (and height) of an image
        * returned by the store in pixels.
        */
        BitmapStore(unsigned int blockWidth);
};

There was nothing in the project spec to indicate that sprites should be square. I chose to inflict that constraint on myself because it made the rest of the problem easier: scaling sprites to an unknown resolution, laying them out on the screen, or rotating them in 90 degree increments.

Configuration is a simple text file

This was a relatively simple project. Still, there was some configuration needed to ensure that it would be usable on any system: what screen resolution should it use?

I'm glad now that the resolution is configurable, because at the time 1366 by 768 pixel fullscreen was actually was a common default for laptops.

The thing that is still a good idea is that the configuration lives in a simple text file, clearly inspired by formats like ini, in the game's directory.

screen_width=1366
screen_height=768
fullscreen=false

In a bigger project, I would probably include the ability to change these settings in the game menu itself. For the scope of this project, having a simple config file to edit was enough. The game would even create the file on first run if it didn't exist.

Looking at modern software that I write, I now explicitly use a standard format like TOML. This is mostly to allow using a shared library for reading and parsing these files. In fact, I did just this on my recent project, localhost-podcast.

Unit testing

I've written before about how I think that unit testing, and test driven development, is kind of important. Now I didn't have a good sense of how to test well yet, but learning how to write automated tests for me started with this project.

I had a whole bunch of tests like this to increase my confidence that the game was working as I expected it to:

/**
* @brief Tests that PlayerCar does not move up if currently on the top
* row of the maze.
*/
TEST(Car, carDoesNotMoveUpOutMaze)
{
    Maze testMaze;
    vector<pair<int,int> > walls;
    testMaze.generateMaze(walls,5,5);

    PlayerCar player(3,0,Maze::UP);

    list<Smokescreen> smokescreens;
    player.update(testMaze, smokescreens);

    double expectX = 3;
    double expectY = 0;
    EXPECT_FLOAT_EQ(expectX, player.x());
    EXPECT_FLOAT_EQ(expectY, player.y());
}

I was committing all sorts of testing sins. I had tests that relied on the side effects of previous tests. I even had tests that popped up alerts and expected you to dismiss them for the test to continue! But even with all of that, tests were still a much faster feedback loop than booting up the game to get into each permutation of making sure the objects moved as expected.

Glad I'm not doing that anymore!

Next, let's take a look at some things that I thought were a good idea at the time, but in hindsight aren't actually a good idea.

EVERYTHING is in an object

Object oriented programming was popular at the time. The two languages I was familiar with were Java and C++, so modelling things with objects seemed to be the way to go.

While this is still appropriate in some cases, I went too far. Everything was put into objects. If I had a helper function, it would be a private function on the class, even if it was a static function that didn't use the class. The most egregious case was that I had a few mathematical helper functions, and I put them together on a class that had only static functions. Almost as a clue to this being silly, I marked the various constructor functions as private so that the compiler wouldn't provide a way to instantiate this meaningless empty class.

class MazeMath
{
    public:
        static double round(double value);
        static double distance(double x1, double y1, double x2, double y2);

    private:
        // These constructors are all actually unimplemented
        MazeMath();
        MazeMath(const MazeMath& ref);
        MazeMath& operator=(const MazeMath& rhs);
};

Inheritance vs Composition

In the same way that I thought everything should be in an object, I also thought that object oriented inheritance was the one true way of sharing functionality between classes. I knew what polymorphism was, and I wanted my code to show that I knew. I was lucky that my requirements here were static, nicely typed up in an unchanging project brief, and so I could come up with a neat deeply nested class hierarchy and never have it overlap strangely. I wouldn't have been happy if my requirements changed in the middle of development.

When I wanted to have a few unrelated game objects automatically get destroyed after some amount of time, I created an abstract LimitedTimeObject class, and slotted it into the object hierarchy between the base GameObject and the child objects that needed the behavior: DestroyedObjectPopup and Smokescreen.

When I had common movement logic to share between the player and the enemy, it went in an abstract Car class, and was slotted into the object hierarchy between the base GameObject and the child objects PlayerCar and EnemyCar.

Good thing I never ended up needing an object that both moved and was destroyed after a time!

These days, I would lean much more heavily on composition. Rather than having the PlayerCar inherit from a base class that can move, I would give it a component that knows all about moving. Rather than having the Smokescreen inherit from LimitedTimeObject, it can have a component with a timer.

The extreme version of this would be the entity component system pattern, where the game entities don't really have any of their own data or behavior, they are just a collection of components, that are acted on by systems.

Performance optimizations without seeing the big picture

Games are performance sensitive applications. If you don't manage to finish your update and redraw the screen fast enough, then your game will stutter and lag. This started to be a problem for me in the middle of development, I needed to figure out how to increase my frame rate.

Now I did do one thing right here: I ran my code through a profiler to get an idea of where my program was spending its time. However, I then tried to fix it by looking at just the hot spots rather than zooming out to think about why they were hot. I didn't think about the application as a whole.

One of the big performance killers that showed up on my profiler was checking if a block on the maze was solid or not. This was used all over the place, the player car would check it to see if you could go somewhere, the enemy car would check it when doing navigation, and various rendering functions would check it to draw the screen. However, when optimizing it, I almost exclusively looked at the getSolid function itself.

bool Maze::getSolid(const int& x, const int& y) const
{
    if (x<0 || y<0) return true;
    if (x>=width() || y>=height()) return true;
    // bounds have already been checked, can use more efficient,
    // less safe, indexing
    return _wallLocations[x][y];
}

Looking at this code again, I feel that it would have benefitted from zooming out. Maybe I should have tried some different sparse data structures for _wallLocations. Since the majority of getSolid calls probably came from the rendering calls, maybe I could have exposed an iterator over the walls to avoid needing to individually query each block. Zooming out further, this maze's walls only ever change when loading a new level - maybe I should have been rendering the maze only once to a buffer and caching the result.

Speaking of performance, I had some questionable ideas about data structures. I used linked lists for most of the game objects, because game objects can be added or removed, and I'd heard somewhere that linked lists would be better for performance when removing items. I never challenged this inherited wisdom using a profiler.

list<EnemyCar> _enemies;
list<Checkpoint> _checkpoints;
list<Rock> _rocks;
list<Smokescreen> _smokescreens;
list<DestroyedObjectPopup> _popups;

I now know that, thanks to cache locality, linked lists are not a great choice in terms of performance when you're iterating over the list, which is what I was doing a lot of.

Header Files and other C++ nonsense

A few years into my career, I actually started to feel nostalgia for C++. Rather, I was feeling nostalgia for the low level control and low runtime overhead that C++ promises. These days, I scratch that itch without having to deal with all of C++'s other warts by using Rust.

Looking back at this project reminds me of the inconveniences of working with C++ that I'm glad I don't need to deal with anymore.

First up is header files. In C++, doing object oriented programming means constantly having one file where you define the shape of your class, including what functions it has, and a second file where you actually implement them. Also, every header file needs to start with a magic preprocessor directive to ensure that if two files include the same header you only end up with one copy of it.

#ifndef CAR_H
#define CAR_H

// all the rest of the source code

#endif // CAR_H

The second is weird parsing stuff that you kind of just need to work around in C++. Like having to put a space in the type vector<vector<bool> >, because if you leave out that last space C++ will think that it's the >> operator. I don't know if this is still a problem in modern C++ compilers, but it was when I was working on this project, and I'm glad to not need to deal with it anymore.

While I'm complaining about C++, there's this weird thing that I do in almost every class. It looks like this:

class Screen
{
    private:
        /**
        * @brief Unimplemented copy constructor, prevents copying of
        * Screen objects.
        *
        * Copying a Screen is unneccesary as there should only be a
        * single Screen object.
        */
        Screen(const Screen& ref);
        /**
        * @brief Unimplemented assignment operator.
        *
        * @see Screen(const Screen& ref)
        */
        Screen& operator=(const Screen& rhs);
}

What is going on is that I have a class which isn't meant to be copied. Make a single instance, pass it around, and it has a destructor to clean up when it's done. But the C++ compiler will give you, without asking, a copy constructor and an assignment operator. If you don't want them, you need to add them as a private function, and then not implement them. In every other context, not implementing something that you declared in a header file looks like a mistake.

Functions that all take the same 7 arguments

I initially wanted to have just one big list of game objects. Running the update functions would look something like this:

for (list<GameObject*>::iterator iter = _entities.begin(); iter!=_entities.end(); ++iter)
{
    iter->update();
}

That didn't work out, because some objects had dependencies on others. For example, the enemy car update function needed to do pathfinding, so it needed to know where the player car and obstacles were.

I ended up solving this by storing each entity type in its own list. The consequence? I had a whole bunch of functions with signatures that looked like this:

virtual void draw(const Maze& maze,
                  const list<PlayerCar>& players,
                  const list<EnemyCar>& enemies,
                  const list<Checkpoint>& checkpoints,
                  const list<Rock>& rocks,
                  const list<Smokescreen>& smokescreens,
                  const list<DestroyedObjectPopup>& popups);

I know that many modern game engines solve this problem using an entity component system. Given the relatively small scope of the game, and the requirement to limit external dependencies, I don't think I should have gone that route. But I should have found a middleground and put these many lists on a common struct. Maybe even with a function to iterate over all of them as if it were a single list of GameObject for the cases where that polymorphism does work, like in rendering.

virtual void draw(const GameWorld& world);

Mutable state everywhere!

While working on this project, I would mark things as const if it was convenient. However, I had not yet become as suspicious of mutable state as I am now.

An example would be how I handled keyboard input. It starts just fine, I created a KeyboardHandler class, which had an event queue and data on the current state of input - which keys are currently mapped. And then, I let the PlayerCar depend directly on the KeyboardHandler, and flushed that event queue when the player car asked which keys were currently pressed.

void KeyboardHandler::updateFlags()
{
    ALLEGRO_EVENT event;
    while (al_get_next_event(_keyboardEvents, &event))
    {
      // updates state about which buttons are pressed
    }
}

Maze::Direction KeyboardHandler::getFacing()
{
    updateFlags();

    if (_up) _previousFacing = Maze::UP;
    else if (_down) _previousFacing = Maze::DOWN;
    else if (_left) _previousFacing = Maze::LEFT;
    else if (_right) _previousFacing = Maze::RIGHT;

    return _previousFacing;
}
void PlayerCar::update(const Maze& maze, list<Smokescreen>& currentSmoke)
{
    _facing = _input.getFacing();
    move(maze);

    if (_input.getSmokescreen())
    {
        makeSmoke(currentSmoke);
    }
}

It made sense at the time, I wanted to have the most up to date keyboard state. Now I look at it and worry about the weird states that this could get into if the keyboard state is being updated while the player is querying about different keys. If I were doing this project today, I would explicitly flush the keyboard's event queue somewhere in the main update process, and PlayerCar would get an immutable snapshot of the keyboard state. This would also have the benefit of making it easier to unit test that they player responds correctly to inputs.

A less concerning but still notable issue was that all entities were mutated in place. This means that if an entity were to look around at all the other entities during its update, each entity would get a different view depending on what had been updated before it. Let's take the EnemyCar as an example. Enemy cars chase the player car. This means that part of their update logic depends on where the player car is. It makes a difference to the outcome if the enemy cars are updated before or after the player car.

If I were starting this project today, at the extreme end I would consider having an entirely immutable game world. This means that every frame, the update function would get an immutable reference to the previous game state, and would need to construct an entirely new game world in the new state. The reason I call this the extreme end of the spectrum is that it might not be as good from a performance perspective, although there are tools like persistent data structures to avoid needlessly copying things that haven't changed.

I had no idea!

These are some things that I clearly didn't know about at the time, and I'm glad that I have since learned.

Version control

I had no idea that version control existed or how to use it correctly. I had briefly touched Subversion during a summer internship, but the reasons to use it hadn't really sank in yet. The next year, I discovered Git and started using it for absolutely everything.

Repeatable builds

These days, I take repeatable builds quite seriously. There should be a set of steps that you can copy paste from a readme file into a terminal to build the project and run tests. These steps should be proven to work on a freshly installed system by using them on a CI build server.

For some strange reason, I didn't have that. There was a big collection of C++ source code files, but no Makefile, and not even a readme! I think the lack of readme is because, being a university project, it was submitted together with a full project report that I don't have anymore. Maybe the report had build instructions? Or maybe, in the tradition of academia, figuring out how to actually use the project was left as an exercise to the reader.

I remember using an IDE at the time, Code Blocks. I think I didn't think much about how to compile because I would just click the "Run" button there. Strangely, I didn't archive the Code Blocks project file with the rest of the project.

This is a great lesson on why documenting the build process is so important: if you ever come back a long time later you won't know how to get a working executable!

Luckily, this wasn't the most complicated project to build. It took a bit of work, but I managed to remember enough to create a Makefile to compile it.

How to work as a team

Like many university projects, this one was group work. I was meant to work together with a partner. Sadly, this was before I had any idea how to work well in a team, and the worst is that I didn't even know that I was bad at it!

Now to be fair to myself, it is very difficult to be good at working in a team when everyone's incentives and priorities aren't aligned. In a university setting, this is difficult because everyone is individually juggling the projects from five different subjects, probably with different groups, and are competing for individual marks. In a workplace, you can create the same problems using a performance review system that emphasizes individual contributions over team success.

We roughly split the work that I would do the majority of the coding, and my team mate would do the majority of the written report. These days, I think of this sort of approach, where a project is split down the middle at the beginning, like trying to build a bridge independently from both sides and praying that it meets in the middle. Both the code and the report suffered for being done in relative isolation.

Then, I made this split much worse than it was supposed to be because I didn't know the technical tools for working together on software projects. I mentioned earlier that I only learned how to use version control the next year, so I was sporadically creating a zip of the project and emailing them to my partner. I also didn't have any continuous integration server, so for a long time at the beginning of the project my partner assumed there was something wrong on his side when he couldn't compile my code: in reality I hadn't ever explained how to link to the Allegro dll in the zip.

We could have worked around the technical difficulties better if I had better social tools for working together on software projects. At the very least this would have meant regular standup-style checkins, and at least some pair programming.

Looking to the future

So that's what came to mind when looking at an old project. Some of it was quite good. I was proud to see that it still ran on modern hardware! And some of it, I can look at and feel good that I've learned a lot since then. Here's hoping that I'll be able to look back in another 15 years at the code I'm writing today and see a similar amount of growth!

Right now, we're in the middle of AI tooling changing what it means to write software. A lot of it is currently bad. Some of it could be useful. We're still in the middle of an economic bubble, that attracts grifters and makes it extremely difficult to predict what will happen with these tools after that bubble pops. Even just where we are now, it seems clear that software development won't look quite the same in 15 years. I'm trying my best to adapt along with this changing world, to keep writing good code, and keep developing my opinion on what good code looks like.


If you'd like to share this article on social media, please use this link: https://www.worthe-it.co.za/blog/2026-05-13-its-been-15-years-what-have-i-learned.html

Copy link to clipboard

Tags: blog, engineering


Support

If you get value from these blog articles, consider supporting me on Patreon. Support via Patreon helps to cover hosting, buying computer stuff, and will allow me to spend more time writing articles and open source software.


Related Articles

Game Programming Inspires My Software Development

I've been fascinated by computer games for as long as I can remember. In fact, almost all of my early computer knowledge came about because I wanted to play games. A lot of time has passed since then and I now work as a software engineer, and most of the systems I work on don't look much like games. Throughout my career, I've maintained an interest in what game engineers are doing. This article presents a few concepts from the context of computer games that help me to solve the problems that I do face.

Advent of Code: Expressing yourself

Every year, I participate in the Advent of Code programming advent calendar. This year, I set myself the challenge to complete the puzzles using only pure expressions in Rust. In this article, I share some of the techniques I used and how it worked out.

Subscribe to my RSS feed.