I spend my days in .NET, where the solution file is a quiet luxury: open the
.sln, and every project is just there — building, testing, referencing each
other, no ritual. Then I’d switch to C++ for game work in the evening and feel
like I’d traveled back twenty years. So I built the thing I was missing: a CMake
workspace that behaves like a solution file. Drop a lib_* folder in the root,
and it’s part of the build. Delete it, and it isn’t. That’s the whole pitch.
This page is the first in a series pulled from something I wrote for myself called the Game Development Bible — and, in keeping with how I do scripture, nothing here is preached that doesn’t compile. The receipt is public: github.com/Yuozas/game-dev-toolkit — clone it, run three commands, watch the tests pass on your machine, not mine.
The shape: libraries first, engines last
Every library in the workspace is built to outlive whatever engine consumes it. The rule has two halves:
- A static C++ core (
libinventory.a) — pure domain logic, no engine headers, no engine allocators, no engine anything. - A thin C shim (
libinventory_c.so) — opaque handles, structured errors, out-params. Exceptions never cross the ABI. This is the door every engine can open: Unity through P/Invoke, Unreal through its build system, Godot through GDExtension — same.so, same contract.
Why so paranoid about the boundary? Because I’ve watched the other version of this story: engine-coupled code where coming back after two months of break means relearning the whole system from zero. I want my libraries to read like APIs — uniform, standard, boring at the seams — so the interesting parts can afford to be interesting.
The .sln feeling, implemented
The root CMakeLists.txt doesn’t list libraries. It discovers them:
file(GLOB _gdt_lib_dirs RELATIVE "${CMAKE_SOURCE_DIR}" "lib_*")
foreach(_lib_dir IN LISTS _gdt_lib_dirs)
# lib_core always goes first; everything else joins if it has a CMakeLists
...
add_subdirectory(${_lib_dir})
endforeach()
Two conveniences ride on top:
GDT_EXCLUDE_LIBS— a cache variable holdinglib_*names to skip. A library that’s mid-surgery gets benched with one flag, not with commented-out lines that rot in the root file. The configure log saysSkipping excluded library: lib_fooso the bench is visible, never silent.gdt_add_library(<name> [WITH_C_API])— one call scaffoldslib_<name>/include,src,test, and optionally the C bindings dir, wired into the same discovery loop. New library, zero ceremony.
Tests follow the same discipline through ctest labels — unit, property,
performance, integration, stress — so ctest --preset test-all-debug
runs the world, and a label filter runs exactly the slice you’re iterating on.
The receipt, and what it cost
The covenant on this site is that “verified” and “should” are different words. So here’s the verification, exact:
- Cold configure + build on Linux (GCC 13, Ninja): clean.
ctest: 100% tests passed, 0 tests failed out of 8 — unit, property, performance suites and a pure-C test that exercises the shim through the C surface only.- Both C example consumers compile in the default target and run — including the one that deliberately triggers a domain error to show a structured error crossing the ABI intact instead of an exception detonating on it.
And because a receipt you didn’t audit is just a screenshot: when I audited this
workspace before publishing, it was not clean. Two landmines were live in
lib_core:
Result::Value()returned a shared mutable static on the error path — and the#ifdef DEBUGassert that was supposed to catch misuse was dead code in every build type, becauseDEBUGis not the opposite ofNDEBUG. Every configuration shipped the dangerous path, silently. It now fails fast in all builds, with a death test proving it, and aValueOr(fallback)for callers who genuinely want a default.MakeUniqueleaked by design — itrelease()d into a non-owning wrapper. Guaranteed leak at every would-be call site; there were zero call sites, which is the only reason it wasn’t a fire. It now returns an owning pointer, with an instance-counting test standing guard.
I’m telling you this instead of hiding it because the audit is the product. A workspace that claims discipline should be able to show you the day its own discipline caught it.
What this page does not claim
The original Bible document this came from also preached Conan packaging, a Python C-API generator, Docker build environments, and per-library watch loops. None of that exists in the repo, so none of it is claimed here. Roadmap and receipt are different shelves. This page only stocks the second one.
Where this goes next
The C shim earned its own story: fixing it surfaced six distinct defects, three of them latent ABI bugs that would have shipped quietly — dangling error strings, error codes reported as success, exceptions with a free pass across the boundary. That post is “One Library, Every Engine”, and it’s next on this shelf.
△ claims that compile. everything else is paint. ▽