A small 2D engine built on SDL3. Code lives in Core/ (namespace bengine), every game target links it as a static lib. This README is about the engine and the choices behind it.
I built NES Bomberman on top of it as a test bed.
Core/Engine/Engine.cpp, RunOneFrame(). Fixed-timestep with a lag accumulator:
- measure real delta, add to
m_lag - drain
m_lagin awhile, each step is oneFixedUpdate()at fixed dt - one variable
Update(deltaTime), thenRender()
Deterministic gameplay goes in FixedUpdate, frame-rate dependent stuff in Update. Native builds sleep to cap the frame, the web build doesn't because the browser drives the loop through requestAnimationFrame. Same RunOneFrame for both.
A Scene owns its GameObjects by unique_ptr. Parent/child links are raw non-owning pointers, ownership already lives in the scene so theres no reason to reference-count the hierarchy too.
Behaviour is components. AddComponent<T>(...) constructs in place, GetComponent<T>() does a dynamic_cast. Both constrained with a ComponentType concept (std::derived_from<T, Component>) for readable errors at the call site.
The Transform is not a component, its just a plain member. I followed Unity here, since every object always has one. World transforms are cached and recomputed lazily, with a dirty flag that propagates down to the children.
- Game Loop / Update - the fixed timestep above.
- Command - input.
InputManagerbinds scancode / gamepad button + key-state to anICommand, so rebinds and the keyboard/gamepad split are just different bindings. - Service Locator - hands out the audio service and the event bus. Audio has a
NullAudioServicefallback so unregistered calls no-op instead of crashing. It is also used for the web build. - Singleton - long lived managers only (Renderer, SceneManager, ResourceManager, InputManager, Time). The course challenges singletons so I kept them to things that genuinely are global and single.
- Observer -
Subject/IObserver, synchronous, observers register themselves.
I have the classic Subject / IObserver in the engine too, but honestly, after working with C# events and Unreal's TMulticastDelegate, the OOP-style observer pattern never really felt right to me. So taking some inspiration from the side notes in the Game Programming Patterns book, I implemented a typed MulticastDelegate<Args...> (Core/Patterns/MulticastDelegate.h) instead, where you just subscribe a callback and don't have to inherit from anything.
Then I added RAII unsubscription on top:
Subscribereturns aScopedDelegatethat unsubscribes in its destructor, so a listener can't outlive its subscription.- re-entrancy safe: unsubscribing during a
Broadcastonly flags the listener dead, the erase is deferred until the broadcast finishes, so you can't invalidate the vector mid-iteration.
The delegate made my life a lot easier, but it scaled badly. To subscribe you need a pointer to the object that owns the delegate, so systems that had nothing to do with each other ended up holding references just to wire up a subscription.
So I added an EventBus (Core/Patterns/EventBus.h) on top of the delegate, not replacing it. It's a type-erased map of std::type_index → a MulticastDelegate<const Event&> slot. You Subscribe<SomeEvent>(...) / Broadcast(SomeEvent{...}), and the two sides only share the event type, never each other. The delegate still does the real work (RAII handle, re-entrancy safety), the bus is just a typed routing table over it, so the localized one-to-one relations still use a plain delegate.
The bus lives in the ServiceLocator instead of being its own singleton, a single known access point without creating another singleton.
Tools/LevelPack is a tiny offline tool that takes the JSON levels and packs each one into a binary .bomb file (a small header + the raw grid), so the game loads them straight from binary instead of parsing JSON at runtime. It also loads the JSON directly if it fails to load the .bomb.
C++23, MSVC, /W4 /WX (warnings as errors). Templates constrained with concepts. Data/ is copied next to the exe by a CMake post-build step with the SDL dll's.
cmake --preset x64-debug
cmake --build out/build/x64-debug
Toolchain via Chocolatey on Windows: choco install -y cmake emscripten ninja python. Then:
mkdir build_web
cd build_web
emcmake cmake ..
emmake ninja
python -m http.server
and browse to http://localhost:8000.
