Gian Saß

Rapid Native Game Development With Live Code-Reloading

· Gian Sass

Developing games in C/C++ is a tedious process. Things like compiling take ages and in-game bugs are hard to debug and fix. And the vicious cycle of compiling, restarting, and backtracking destroys focus and productivity.

To solve this problem, most implement a scripting language: Lua for instance is quite popular with game developers. One can reload game scripts at runtime without having to restart the game. In this way AI code for a monster could be written and tested seamlessly. A huge win!

But there is a catch.

  • Having to support an additional language makes everything more complicated.
  • Adding an API that interacts with your engine creates a lot of boilerplate code.
  • Performance losses are not to be forgotten!

So what if we could write our game entirely in C and reload our code instantly, leaving things “hardcoded”?

Introducing extremely modular architecture

Sometimes the solution is a complete revamp. Split the engine into two pieces.

The problem is not C, the problem lies in monolithic architectures. If both game logic and engine are hosted on the same executable, there is no way to reload one thing while keeping the other running. Thus we need to split both pieces into separately compileable layers.

In summary: the challenge is to architect your engine to maintain the game code in a library which essentially be hot-swapped live.

How do we do this? First stop and think about the what role memory has to take in a design like this. What can we do to keep the game state intact when reloading the code? Allocating memory on the game side is a no since its heap would be destroyed once we unload the library.

_Solution: _allocate all game memory on the platform layer and pass it to the game on each reload. This poses another challenge because it implies manual memory management, but more on that later.

Lastly there needs to be some sort of way for the game to interact with the platform layer and vice-versa. To do that, the game must export functions that the platform layer will use to update and render the game. On the other side, the platform layer passes a struct of function pointers to the game through an initialisation function which we will deem GameInit.

In this way, we can abstract the platform-specific parts of the engine.

I’ll document a sample engine for demonstration purposes. The following code examples will be in C (actually just C-like C++ utilising some of C++’s shortcuts such as eliminating the need to typedef structs etc.), but other native language should work as well!

You should always consult my repo as reference if things are not working.

Code layout

The project layout as follows:

  • shared.h: Code shared by both layers
  • platform.cpp: The platform layer
  • game.cpp: The gamer layer
  • build.bat: A simple build file

Let’s start with common definitions for both layers. These will be placed inside shared.h.

First, the memory part.

#include <stdint.h>

// All game memory is encapsuled in this struct. It uses the basic
// technique of stack allocation.
struct GameMemory
{
    uint8_t *ptr;
    uint8_t *cursor;
    size_t size;
};

// Allocate a block of memory
inline void *
GameAllocateMemory(GameMemory *memory, size_t size)
{
    void *result = memory->cursor;
    memory->cursor += size;
    return result;
}

// Simple helper macro to make allocation of structs easier, you
// could also use a template for this
#define GameAllocateStruct(memory, type) \
    (type *)GameAllocateMemory(memory, sizeof(type))

Game memory will only need to use a simple stack allocator. This technique proves fine for most games, and game objects or entities can be pre-allocated inside a pool for instance.

The rest establishes function signatures. Do not get confused by these macros. They simply help keep function signatures across the codebase correct.

// Only one platform api call as an example. This draws a white box.
#define PLATFORM_DRAW_BOX(n) void n(float x, float y, float width, float height)
typedef PLATFORM_DRAW_BOX(PlatformDrawBoxFn);

struct PlatformAPI
{
    PlatformDrawBoxFn *PlatformDrawBox;
};

//
// These are all the game functions. These macros help maintain the
// signature across various places easier.
//

#define GAME_INIT(n) void n(GameMemory memory, PlatformAPI api)
typedef GAME_INIT(GameInitFn);

#define GAME_UPDATE(n) void n(float dt)
typedef GAME_UPDATE(GameUpdateFn);

#define GAME_RENDER(n) void n()
typedef GAME_RENDER(GameRenderFn);

Coding the game layer

This basic game layer does not much except implement the basic game API. But to demonstrate the hot-swapping feature we will make the game display a diagonally moving white box.

Later you will be able to change the hardcoded velocity of the box (as defined in GameUpdate) while the game is running and see a change instantly!

All game state will be encapsuled in GameState. It keeps track of the game’s memory and the platform API (which is used to display the white box). Moreover we also keep track of the position of the white box.

#include "shared.h"

