Skip to main content

Project

My Game Engine

C++23 engine with SDL3, Vulkan 1.3, and a modular gameplay library.

Role

Game Engine, Tools, Runtime

Date

28-02-2026

Tech
C++23SDL3VulkanMesonNinjaGLM

Highlights

  • Vulkan 1.3 renderer with SDL3 windowing and input handling
  • Modular gameplay library with hot-reloadable state and scene/controller gameplay
  • Asset packing and cross-platform bundling (Linux/Windows) with Doxygen docs

Overview

This project is my "engine-first" way of building a top-down action game: a small C++23 runtime that hosts a game-agnostic engine library, plus a hot-reloadable gameplay module. The goal isn't to compete with existing engines — it's to have a codebase where I can change gameplay, rendering, UI, and tooling in the same place, and keep iteration tight.

The stack is intentionally boring in the best way:

  • SDL3 handles the window, input, and platform glue.
  • Vulkan 1.3 is the primary rendering backend.
  • Meson + Ninja build everything quickly (and predictably).
  • A small set of focused tools (asset packing + bundling scripts) keep “it runs on my machine” from becoming the release process.
My engine (screenshot placeholder)
Game screenshot.

Architecture: three targets, one loop

At a high level, everything is organized around a clean boundary between “host”, “engine”, and “game”:

  • Runtime host (executable): finds the gameplay shared library, boots the engine, and runs a session loop. It's responsible for hot reload and for making asset paths work in both dev and bundled builds.
  • Engine (static/shared library): rendering, audio, asset IO, input state, and a minimal scene framework. The engine exposes services and accepts callbacks.
  • Gameplay module (shared library): the game itself (scenes, controllers, UI screens, persistence). It exports a small C ABI surface so the host can load/unload it safely.

This split is the main reason the project stays pleasant to work on. I can rebuild just the gameplay module, have the runtime detect the updated .so/.dll, and swap it without restarting the whole program.

System architecture diagram
System architecture diagram.

Hot reload that keeps your state

The hot-reload path is deliberately explicit: the runtime host doesn't try to be “magical” — it just asks the module to serialize and deserialize its own state.

The loop looks like this:

  1. Load the gameplay module and create a Game instance.
  2. If a previous session left behind a serialized state buffer, attempt to restore it.
  3. Run the game until it exits normally or requests reload.
  4. On reload, ask the module how many bytes it needs to save state, allocate a buffer, then save.
  5. Unload the module, reload the new one from disk, recreate the Game, and restore the saved buffer.

Two details that matter in practice:

  • The runtime watches the library file for changes and flips an atomic reloadRequested flag. The game can poll that via a callback and decide when to request reload (e.g. between frames).
  • The dynamic loader path is hardened for the real-world race where the linker is still writing the shared library while the file watcher already noticed an update.

That combination gives you the “edit → compile → alt-tab → continue playing” loop, while still being honest about what can and can't be preserved.

Engine core: services + callbacks

The engine is intentionally “library-shaped”. Instead of a giant base class you inherit from, you wire it together through a small API:

  • Engine::pollEvents() and Engine::update(dt) advance input/window/audio state.
  • Engine::render() executes the render path.
  • Gameplay supplies callbacks like a draw callback, an overlay callback (for UI), and a lighting callback.

I like this shape because it forces a contract:

  • The engine owns platform concerns (window lifetime, swapchain recreation, audio device, file IO, logging).
  • The game owns decisions (what to draw, what lights exist, what UI is currently active).

On top of that sits a minimal scene framework (enter/exit/update) that the gameplay module uses for main menu vs gameplay, transitions, and so on.

Vulkan renderer: 2D, but treated like a real renderer

The renderer is built around a sprite-batching model, but it's not “just draw sprites”. The batching data includes enough information to support lighting and post-processing without turning everything into bespoke draw calls.

Under the hood, Vulkan setup is kept pragmatic: instance/device/swapchain selection is handled via vk-bootstrap, and GPU memory allocations go through VMA so resource creation stays predictable as the renderer grows.

The key idea is: gameplay pushes a stream of sprite vertices plus a list of draw batches. Each batch describes:

  • which textures are bound (albedo + optional normal map, plus some special-case textures),
  • the blend mode (alpha vs additive),
  • a material hint and a few per-batch toggles,
  • camera parameters used to transform into screen space,
  • which lighting state to use.

From there, Vulkan becomes a predictable pipeline:

  • a world/composite pass renders the scene into the swapchain,
  • a UI pass renders opaque UI elements in a separate render pass,
  • a UI overlay pass loads the composite result and draws transparent UI on top.

Lighting is represented as a compact GPU-friendly struct (ambient + point lights, capped to a fixed maximum), which keeps “lighting feels good” from exploding into a complex deferred system for a 2D game.

Post-processing is also first-class. The engine keeps a structured “post-process state” (vignette, LUT grading, fog, god rays, CRT, transitions), and the game can treat these like gameplay-controlled knobs rather than hard-coded effects.

Assets: dev-friendly filesystem, release-friendly packs

One of the easiest ways to kill motivation in a hobby engine is to make assets annoying.

In dev builds, the game reads from the assets/ folder so iteration is instant. In release builds, the pipeline prefers an assets.pak next to the executable so the bundle is relocatable and self-contained.

The runtime host does the boring work:

  • It tries to locate the asset root relative to the executable, not the shell's current working directory.
  • In pack mode, it searches a few candidate paths for the pack file.
  • It sets the process CWD so relative paths behave consistently across dev and bundles.

The AssetManager sits underneath that and exposes a simple “virtual path → bytes” API. It supports disabling filesystem fallbacks entirely in pack mode, which is a small but powerful way to detect missing assets early.

Tooling: builds, bundles, and docs

The build is Meson/Ninja, and the project is structured so clangd can pick up exact compilation flags from compile_commands.json.

For shipping builds, the tooling does three things:

  • Compiles with release flags (and optional LTO).
  • Packs assets so bundles are portable.
  • Assembles bundles for Linux and Windows (Windows is cross-built from Linux via MinGW-w64).

I also keep a Doxygen config in-repo so the engine/game boundary stays documented as it evolves.

Why this architecture works for me

The engine is “small” in the sense that it avoids sprawling abstractions, but it's not a toy. The project has enough structure to support real features (lighting, particles, UI, persistence, packaging) while still being easy to change.

The big wins so far:

  • Iteration speed: hot reload with explicit state save/restore.
  • Portability: asset packs + bundle assembly keep release runs reproducible.
  • Separation of concerns: the engine stays game-agnostic; the gameplay module stays free to be messy.

If you're watching the demo videos and wondering “why build all this instead of using X?”, the honest answer is: because the engine is the project. The fun part is making the architecture real.