The Hard Way: C++23 Modules in a Real Godot + Flecs Project
Written by, Funnan on March 31, 2026
C++ modules have been standardized for years now. The toolchain support story, people keep saying, is getting better. What you don’t hear about as often is what adopting modules in a real non-trivial codebase actually looks like — especially when your stack includes a third-party ECS library, a shared-library GDExtension target, and you need it all green on GCC, Clang, and MSVC.
This is that story.
Why Bother
Briarthorn’s backend is a C++23 codebase built around Flecs and exposed to Godot 4 via GDExtension using godot-cpp. The codebase is split into several static library targets — briarthorn-core, briarthorn-components, briarthorn-systems, briarthorn-modules, and a briarthorn-godot shared library that links them all into something Godot can load.
Modules were appealing for the usual reasons: cleaner dependency boundaries, faster incremental builds, and the ability to stop playing header inclusion whack-a-mole across a growing number of subsystems. The #include <Flecs.hpp> in every file that needed ECS access was already getting tedious, and the goal of keeping the module graph explicit and auditable aligned well with how the project was growing.
What we were not prepared for was how many things in the ecosystem quietly assume the old model.
The Initial Architecture and Where It Broke
The first approach was a pattern you see recommended in a lot of “getting started with modules” content: umbrella facade modules that re-export everything from a subsystem. Something like:
// src/systems/Module.ixx (the old facade approach)
export module bt.systems;
export import bt.systems.CursorMotion;
export import bt.systems.KinematicsMotion;
export import bt.systems.NavigationMotion;
// ...
Consumers could then write a single import bt.systems; and get everything. Clean in theory.
In practice, with GCC 15.2 and a module graph of this size, this quickly produced:
error: bad import dependency
error: bad file data for compiled module '<path>/bt.systems.gcm'
These errors were both alarming and misleading. They weren’t pointing at logical bugs in the code — they were the compiler’s way of saying it had lost confidence in some part of the compiled module artifact chain. Touching one file invalidated something else. Rebuilding with stale .gcm files produced different errors than a clean configure. The facade re-export layer was amplifying every dependency, making the module graph both wide and deep — exactly the conditions where GCC’s module dependency tracking has historically been least reliable.
Clang and MSVC have their own module artifact formats and their own sensitivity points, but the structural problem was the same: the facade pattern turned a manageable graph into a web.
The Failures That Looked Unrelated
There were two categories of error that appeared during this process that looked like separate problems but were both downstream of modules.
Category 1: Duplicate overloads on defaulted special members.
A recurring pattern in the Godot model wrapper classes (Cursors, Entities, Players, Portals, and others) was something like:
export module bt.gd.models.Cursor;
export namespace bt::gd
{
class Cursors : public godot::RefCounted
{
GDCLASS(Cursors, godot::RefCounted)
public:
Cursors() = default;
~Cursors() override = default;
// ...
};
}
The compiler — and this happened on GCC specifically — would reject these with a diagnostic along the lines of the constructor or destructor being overloaded with itself. This makes no sense until you understand that certain macro expansions (like GDCLASS) interact poorly with = default in exported class bodies in ways that are implementation-defined and not well-documented. The fix was replacing every = default in these exported classes with explicit empty definitions:
Cursors()
{
}
~Cursors() override
{
}
Unglamorous, but effective. The same fix was applied to Briarthorn itself, which had a similar issue with its destructor.
Category 2: Dependency compatibility — the static const TU-locality problem.
The second category was subtler. When a header-only or header-heavy library declares things like:
// The old pattern in flecs/addons/cpp/c_types.hpp
static const flecs::entity_t PAIR = ECS_PAIR;
static const flecs::entity_t AUTO_OVERRIDE = ECS_AUTO_OVERRIDE;
static const flecs::entity_t TOGGLE = ECS_TOGGLE;
Each of those static const declarations has translation-unit local linkage. In traditional #include-based builds, this is fine — each TU gets its own copy and the linker sorts it out. Under C++23 modules, when such a header is pulled into a module’s global fragment, the behavior around TU-local entities becomes more constrained. You can end up with linkage mismatches, multiply-defined symbols, or subtle ODR violations depending on how the BMI is shared and consumed.
The same issue appeared in godot-cpp, where Wrapped had thread-local static members declared in the header without inline initialization, with out-of-line definitions in a .cpp. This pattern is incompatible with modules once consumers start importing rather than including.
Patching the Dependencies
Neither of these issues was something we could fix upstream at the time. The solution was vcpkg overlay ports.
The project uses vcpkg for dependency management, with overlay ports under cmake/vcpkg/ports/ that shadow the upstream registry entries. These overlay ports apply targeted patch files that make the dependencies module-friendly.
The Flecs patch (cmake/vcpkg/ports/flecs/fix-tu-local-linkage-for-cxx23-modules.patch) converts the namespace-scope static const declarations in flecs/addons/cpp/c_types.hpp to inline const:
-static const flecs::entity_t PAIR = ECS_PAIR;
-static const flecs::entity_t AUTO_OVERRIDE = ECS_AUTO_OVERRIDE;
-static const flecs::entity_t TOGGLE = ECS_TOGGLE;
+inline const flecs::entity_t PAIR = ECS_PAIR;
+inline const flecs::entity_t AUTO_OVERRIDE = ECS_AUTO_OVERRIDE;
+inline const flecs::entity_t TOGGLE = ECS_TOGGLE;
This applies to well over a hundred declarations across the file — every built-in tag, event, term flag, and policy constant. inline const at namespace scope has external linkage and a single definition across TUs, which is the correct behavior for module consumption.
The godot-cpp patch (cmake/vcpkg/ports/godot-cpp/fix-tu-local-linkage-for-cxx23-modules.patch) makes two targeted changes. The make_property_info helper function in type_info.hpp gets the same static → inline treatment. More significantly, the thread-local statics in Wrapped are converted to inline-initialized members:
-_GODOT_CPP_THREAD_LOCAL static const StringName *_constructing_extension_class_name;
-_GODOT_CPP_THREAD_LOCAL static const GDExtensionInstanceBindingCallbacks *_constructing_class_binding_callbacks;
+_GODOT_CPP_THREAD_LOCAL static inline const StringName *_constructing_extension_class_name = nullptr;
+_GODOT_CPP_THREAD_LOCAL static inline const GDExtensionInstanceBindingCallbacks *_constructing_class_binding_callbacks = nullptr;
And the corresponding out-of-line definitions in wrapped.cpp are removed, since they’re now defined inline. This is the pattern required for TLS statics under modules — the definition lives with the declaration, not in a separate .cpp.
A third patch, packagable.patch, makes godot-cpp’s CMake install and export behavior vcpkg-friendly: adding proper install(TARGETS ...) rules, CMake package config generation, and export target wiring so find_package(unofficial-godot-cpp) works correctly after vcpkg installs it — something the upstream project doesn’t produce out of the box.
These patches are versioned alongside the application code. They’re not hacks. They’re load-bearing parts of the migration.
The Simplification That Made Progress Real
After patching the dependencies, the biggest single improvement came from deleting the facade modules entirely.
Every Module.ixx umbrella file went away. Instead of:
import bt.systems; // gets everything, transitively
Each consumer now imports exactly what it needs:
import bt.systems.CursorMotion;
import bt.systems.KinematicsMotion;
The result was a module graph that was wider but shallower, and where every dependency was explicit and local to the file that needed it. The “bad import dependency” and “bad file data” errors stopped appearing after this change. GCC’s module dependency tracking is much more reliable when the graph has bounded fan-in at each node.
This also had a secondary benefit: it made incremental builds more predictable. Touching CursorMotion.ixx only invalidates files that import it directly. With the old facade, it invalidated everything that imported bt.systems.
The Final Trap: PIC and Shared Libraries
With the compiler errors resolved and the module graph stable, the last failure was at the linker stage on Linux (AArch64):
relocation R_AARCH64_ADR_PREL_PG_HI21 against symbol ... can not be used when making a shared object; recompile with -fPIC
The Godot GDExtension target is a shared library. The static libraries it links (briarthorn-core, briarthorn-components, etc.) were not being compiled with -fPIC, which is required for position-independent code in shared objects on ELF platforms.
The fix is a single CMake line at project root:
# CMakeLists.txt
set(CMAKE_POSITION_INDEPENDENT_CODE ON)
Set it early, set it globally. Every static library in the tree inherits it. There is no reason to be selective about this when your static libs are always destined to feed a shared target.
Cross-Compiler Considerations
The module architecture was designed to be compiler-agnostic from the start, with CMake presets managing the configuration for each toolchain. The codebase builds with MSVC on Windows, GCC on Linux (including AArch64), and Clang on macOS — all through the same source tree with compiler-specific flags handled in CMake rather than in code.
MSVC has the most mature module support and was generally the least surprising. Its handling of = default in exported classes and TU-local linkage issues is more permissive in some areas, which can mask bugs that GCC surfaces more aggressively.
GCC 15.2 was the hardest to satisfy but also the most educational. Many of the issues it surfaces — module graph instability, defaulted member diagnostics, TU-local linkage complaints — are technically correct enforcement of the standard; GCC is stricter than most consumers expect.
Clang’s module support, particularly with precompiled module interfaces (.pcm), behaved well once the dependency patches were in place. The main Clang-specific consideration was ensuring that the vcpkg triplet configuration was consistent with the module cache paths used during the build.
The upshot: write code that satisfies GCC, and it will tend to satisfy the others. Write code that only satisfies MSVC, and you may build a backlog of latent correctness issues.
What the Final Architecture Looks Like
The end state is a clean module graph with no facade layers:
bt.core.*— fundamental types, phase tags, entity wrappers; consumed directlybt.components.*— ECS component types; no dependencies on systems or modulesbt.systems.*— ECS system implementations; import components directlybt.modules.*— Flecs module registrations; import systems and components directlybt.commands.*— command objects; importbt.Briarthornandbt.gd.*bt.gd.*— Godot-facing model wrappers built on top of the module stack
The briarthorn-godot shared library links all of these static targets and exposes the GDExtension entry point. Everything compiles clean across all three toolchains.
What We Would Do Differently From Day One
-
Skip the facade layer entirely. The re-export umbrella pattern feels elegant but creates module graph coupling that the toolchain chokes on. Explicit imports at the point of actual use are more verbose but far more stable.
-
Patch dependencies before migrating, not after. We hit the TU-local linkage issues mid-migration when they could have been identified and patched upfront by auditing headers for
static constat namespace scope before writing the first.ixx. -
Set PIC globally before writing a single target. It costs nothing on most platforms and avoids the linker ambush when everything eventually feeds into a shared lib.
-
Clean the build directory after every module graph refactor. Stale
.gcm/.pcmartifacts produce errors that look nothing like the actual problem. If you’re seeing module-related failures after a significant refactor, clean and reconfigure before debugging. -
Prefer explicit special member definitions in exported classes.
= defaultin exported class bodies can interact with macro-expanded code in ways that produce confusing diagnostics. It’s not always wrong, but when things are breaking and you can’t explain why, explicit empty implementations are the pragmatic move.
Checklist for Teams Attempting This
- Keep import paths explicit. Avoid deep re-export chains at first.
- Validate each subsystem’s migration independently before integrating.
- Audit third-party dependencies for
static constat namespace scope before they enter the module graph. - Carry dependency fixes in reproducible overlay ports alongside your application code.
- Set
CMAKE_POSITION_INDEPENDENT_CODE ONbefore building any static lib that feeds a shared target. - Clean module artifact caches after any module graph-level refactor.
- Prefer explicit constructor/destructor definitions when compiler diagnostics look contradictory in exported classes.
- Build against GCC regularly even if it isn’t your primary platform — it will surface issues the others won’t.
Modules in 2026 are genuinely usable. The toolchain support is real. But “usable” doesn’t mean “frictionless” — at least not yet, and not when your stack includes third-party libraries that have never been tested under modules. The key takeaway is that module adoption in a real project is as much infrastructure work as it is code migration: patching dependencies, validating the build system, and understanding what the compiler’s more cryptic module errors actually mean.
The reward is a codebase where the dependency graph is visible, accurate, and enforced by the compiler — and where import bt.modules.CursorModule; means exactly what it says.