Building a Custom SDL3 Game Engine: Lessons From a 5-Year Solo Dev Project
When a custom engine makes sense
Most 2D game developers reach for Unity, Godot, or GameMaker. That’s a reasonable call for most projects. But Oneirium—a Victorian Gothic horror metroidvania built solo over roughly five years—runs on ZEngine: a custom C++ engine built from scratch on top of SDL, migrated from SDL2 to SDL3 during active development. The developer’s experience is worth examining closely, because the trade-offs of this approach are concrete and specific.
SDL sits between raw graphics APIs like OpenGL, DirectX, or Vulkan and full-featured engines. It handles windowing, input, audio, and rendering at a level of abstraction that gives you cross-platform support without imposing a scene graph, entity model, or scripting runtime on you. For a game with custom atmospheric shaders, a sanity system that alters visuals and enemy behavior in real time, and frame-precise hitboxes, that blank-slate architecture has real value.
SDL3 arrived, and migrating was mostly painless
SDL3 hit stable release in January 2025. The SDL team shipped three Python scripts alongside it—rename_symbols.py, rename_headers.py, and rename_macros.py—that automate the bulk of symbol renames and header updates from SDL2. By one developer’s account, the tooling caught roughly 80% of required changes before any manual work was needed. For a major-version migration, that’s an unusually high hit rate.
The changes that still needed manual attention were mostly intuitive. SDL3 introduces a properties system and explicit app-lifecycle callbacks, both of which clean up initialization patterns that SDL2 handled implicitly. Texture creation flags changed. Custom pixel format handling needed revisiting. The library also ships a Coccinelle script (SDL_migration.cocci) for more granular, pattern-based C source transformations if the Python scripts don’t cover everything.
And then there was audio.
SDL3_mixer is not just a renamed SDL2_mixer
SDL3’s audio subsystem is a genuine redesign, not a rename pass. In SDL2, the standard approach was an audio callback fed by the application. In SDL3, you bind SDL_AudioStream objects directly to audio devices. The callback path still exists, but it’s no longer the default mental model—and that shift is real, especially mid-project.
SDL3_mixer 3.0 went even further. The API was completely rewritten with no compatibility shim provided. Mix_OpenAudioDevice is gone. Mix_QuerySpec() became MIX_GetMixerFormat(). The simplified Mix_OpenAudio(0, NULL) call covers the common case cleanly once you know the new structure, but getting there from an existing SDL2_mixer codebase means navigating a period where old and new audio patterns coexist in the same project.
That hybrid period is where subtle bugs breed. Audio device state, stream bindings, and callback behavior can conflict in ways that don’t produce obvious error messages. Testing audio paths in isolation—one system at a time, confirmed before moving on—is the practical answer. It’s inherently slower than the rest of the migration, which is why developers consistently flag this as the longest single piece of the SDL2-to-SDL3 transition.
What SDL3 actually buys you for 2D development
The rendering improvements are real and automatic. SDL3’s 2D renderer has batching built in and wired up for Direct3D 11/12, Apple Metal, and Vulkan—no opt-in flag required. For a game with custom atmospheric fog shaders and dynamic lighting passes, the reduced CPU overhead from fewer draw calls adds up across a full scene. One caveat: apps that call directly into the underlying graphics API need to call SDL_FlushRenderer() first to drain any pending SDL work.
Controller support improved substantially. SDL3’s gamepad API maps inputs more consistently across hardware than SDL2 managed. There’s a learning curve with the API changes, but the result is better cross-device behavior without manual platform-specific handling.
SDL3’s explicit initialization and teardown structure also cuts a category of subtle bugs that affected SDL2 apps on some platforms—particularly Android and iOS, where lifecycle management was fiddly to get right.
What a custom engine lets you actually build
Oneirium features a custom in-game “sanity language”—a constructed script that appears as the protagonist’s mental state degrades. That kind of system doesn’t slot cleanly into a standard engine’s UI or text rendering pipeline. Same with the dynamic sanity mechanics that alter enemy behavior, world visuals, and available player abilities in real time based on accumulated psychological pressure.
Three character classes, a branching passive ability tree, custom fog and lighting shaders, an interconnected world with genuine shortcuts and backtracking—none of this is impossible in a commercial engine. But an engine’s architectural assumptions can actively fight a game’s specific needs. ZEngine doesn’t impose those constraints because it was built around the game, not the other way around.
The playtest on Steam launched as a mostly linear demo slice. The full game targets the interconnected, non-linear world structure that defines the metroidvania genre—ability-gated exploration, meaningful backtracking, and spaces that recontextualize as you gain new tools. As a proof of concept for SDL3 as a foundation, it’s substantive: a complex, atmospheric 2D game running on a hand-built engine from a single developer.
Should you build your own engine with SDL?
Probably not, unless you have a specific reason. SDL is a platform abstraction layer, not a game engine—and building entity systems, asset pipelines, physics, a toolchain, and all the surrounding infrastructure is years of real work. The Oneirium developer spent roughly five years on ZEngine alongside a full-time job.
But SDL3 is a genuinely good foundation when you do need that level of control. The migration tooling is better than most major-version library upgrades manage. The documentation covers edge cases. The performance ceiling over SDL2 is measurably higher for rendering-heavy projects. If the reason to go custom is real, the layer under you is solid.
