Building a Custom 2D Game Engine with SDL3: Migration Guide and Real-World Lessons
Why Build a Custom 2D Engine on SDL?
Most solo devs reach for Unity, Godot, or GameMaker and that’s a reasonable call. But if your game needs fine-grained control over the rendering pipeline — custom shaders, specific batching behavior, deep platform API access — SDL is worth a serious look before committing to a full engine.
SDL isn’t a game engine. That distinction matters. It’s a cross-platform abstraction layer covering windowing, input, audio, and (in SDL3) GPU access, leaving all architecture decisions to you. You write more code. You also never fight the framework to do something it wasn’t designed for.
SDL3: What Actually Released
SDL 3.2.0, the first officially stable SDL 3 release, shipped in January 2025. ABI was locked before that point, which is the real signal that it’s safe to build on — existing features won’t change or be removed. The release brought a GPU API, Dialog API, Camera API, Properties system, main-callback support, and substantially reworked audio handling.
For SDL2 developers, the path forward is well-supported. The SDL team ships a Coccinelle-based migration script at build-scripts/SDL_migration.cocci in the SDL repository that handles the mechanical work automatically.
The Migration Script: What It Does and Doesn’t Cover
To run it:
spatch --sp-file build-scripts/SDL_migration.cocci .
That sweeps your source tree for API renames, parameter reorders, and header consolidation. It’s effective for the mechanical layer. What it won’t touch are subsystems where the behavior itself changed — not just the name.
Areas that still need manual work:
- Audio — the callback model is gone; SDL3 uses
SDL_AudioStreaminstead - Pixel format constants — some were renamed or restructured
- Texture creation flags — certain flag combinations changed meaning
- SDL_mixer — the SDL3 version carries a different API; any hybrid SDL2/SDL3 period needs careful isolation between layers
Audio: Why It Takes the Longest
Developers migrating real codebases consistently report audio as the most time-consuming part. The reason is structural, not superficial.
In SDL2, audio worked around a callback. You registered a function; SDL called it when the device needed data. Your job: fill a buffer on demand. In SDL3, you create an SDL_AudioStream, bind it to an output device, and push data into it. The device pulls from the stream asynchronously.
That direction reversal — push vs. pull — means any mixer or audio engine built around callbacks needs rearchitecting, not just porting. The SDL3 migration docs cover this in detail and are worth reading before touching any audio code.
Terminology changed too. SDL2’s “capture” and “output” devices became “recording” and “playback” in SDL3. Devices open by instance ID rather than string name, which actually simplifies dynamic device switching once you’re past the adjustment period.
The most practical approach: put a thin internal interface in front of your audio code before you start — Audio::play(), Audio::stop(), whatever contracts your game uses — so each side of the migration sees a stable surface. Keeps the ifdef count manageable and makes rollback much less painful if something breaks mid-migration.
New APIs Worth Knowing
GPU API
The headline addition for graphics-heavy work. SDL’s GPU API provides cross-platform modern GPU access in the style of Metal, Vulkan, and Direct3D 12, with SDL selecting the backend per platform. For pure 2D using SDL_Renderer you might not need it — the renderer itself got batching improvements in SDL3 — but heavy custom shader pipelines or GPU compute require it.
Main Callbacks
An alternative to the traditional main() game loop. Implement SDL_AppInit, SDL_AppIterate, SDL_AppEvent, and SDL_AppQuit; SDL drives the loop. Optional for desktop-only games. For anything targeting mobile or web it integrates far more cleanly with those platforms’ event models — worth considering from the start if any non-desktop port is on the roadmap.
Properties System
A fast key/value dictionary attachable to SDL objects. Several creation functions now accept a properties ID instead of a flags integer, keeping the API extensible without breaking existing callers. The migration guide documents which function signatures changed this way.
Engine Architecture for an Atmospheric Metroidvania
A 2D metroidvania with heavy atmospheric requirements puts particular pressure on the engine layer. Precise hitboxes mean implementing your own collision — SDL provides none. Custom fog, lighting, and distortion effects need shader access, which in SDL3 means the GPU API or SDL_Renderer’s shader extension. Dynamic visual effects tied to game state (a sanity meter altering the scene, say) mean your renderer needs to accept per-frame parameter changes without restructuring around them.
SDL doesn’t impose solutions to any of these. That’s the point.
Controller support is one area where SDL3 noticeably improved over SDL2. The gamepad API is more consistent across hardware, with fewer per-device edge cases. For a game built around precise, punishing combat, that matters more than it might initially seem.
The Honest Trade-Off
Unity and Godot ship with input remapping, audio mixing, asset importing, serialization, and scene management out of the box — systems you’d write from scratch on SDL. A five-year solo development timeline isn’t unusual for a metroidvania; a meaningful portion of that time on a custom engine goes to infrastructure that a commercial engine would have handed you.
That’s not a reason to avoid it. Some games genuinely need the control, and there’s no fighting the framework on decisions it made for a different use case. The trade-off is just worth naming clearly before you start.
