Building a Macro-Abstracted Game Engine
Modern C++ game development often involves significant boilerplate: managing window handles, rendering contexts, and event polling loops.
In this tutorial, we will explore a specific architectural pattern: Hiding the engine lifecycle behind preprocessor macros. This allows the end-user to write games that look like simple scripts, while maintaining the performance of C++.
Reference Implementation
The concepts taught here are based on the architecture of Wizard Engine.
The Philosophy
The goal of this architecture is inversion of control. Instead of the user calling `Init()` and `PollEvents()` manually, we provide a syntactic wrapper that handles this automatically.
We want the user code to look like this:
// Desired API
APP_MAIN("My Game", 800, 600) {
APP_LOOP(true) {
// Game Logic Here
}
}
To achieve this, we need to construct a singleton backend and wrap standard C++ entry points.
Prerequisites
We will use SDL2 as our backend rendering and windowing provider. Ensure you have:
- C++17 Compiler (Clang/GCC/MSVC)
- SDL2 Development Libraries
- CMake (optional, but recommended)
Step 1: Global State Management
Since our macros need to access the window and renderer from anywhere, we will use a Singleton pattern. This class acts as the bridge between the high-level macros and the low-level SDL calls.
#pragma once
#include <SDL2/SDL.h>
#include <iostream>
#include <string>
class EngineCore {
public:
// Singleton Access
static EngineCore& Get() {
static EngineCore instance;
return instance;
}
// State
SDL_Window* window = nullptr;
SDL_Renderer* renderer = nullptr;
bool isRunning = false;
// Initialization
void Init(const std::string& title, int w, int h) {
if (SDL_Init(SDL_INIT_VIDEO) < 0) exit(1);
window = SDL_CreateWindow(title.c_str(),
SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED,
w, h, SDL_WINDOW_SHOWN);
renderer = SDL_CreateRenderer(window, -1,
SDL_RENDERER_ACCELERATED | SDL_RENDERER_PRESENTVSYNC);
isRunning = true;
}
// Cleanup
void Shutdown() {
SDL_DestroyRenderer(renderer);
SDL_DestroyWindow(window);
SDL_Quit();
}
// Stop Request
void Quit() { isRunning = false; }
};
Step 2: Macro Abstraction
This is where the magic happens. We need to replace `int main` and the `while` loop. This technique is heavily utilized in the reference implementation (Wizard Engine).
The Entry Point
We define a macro that creates the standard `main` function, initializes our singleton, and then leaves an opening brace for the user.
#define APP_MAIN(title, width, height) \
int main(int argc, char* argv[]) { \
EngineCore::Get().Init(title, width, height); \
/* User code block will naturally follow this macro */
The Game Loop
The loop is trickier. In a game engine, a single frame usually consists of: Present (prev frame) -> Clear -> Poll Events -> Run Logic.
We can pack the boilerplate into the `while` condition using the C++ comma operator.
// Helper for event polling
inline void internal_poll() {
SDL_Event e;
while(SDL_PollEvent(&e)) {
if(e.type == SDL_QUIT) EngineCore::Get().Quit();
}
}
// The Loop Macro
#define APP_LOOP(cond) \
while (cond && EngineCore::Get().isRunning && \
(SDL_RenderPresent(EngineCore::Get().renderer), \
SDL_RenderClear(EngineCore::Get().renderer), \
internal_poll(), true))
Step 3: Asset Wrapper
To complete the engine, we need a way to draw images without exposing `SDL_Texture` pointers to the user.
class Sprite {
SDL_Texture* tex;
SDL_Rect rect;
public:
Sprite(const char* path, int x, int y) {
SDL_Surface* surf = SDL_LoadBMP(path);
tex = SDL_CreateTextureFromSurface(EngineCore::Get().renderer, surf);
rect = {x, y, surf->w, surf->h};
SDL_FreeSurface(surf);
}
void Draw() {
SDL_RenderCopy(EngineCore::Get().renderer, tex, NULL, &rect);
}
};
Final Result
By assembling these components, we have created a development environment that abstracts away the C++ complexity. Here is what the end-user experience looks like:
#include "engine.hpp"
// Setup window
APP_MAIN("My Retro Game", 800, 600)
{
// Load assets
Sprite hero("assets/hero.bmp", 100, 100);
// Start Loop
APP_LOOP(true)
{
// One line rendering
hero.Draw();
}
return 0;
}
Want to see this in production?
The Wizard Engine repository includes this architecture plus advanced features like Audio, Physics, and ECS.