Scene nodes are plain C++ classes, but scenes are saved as data (JSON). The node type system bridges the two: a registry maps a stable type-name string to how a node is constructed and how its data is read and written. This lets the editor list every available node type (built-in and project-defined) and lets scenes round-trip through files. When a saved scene references a type the current build does not have, it loads anyway and preserves the data instead of failing.
Key features:
Node and its subclasses carry no serialization code. The (de)serialization hooks live in the registry, not on the node.PlaceholderNode that preserves the original type name and data, re-emits them verbatim on save, and is flagged in the editor.engine/scene/NodeTypeRegistry.h): typeName -> NodeTypeInfo. Holds the display name, a factory, and optional serialize/deserialize functions. Keeps a std::type_index -> typeName reverse map so the serializer can name a live node by its concrete type.engine/scene/nodes/PlaceholderNode.h): stand-in for an unknown type. A SpatialNode that stores the missing type name and the original props as raw JSON text.engine/scene/SceneSerializer.h): walks the node tree, writes common fields itself, and delegates type-specific data to the registry.GameEngine::initialize, before any scene loads.Each node is one object:
{
"type": "Light",
"name": "Sun",
"enabled": true,
"transform": { "position": [0,0,0], "euler": [50,-30,0], "scale": [1,1,1] },
"props": { "lightType": "Directional", "color": [1,1,1], "intensity": 2.0 },
"children": []
}
type, name, enabled, transform, and children are handled by the
serializer for every node. props is the type-specific block produced and
consumed by that type’s registry hooks (light data, camera parameters, a model
path, a custom node’s own members). A camera also gets a top-level "main": true
when it is the scene’s main camera, since that is a scene-level fact a node
cannot know about itself.
Asset paths inside props (e.g. a model path) are stored relative to the scene
folder when the asset lives inside it, absolute otherwise, so a scene folder
stays portable across machines.
A project registers its node type once at startup (after the engine is initialized, so the built-ins are present). The factory is explicit because not every node is default-constructible.
#include "engine/scene/NodeTypeRegistry.h"
engine::scene::NodeTypeRegistry::instance().registerType<EnemyNode>(
"EnemyNode", // stable key written to scene files - do not rename casually
"Enemy", // label shown in the editor's Add menu
[] { return std::make_shared<EnemyNode>(); }, // factory
[](const engine::scene::nodes::Node &n, nlohmann::json &props,
const engine::scene::NodeSerializeContext &) {
const auto &e = dynamic_cast<const EnemyNode &>(n);
props["hp"] = e.hp;
},
[](engine::scene::nodes::Node &n, const nlohmann::json &props,
const engine::scene::NodeSerializeContext &) {
auto &e = dynamic_cast<EnemyNode &>(n);
e.hp = props.value("hp", 100);
});
The serialize/deserialize arguments are optional - omit them for a node with
no data of its own. NodeSerializeContext::sceneFolder is available for types
that need to store paths relative to the scene (the built-in Model type uses
it). The cast from Node& must be dynamic_cast, not static_cast, because
Node is a virtual base.
After this call, the type appears in the editor’s Add menu, round-trips through scene files, and degrades to a preserving placeholder anywhere it is not registered.
A node is data plus behavior. The data (transform, properties, hierarchy) always travels in the JSON. The behavior is compiled C++ - it can only run in a build that compiled that type. You cannot put runnable code in a scene file.
So when a scene that uses EnemyNode is opened in a build that never registered
EnemyNode:
PlaceholderNode.props, the transform,
the name, and its place in the hierarchy. Its subtree is still reconstructed.EnemyNode restores it fully. No data
is lost.(Missing) EnemyNode and offers “Replace with Spatial
Node” or “Delete” (data is only dropped on that explicit action).This mirrors how Unity handles a missing script (the serialized data is kept and reconnects when the script returns). To make the behavior itself portable to a build that did not compile it, ship the type’s C++ source so the target build compiles it, or add a scripting layer - neither is part of this system.
include/engine/scene/NodeTypeRegistry.h, src/engine/scene/NodeTypeRegistry.cpp - registry + built-in registrations.include/engine/scene/nodes/PlaceholderNode.h - missing-type stand-in.include/engine/scene/SceneSerializer.h, src/engine/scene/SceneSerializer.cpp - save/load.src/engine/GameEngine.cpp - calls registerBuiltinNodeTypes() during init.