Lights: A High-Level Tour of My Game Engine (and the Weird Stuff Inside)

I've been heads down on Lights, the C++ engine underneath my current game projects, for a while now. Long enough that it's grown some genuinely unusual corners. This is the first in a series about how it actually works. We'll start high level: what the engine looks like from the outside, how it's put together, and a few of the weirder decisions I've made along the way. Later posts will dig into specific systems in more depth.

The shape of it

Lights is a C++23 static library, built with CMake, windowing handled entirely through SDL3. It's split into two layers: core (audio, platform, rendering, text, a small generic algorithms library) and framework (the game loop, scene management, input, prebuilt layers built on top of core). It's not consumed as a submodule. It gets pulled into my games' superrepo via CMake's FetchContent and compiled as a static lib alongside everything else.

Lights layering diagram: consumer game with entt registry, Lights framework layer, Lights core layer, and the ozz_rendering RHI repo

Rendering lives in its own repo

The actual GPU backend isn't part of Lights at all. It's a sibling project, ozz_rendering, pulled in the same way, with a CMake flag (LOCAL_RENDERING_DIR) to swap in a local working copy when I'm iterating on both at once. It exposes a device centric RHI: an RHIDevice owns every GPU resource, and callers only ever hold opaque handles, never raw GPU objects. Backends currently implemented: Vulkan, OpenGL, and WebGPU, plus an Auto mode that resolves to whatever's appropriate for the platform.

The engine doesn't force an ECS on you

SceneObject is a flat struct. A transform, a mesh (vertex/index buffer handles), a material. That's it. Lights only knows how to draw one, through the graph based renderer described below. It has no opinion about how you organize game logic above that.

idkwtfim2D, one of the games built on Lights, glues in its own entt::registry at the game layer for that. GameLayer inherits SceneLayer and Renderable from Lights, but owns an entt::registry directly as a member, plus a couple of composite SceneObjects. Systems walk the registry each frame and feed their results into those scene objects, which are what Lights actually renders. The engine and the ECS stay fully decoupled. You bring whichever object model actually fits the game.

Most of this is handrolled

It's worth being upfront about what's actually mine here versus what's a library doing the work. The exceptions are a short list: SDL3 for windowing and input plumbing, glm for math, freetype2 for font rasterization, rtaudio for the actual audio I/O, spdlog for logging, toml11 for config parsing, Crypto++ for hashing primitives, Clay for UI layout, and (on desktop builds) Boost.Beast and Asio for the WebSocket transport behind the networking layer. There's also a Tracy profiler hook in debug builds, and native builds can use the Slang shader compiler (the web build compiles shaders to WGSL offline instead, since Slang has no wasm distribution).

Everything else is built from scratch: the graph system, the frame graph orchestration that runs on top of it, the audio DSP nodes, scene management, the input system, the schema/codegen tooling, collision primitives. That's the part worth digging into.

The weird and interesting stuff

One graph class runs both my modular synthesizer and my renderer

This is the one I most wanted to write about. Deep in core/algo there's a GraphNode, a completely generic directed graph primitive. Connect nodes, disconnect them, topologically sort from a root, flatten the graph. It knows nothing about audio or rendering. It's maybe 40 lines of domain free plumbing.

Two completely unrelated systems build on top of it.

Diagram showing GraphNode as a shared base class for both AudioGraphNode (a modular-synth style DSP graph) and Renderable (a hand rolled frame graph)

The audio system's AudioGraphNode inherits it directly, and there are real node types running on it: mixers, processors, and a saw_tooth_node oscillator. It's a modular synthesis style DSP graph, not just a sample player.

The renderer's Renderable also inherits it. Each renderable declares named inputs it needs and produces named render targets other nodes can pull from, and Renderer::ExecuteSceneGraph() runs the whole thing as a topologically sorted dependency graph. A hand rolled frame graph.

Most engines would never share infrastructure between a synthesizer and a renderer. Mine uses the exact same 40-line class for both, because a dependency graph is a dependency graph regardless of what's actually flowing through it.

The input system does chords and sequences with one struct

I'm fairly proud of this one. Input mappings bind a named action to a list of InputChords, and each chord can work two completely different ways depending on one flag.

With bIsSequence off, it's a simultaneous chord: every key in the list has to be down at once, Ctrl+Shift+S style. With it on, the same struct becomes a timed sequence: press the keys in order within a configurable window (TimeBetweenKeys) and it fires, Konami code style. Miss a step or run out of time and it resets, but there's a specific carve out in ReceiveEvent so that pressing the first key of the sequence again always restarts it cleanly, even mid-attempt, instead of leaving you stuck.

One data structure, one function, two genuinely different input patterns, and action mappings, axis mappings, and text input listeners all sit on top of the same subsystem.

Clay UI renders through the same pipeline as everything else

The UI toolkit is Clay, a small immediate mode layout library, wrapped in a ClayUILayer that is itself a SceneLayer and a Renderable. Nothing about it is special cased in the renderer. Each frame it builds a Clay layout, gets back a stream of render commands (rects, borders, images, text, scissor regions), and converts those commands into scene objects with generated or refreshed materials and meshes, which then render into that layer's target like any other renderable in the graph.

The nice part is that UI composes with gameplay layers through the exact same render target plumbing everything else uses. It's not a separate overlay system bolted on afterward.

I wrote my own protobuf, then I didn't

Networking needs a wire format, and at the time I didn't want to drag a compiled toolchain into my CMake build just to get one. So I built ozz_typegen: a small schema compiler that parses a custom .ozz schema language and generates C++ structs and enums, paired with a hand rolled binary format (ozz_binarypacking).

Its own README was upfront about what it was and wasn't:

"A crack at doing type generation based on schema similar to protocol buffers. I wanted a low-friction way of doing this in CMake without having to rely on compiled libraries or anything like that. This will obviously be a lot less powerful than protobufs, but for my purposes it should do quite well."

It worked, but the limits were real: C++ output only, no nested types, generated files couldn't reference each other. Eventually those limits caught up with me on simple-mmo's connection and character schemas, and I switched to FlatBuffers instead. Same low-friction CMake integration I was originally chasing, none of the corners I'd cut to get there. ozz_typegen is still sitting in the repo, but the networking layer has moved on.

I wrote my own password hashing, then I didn't

Same pattern, different subsystem. Lights' core/util has a crypto.h that wraps Crypto++ directly: GenerateSalt() and HashPassword(), SHA-512 under the hood. It sits right next to things like ring buffers and memory-size literals, which tells you this was never meant to be a generic "engine for any game." It's built with a networked, account-having game in mind.

But that's not what actually protects real users. simple-mmo's server doesn't call into crypto.h at all. It uses libsodium's crypto_pwhash_str, which hashes with Argon2id and bakes the salt directly into the output string, verified on the other end with crypto_pwhash_str_verify. There's even a precomputed dummy hash the server checks against when a user doesn't exist, so a login attempt takes the same amount of time either way and doesn't leak whether an email is registered.

The engine-level version was a reasonable first pass. The real implementation used the properly vetted library where it actually mattered.

What's next

This was the tour. Future posts will go deeper on specific pieces: the frame graph renderer and how ExecuteSceneGraph() actually schedules work, the FlatBuffers based networking layer, and the Clay UI integration in more detail. If there's a particular one you want first, let me know.