struct GameState
{
    GameMemory memory;
    PlatformAPI api;

    float x;
    float y;
};

static GameState *state;

Notice that we declare the state as a pointer. It is important to understand that the game state will live inside the memory supplied by the platform layer. In this way, the game state will remain intact even if the game library is unloaded, in which case the game library’s heap is destroyed, thus clearing any memory allocated on the game’s side.

In the GameInit function we first make use of our memory functions in order to allocate the game state on the platform’s stack memory. We then grab a copy of the platform API and store it.

In GameUpdate we apply a certain velocity to our box’s x and y-values. Finally in GameRender we draw the box using the platform API.

extern "C" GAME_INIT(GameInit)
{
    state = GameAllocateStruct(&memory, GameState);
    state->api = api;

    if(state->memory.ptr == 0) {
        state->memory = memory;
    }
}

extern "C" GAME_UPDATE(GameUpdate)
{
    state->x += dt * 50.0f;
    state->y += dt * 50.0f;
}

extern "C" GAME_RENDER(GameRender)
{
    state->api.PlatformDrawBox(state->x, state->y, 50.0f, 50.0f);
}

A cryptic but essential part is:

if(state->memory.ptr == 0) {
    state->memory = memory;
}

Why do we do this? Remember that GameInit receives a GameMemory struct and that GameState also has a GameMemory struct. So which one to use?

The very first time GameInit is called, our game’s memory has not yet been written to yet and is thus filled with zeroes (this is not related to the fact that static variables are cleared at start rather that the supplied memory itself is cleared at start). Therefore if state->memory.ptr is null, that means our game state’s memory pointer has not been initialised yet. So we do a copy assignment.

Why do we keep track of the memory in our state at all? This is directly related to the fact that we are utilising a stack memory layout.

Recall the layout of GameMemory. It has a cursor pointing to next available space in memory. In order for our game to remember where we left off allocating, this cursor is saved in the state. Otherwise the game would not respect previous allocations and overwrite existing memory.

Coding the platform layer

Now, onto the platform layer.

  • Initialise SDL and OpenGL
  • Load the game code
  • Execute the game loop
  • Call game functions
  • Reload game code when file was overwritten

Include files:

#include <stdarg.h>
#include <stdio.h>
#include <string.h>
#include <sys/stat.h>
#include <SDL.h>
#include <SDL_opengl.h>

#include "shared.h"

Similar to the game layer we will store all state in one single struct as this helps keep everything organised and clean. This basic platform layer only takes track of the essentials: a pointer to the window, OpenGL context, the game code, and memory.

static struct
{
    SDL_Window *window;
    SDL_GLContext gl_context;
    GameCode game_code;
    GameMemory game_memory;
} state;

We define three helper functions. First a general quit function and error function. The latter accepts a printf-style string and displays a cross-platform message box through SDL. Second, a function to retrieve a file’s last write time that we will use to identify if it is time to reload the game library.

void Quit()
{
    SDL_Quit();
    exit(0);
}

void Die(const char *fmt, ...)
{
    char buffer[1024];

    va_list va;

    va_start(va, fmt);
    vsprintf(buffer, fmt, va);
    va_end(va);

    SDL_ShowSimpleMessageBox(SDL_MESSAGEBOX_ERROR,
                             "Houston, we have a problem!",
                             buffer, state.window);
    Quit();
}

time_t GetFileWriteTime(const char *file)
{
    struct stat buf;
    if(stat(file, &buf) == 0) {
        return buf.st_mtime;
    }

    return 0;
}

The next part deals with loading and unloading the game library. The entire game code is stored in a struct called GameCode that holds function pointers that we retrieve from the game library. We also store the library handle and a timestamp of the library’s last file write-time.

Loading the game library is pretty simple but we cannot use the library as-is, because it needs to be able to be written to in order for us to do live-compiling. We thus need to make a copy of the library which we’ll load instead. In this way, the original file can be written to at anytime. You’ll see in a bit why this is important.

struct GameCode
{
    GameInitFn *game_init;
    GameUpdateFn *game_update;
    GameRenderFn *game_render;

    HMODULE handle;
    time_t last_file_time;
};

