Projects
My Game Engine
C++23 engine with SDL3, Vulkan 1.3, and a modular gameplay library.
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.

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.

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:
- Load the gameplay module and create a
Gameinstance. - If a previous session left behind a serialized state buffer, attempt to restore it.
- Run the game until it exits normally or requests reload.
- On reload, ask the module how many bytes it needs to save state, allocate a buffer, then save.
- 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
reloadRequestedflag. 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()andEngine::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.