Runtime Reflection in C++
Why reflection?
We wanted a way to easily change the game components parameters, almost like you would do in a professional game engine, allowing designers and artists to iterate on gameplay values without needing to touch source code or recompiling the engine.
To achieve this we built a custom runtime reflection system
that scans through all the source files at compile-time,
identifying classes and structs that contain reflection
attributes. These attributes—such as
[serializefield] or [hideineditor]
serve as markers that tell the system which members should
be exposed to the editor. The system then generates a .cpp
file which includes the metadata for the class its currently
reflecting.
And what this system also allows is the implementation of
custom attributes, such as [ReplicateField] for
networking.
Iteration
I developed two reflection systems. The first used a templatized class to store reflected values, which meant every instance of a class containing reflected variables also carried redundant metadata - type names, offset information, and validation flags - duplicated across hundreds or thousands of objects. That scrap data should have been stored in a central type registry, not per-instance.
The newer system fixes this by moving to compile-time reflection. All metadata is generated once per type during static initialization and shared across all instances. The per-object footprint is now just the raw data - no overhead, no duplication.
Difference
The old system:
class ObstacleSpawner : public Eclipse::Component
{
COMPONENT_BASE_2(ObstacleSpawner, 3)
public:
void Start() override;
void Update() override;
private:
SERIALIZED_FIELD(Eclipse::Assets::Prefab, ObstaclePrefab);
SERIALIZED_FIELD(float, ObstacleSpawnInterval, 2);
float obstacleSpawnTimer = 0.f;
};
SERIALIZED_FIELD macro expanded:
Eclipse::Reflection::SerializedVariable ObstaclePrefab{ "ObstaclePrefab", this, true, 0.1f };
Everything reflected is stored locally in the variable, which means every instance of this component will cause duplication of data.
The new system:
CLS
class Player : public Eclipse::Component
{
public:
void Move();
private:
[[serializefield]]
float moveSpeed = 2.4f;
};
END_CLS
Build time code generation (via lexer), static/shared reflection data per class.
How It Works
1. Scan
Lexer scans all source files for attributes
2. Generate
Creates .cpp files with TypeInfo<T> specializations
3. Register
TypeRegistrar registers each type during static initialization
4. Runtime Usage
Engine queries metadata for UI, serialization, networking
Key Takeaway
The shift from per-instance macro metadata to build-time
code generation proved to be dramatically more efficient. In
the old system, each reflected variable carried a
100-200 byte
SerializedVariable
object meaning a component with 5 reflected fields added
over 500 bytes of duplicated metadata per instance.
For 10,000 objects, that's 5+ MB of wasted
memory.
The new system eliminates this entirely. Reflection data is generated once per type and stored in static memory. Each instance now pays only for its actual data - no overhead, no duplication, just efficiency.