GameCode LoadGameDLL(const char *path, const char *temp_path)
{
    GameCode result = {};
    result.last_file_time = GetFileWriteTime(path);

    CopyFileA(path, temp_path, FALSE);

    result.handle = LoadLibraryA(temp_path);
    if(result.handle) {
        result.game_init = (GameInitFn *)GetProcAddress(result.handle, "GameInit");
        result.game_update = (GameUpdateFn *)GetProcAddress(result.handle, "GameUpdate");
        result.game_render = (GameRenderFn *)GetProcAddress(result.handle, "GameRender");
    }

    return result;
}

void UnloadGameCode(GameCode *game_code)
{
    FreeLibrary(game_code->handle);
    game_code->handle = 0;

    game_code->game_init = 0;
    game_code->game_update = 0;
    game_code->game_render = 0;
}

Here we implement the DrawBox function — a simple OpenGL draw-call. Lastly a function that creates a valid PlatformAPI struct. For real games I discourage you of creating a separate function for every drawing mechanism. Instead I suggest populating a command buffer and sending that into the platform layer to consume.

PLATFORM_DRAW_BOX(DrawBox)
{
    glBegin(GL_QUADS);
        glColor3f(1.0f, 1.0f, 1.0f);
        glVertex2f(x, y);
        glVertex2f(x+width, y);
        glVertex2f(x+width, y+height);
        glVertex2f(x, y+height);
    glEnd();
}

PlatformAPI GetPlatformAPI()
{
    PlatformAPI result = {};

    result.PlatformDrawBox = DrawBox;
    return result;
}

The game’s memory will be allocated here. Notice the use calloc and not malloc, thus zeroeing out the memory at startup. You should remember why we explicitly do this.

GameMemory AllocateGameMemory()
{
    GameMemory result = {};

    result.ptr = (uint8_t *)calloc(1, 1024);
    result.size = 1024;
    result.cursor = result.ptr;

    return result;
}

In the main function we do the standard SDL-initialisation. This is the first time the game code is used in the platform layer. Since I described every function previously, the workings of this code should be self-explanatory.

int main(int argc, char *argv[])
{
    if(SDL_Init(SDL_INIT_EVERYTHING) < 0) {
        Die("Failed to initialize SDL2: %s\n", SDL_GetError());
    }

    state.window
        = SDL_CreateWindow("Awesome Game",
                           SDL_WINDOWPOS_UNDEFINED, SDL_WINDOWPOS_UNDEFINED,
                           640, 480,
                           SDL_WINDOW_OPENGL | SDL_WINDOW_SHOWN);

    if(!state.window) {
        Die("Failed to create window: %s\n", SDL_GetError());
    }

    state.gl_context = SDL_GL_CreateContext(state.window);
    SDL_GL_SetAttribute(SDL_GL_DOUBLEBUFFER, 1);
    glOrtho(0.0f, 640.0f, 0.0f, 480.0f, 0.0f, 1.0f);

    state.game_memory = AllocateGameMemory();
    state.game_code = LoadGameDLL("game.dll", "game_temp.dll");
    state.game_code.game_init(state.game_memory, GetPlatformAPI());

    GameLoop();

    return 0;
}

Lastly: our game loop function which performs updating and rendering.

void GameLoop()
{
    for(;;) {
        SDL_Event event;
        while(SDL_PollEvent(&event)) {
            if(event.type == SDL_QUIT) {
                Quit();
            }
        }

        state.game_code.game_update(1.0f/60.0f);

        glClear(GL_COLOR_BUFFER_BIT);
        state.game_code.game_render();
        SDL_GL_SwapWindow(state.window);

        // RELOAD
        time_t new_dll_file_time = GetFileWriteTime("game.dll");
        if(new_dll_file_time > state.game_code.last_file_time) {
            UnloadGameCode(&state.game_code);
            SDL_Delay(200);
            state.game_code = LoadGameDLL("game.dll", "game_temp.dll");
            state.game_code.game_init(state.game_memory, GetPlatformAPI());
        }

        SDL_Delay(1);
    }
}

In this block all the magic happens:

time_t new_dll_file_time = GetFileWriteTime("game.dll");
if(new_dll_file_time > state.game_code.last_file_time) {
    UnloadGameCode(&state.game_code);
    SDL_Delay(200);
    state.game_code = LoadGameDLL("game.dll", "game_temp.dll");
    state.game_code.game_init(state.game_memory, GetPlatformAPI());
}

If we notice that the game library has been updated, we unload and reload the game code. The induced delay is because there is a short time delay between the file update and restored access.