An engine class usually gets described four times. Once for the serializer. Once for the editor's property panel. Once for the script bindings. And now a fourth time, in prose, for the AI agent that is supposed to author scenes with it. In SETech a class describes itself once, in one macro block, and all four read the same description.
This article covers how that reflection system is built, how AngelScript is wired to it with no per-class glue, and why the combination turned out to be exactly what an AI agent needs. It also lists what the system does not do, because the limits are part of the design.
One macro block per class
A reflected class carries one macro in its header, SETECH_REFLECTCLASS( Type ), and one block in its .cpp. Here is the engine's light entity, trimmed to its first few members:
SETECH_REFLECTEDENUM_BEGIN( eSELightType )
SETECH_REFLECTEDENUM_ADDVALUEALIAS( SETECH_LIGHT_TYPE_DIRECTIONAL, "Directional" )
SETECH_REFLECTEDENUM_ADDVALUEALIAS( SETECH_LIGHT_TYPE_POINT, "Point" )
SETECH_REFLECTEDENUM_ADDVALUEALIAS( SETECH_LIGHT_TYPE_SPOT, "Spot" )
SETECH_REFLECTEDENUM_END()
SETECH_REFLECTCLASS_BEGIN_DERIVED( SELightEntity, SESceneEntity )
SETECH_REFLECTMEMBER( m_LightType, "LightType", "Type of light ( directional, point or spot )", "" )
SETECH_REFLECTMEMBER_ATTR( m_Color, "Color", "Light color", SETECH_MEMBER_ATTR_COLOR_RGB, "" )
SETECH_REFLECTMEMBER( m_Intensity, "Intensity", "Light intensity multiplier", "" )
SETECH_REFLECTMEMBER( m_Radius, "Radius", "Falloff radius for point and spot lights", "" )
SETECH_REFLECTMEMBER( m_bCastsShadow, "CastsShadow", "Spot light casts shadow when true", "" )
// ...
SETECH_REFLECTCLASS_END()Each member gets a name, a display name, a description and a category. Attributes add meaning the C++ type cannot carry: this SEVec3 is an RGB color, this string is a texture path, this float has a range, this field is read-only or hidden.
No code generation
There is no parser and no generated file. The system is macros, templates and static initialization. BEGIN expands to a few file-scope objects: a factory, a callback that defines the members, and an SEClassType whose constructor registers the type. Members are described by offset arithmetic on a dummy pointer, and template overloads classify the C++ type, so a T&, a T*, a std::vector<T> and a std::vector<T*> each come out as the right kind of variable without being told.
Registration happens during static initialization, the first registered type brings up the type database, and one explicit pass at engine startup resolves type IDs into type pointers. Objects are created by name through the factory. Cooked binary files do not store the registration-order ID: they store an FNV-1a hash of the type name, which is stable across builds, and a hash collision is reported at registration.
One walk, many consumers
Everything that consumes reflection walks the same list: SEType::GatherMembers recurses into the parent type first, then appends the type's own members. On top of that one walk sit:
- XML, JSON and binary serialization. Three formats, one member loop each. The JSON loader skips any key that is absent, which looks like a small detail and becomes important later.
- The editor's property panel. It groups members by category into collapsing headers, shows the description as a tooltip, draws a slider when a range is set, a combo for a reflected enum, a color picker for an RGB attribute, and adds or removes vector elements through the type factory.
- Play and Stop. Pressing Play snapshots the whole world to a JSON string through the same serializer. Stop restores it.
- Scene files. A scene is the world plus a list of entities, each one a type name and its members:
{
"type": "SELightEntity",
"name": "Sun",
"members": {
"m_LightType": "Directional",
"m_Color": "{ 1.000000 , 0.930000 , 0.800000 }",
"m_Intensity": "0.800000",
"m_AnglesDegrees": "{ -130.000000 , -50.000000 }",
"m_bCastsShadow": "True"
}
}Values are strings, parsed by the same text serializer the XML path uses. Pointer members are saved as a reference by name and resolved after everything has loaded. Nested reflected types repeat the same type and members shape.
Methods are reflected too
The same block can list methods. This is the whole input object that scripts use:
SETECH_REFLECTCLASS_BEGIN( SEScriptInput )
SETECH_REFLECTMETHOD( IsLeftDown, "true while the left arrow key, d-pad left or left stick left is held" )
// ...
SETECH_REFLECTMETHOD( IsKeyDown, "true while the given ASCII letter/digit key is held ( 'r' also accepts the gamepad Y button )" )
SETECH_REFLECTCLASS_END()For each method the system stores the member-function pointer, fills in parameter and return descriptors, and builds an invoke thunk for that signature. The result is one uniform call shape for every reflected method in the engine: Invoke( object, args, returnValue ). That uniform shape is what makes the next part small.
AngelScript, with no per-class glue
Script bindings are usually where the second description of every class lives: a registration call per type, per property and per method, written by hand and kept in step by hand. We do not write those. One function, SEScriptReflectionBinding::BindAll, runs once when the script engine starts and makes four passes over the reflection database:
- Register every reflected class as a script reference type.
- Register its members as properties, its methods as methods, and its static methods in a namespace named after the type.
- Register implicit upcasts and checked downcasts along each parent chain.
- Register global reflected functions.
A property binding is the reflected byte offset handed straight to AngelScript, so a script reading a field is a direct memory access with no call in between:
std::string strDecl = TypeToken( pMember, ®istered, true ) + " " + strProperty;
_pEngine->RegisterObjectProperty( strClass.c_str(), strDecl.c_str(), ( int )pMember->m_uiByteOffset );And every instance method in the engine goes through the same trampoline, with the reflected method riding along as auxiliary data:
void GenericMethodTrampoline( asIScriptGeneric* _pGen )
{
SEMemberMethod* pMethod = ( SEMemberMethod* )_pGen->GetAuxiliary();
SEMarshalSlot aSlots[ MAX_SCRIPT_ARGS ];
void* apArgPtrs[ MAX_SCRIPT_ARGS ];
MarshalArgsIn( _pGen, pMethod->m_Params, aSlots, apArgPtrs );
SEMarshalReturn marshalReturn;
void* pReturn = marshalReturn.Prepare( &pMethod->m_ReturnParam );
pMethod->Invoke( _pGen->GetObject(), apArgPtrs, pReturn );
marshalReturn.WriteOut( _pGen );
}AngelScript is built in its maximum portability mode, so every call uses the generic calling convention and none of this depends on a platform ABI.
The proportions are the argument. The engine has well over a hundred reflected classes, more than a thousand reflected members and well over a hundred reflected methods. The whole repository contains a couple of dozen AngelScript registration calls. About half of them bind one hand-written value type, the 3D vector. The rest are the generic loops above. There are no per-class registration calls, and the binder is a single file of a few hundred lines.
What a script looks like
A behavior is a plain script class. The base scene entity carries a few reflected members for it: a script file, a class name, inline script source, and an enabled flag. Because those are ordinary reflected members, attaching a script to an entity is just more scene JSON.
class ArenaPlayer
{
SECharacterSceneEntity@ self;
ArenaPlayer( SECharacterSceneEntity@ _owner ) { @self = _owner; }
void OnUpdate( float dt )
{
float x = 0.0f;
float z = 0.0f;
if ( gInput.IsUpDown() || gInput.IsKeyDown( 119 ) ) { z += 1.0f; } // w
// ...
float speed = gInput.IsKeyDown( 114 ) ? 45.0f : 24.0f;
self.SetMoveInput( x * speed, z * speed );
if ( gInput.IsSpaceDown() )
{
self.TriggerJump();
}
}
}OnInit, OnUpdate and OnDestroy are optional and found by declaration. A script that throws is logged, and a behavior that keeps throwing is disabled instead of taking the frame down with it. Reloading discards the module and rebuilds it from disk on the next tick, which is what our driving demo binds to F5. Instance state does not survive a reload, because the behavior object is recreated.
The binding has a smoke test that says what matters in a few lines: a script assigns a new value to gHair.m_uiStrandCount and the test asserts that the C++ field changed. Nobody wrote a binding for the hair renderer. It was reflected for the editor, and the script side came with it.
Why this is what an AI agent needs
An agent that authors scenes needs three things: a description of what exists, a way to create and change it, and a way to give it behavior. We did not build those three for agents. They were already there.
The schema writes itself
The schema exporter walks the reflected types, keeps the ones that can be created and derive from the scene entity, and writes out each one with its members, value formats, enum options, ranges, descriptions and its script API. It comes out as a Markdown file the agent reads:
### SELightEntity
{ "type": "SELightEntity", "members": {
...
"m_LightType": one of: "Directional" | "Point" | "Spot" // Type of light ( directional, point or spot )
"m_Color": "{ <r> , <g> , <b> }" // Light color [rgb color 0..1]
"m_Intensity": "<float>" // Light intensity multiplierLook at where the comments come from. They are the description strings from the macro block at the top of this article, the ones written as editor tooltips. A sentence written once for a human hovering over a property is now the documentation an agent reads before it writes JSON. Keeping tooltips honest stopped being a nicety.
Commands are just scene JSON
The bridge is deliberately plain. With remote control enabled, the editor watches for a file called ai_command.json. The agent writes it, the editor applies it, deletes it, and writes a one-line result to ai_result.json. There is no server and no port. One JSON object carries a small set of keys, applied in a fixed order: clear, remove, edit, world, entities and mode.
{ "edit": [ { "name": "tri_red", "members": { "m_Intensity": "12.0" }, "position": "{ 0 , 25 , 23.09 }" } ] }None of those keys needed new machinery. Creating an entity is the factory, by type name, followed by the JSON loader. Editing one is the JSON loader again on a live object, and this is where skipping absent keys pays off: a partial object is a valid edit. After an edit the entity gets the same change notification the editor's own panels trigger.
pEntity->GetClassType()->LoadJSON( pEntity, pEntry );
ApplyFriendlyTransform( pEntity, pEntry );
pEntity->OnRTU();Behavior travels in the same file
Because inline script source is a reflected string member of every entity, a command can create an entity, give it an AngelScript class as text, and set "mode": "play". One file takes an empty world to a playable prototype, and the script the agent wrote is bound to the engine through the same reflection data the agent read in the schema. The description it was given and the API it gets cannot disagree, because they are the same data.
What it does not do
The limits are worth stating plainly.
- Members are listed by hand. With no code generation, a member missing from the macro block is silently unreflected.
- Static libraries need a nudge. The linker drops translation units nothing references, so applications call
Type::ForceLink()for types that live in static libraries, and the startup pass warns when a referenced type was not linked. - Containers are vectors and fixed arrays. No maps, no sets, no reflected templates.
- Scripts see a subset. Script-visible properties are bool, integer, float, double, string and the 3D vector. Enums, other vector and matrix types, arrays and pointer members are not exposed as properties. Overloaded methods and unsupported signatures are skipped, and reflected constructors are not bound, so a script cannot create engine objects.
- Script handles are raw. Engine objects are registered without reference counting, so a script holding a handle to a deleted entity is holding a dangling pointer.
- The bridge is one way. It is a polled file with no handshake. The result is a count of what was removed, edited and applied. An unknown type or a bad member is skipped without a per-entity error, and there is no command to read the current scene back.
- Not everything is derived. The schema exporter appends one hand-written usage note for the synthesizer entity, the scene loader constructs a few entity types explicitly because they need a world pointer, and the editor picks its widget per core type with a hand-written chain.
The point
We did not set out to make an engine for AI agents. We set out to stop describing classes more than once. The serializer, the editor, the script bindings and the agent schema are four readers of one description, and the newest reader cost the least, because by the time it arrived there was nothing left to describe.
You can try the editor side of this in your browser. SETech Studio Lite is the engine's editor compiled to WebAssembly: the property panels, the JSON scenes and the sample games are all running on the system described here.