Seraph

January 26, 2026 - Sebastian Himmer
UniversityProjectsEngine Programming
1185 words 6 min read

Description

Seraph is a C++ wrapper library for AngelScript designed to eliminate boilerplate and provide a clean, type-safe API for script integration. Built during Block B at BUas, the library transforms AngelScript's verbose interface into an intuitive template-based system. I found the AngelScript API to be too manual and wanted to figure out how templates could simplify the workflow.

In this breakdown, I will talk about the library itself, but also its implementation into my custom engine from the last block.

Architecture

The library maintains strict separation between AngelScript internals and user-facing APIs. All AngelScript state (asIScriptEngine, asIScriptContext) remains encapsulated within srph::Engine, never exposed to consuming applications. User interaction occurs exclusively through handles and templated interfaces.

The handle-based design prevents direct pointer manipulation while enabling full script lifecycle management. When creating type instances, Seraph returns srph::InstanceHandle objects that are then used for the entire interaction with the script instance.

Type Registration System

Value Types

Stack-allocated types with automatic lifetime management:

srph::TypeRegistration::Class<vec3,
      srph::ClassType::Value>(scripting, "vec3")
    .BehavioursByTraits()
    .Constructor<float, float, float>("float x, float y, float z")
    .Operator(SRPH_OPERATOR(glm::operator+, (const vec3&, const vec3&), vec3),
              srph::TypeRegistration::OperatorType::Add, "vec3", "vec3")
    .Property("float x", offsetof(vec3, x));

BehavioursByTraits() uses AngelScript's type traits to auto-generate constructor, destructor, assignment operator, and copy constructor.

Reference Types

Heap-allocated types with handle support:

srph::TypeRegistration::Class<TransformComponent, srph::ClassType::Reference>(
    scripting, "TransformComponent", asOBJ_NOCOUNT)
    .Method("glm::vec3 Forward()", &TransformComponent::Forward)
    .Property("glm::vec3 localPosition", offsetof(TransformComponent, localPosition));

The .Method() call accepts both member function pointers and lambdas, with automatic type marshalling for return values and parameters.

Operator Support

Custom operators (unfortunately) required macro-based approach to handle both member and free function definitions:

// Member operator
.Operator(SRPH_OPERATOR_MEMBER(vec3, operator+=, (const vec3&), vec3&),
          srph::TypeRegistration::OperatorType::AddAssign, "vec3", "vec3")

// Free function operator
.Operator(SRPH_OPERATOR(glm::operator-, (const vec3&, const vec3&), vec3),
          srph::TypeRegistration::OperatorType::Sub, "vec3", "vec3")

This remains the most verbose part of the API. I plan to revisit this in the future versions and try a different approach.

Enums

Integration with magic_enum eliminates manual enum value registration:

scripting->Namespace("Input");
srph::TypeRegistration::Enum<Input::KeyboardKey>(scripting).Register();

Global Functions

Namespace-scoped global functions for APIs that I selected to be accessed as a "singleton":

scripting->Namespace("Scene");

srph::TypeRegistration::Global(scripting)
    .Function("Entity CreateEntity(const string&in name)",
              [](const std::string& name) -> UUID {
                  auto& scene = Engine.ECS().Scene();
                  return scene.GetComponent<UUIDComponent>(
                      scene.CreateEntity(name)).uuid;
              });

This pattern abstracts complex C++ APIs behind simple interfaces while enabling safety checks.

Interfaces

Application-registered interfaces define contracts for script classes:

srph::TypeRegistration::Interface(scripting, "IAngelBehaviour")
    .Method("void Start()")
    .Method("void Update(float dt)");

AngelScript classes then implement these interfaces. If any of the method is not defined, the script won't compile.

class Enemy : IAngelBehaviour {
    void Start() { }
    void Update(float dt) { }
}

Function Calling

Builder pattern for script function invocation with automatic argument deduction:

srph::FunctionCaller(&Engine.Scripting())
    .Module("Game")
    .Function("void Update(float)", handle)
    .Push(deltaTime)
    .Call();

The .Push() method uses template specialization to handle primitives and registered types uniformly:

context->SetArgObject(m_argIdx++, 
    const_cast<void*>(static_cast<const void*>(&value)));

Reflection System

Runtime property reflection with metadata filtering:

struct ReflectedProperty {
    std::string type;
    std::string name;
    void* data;
};

auto fields = Engine.Scripting().Reflect(handle, "ShowInInspector");
for (auto& field : fields) {
    if (field.type == "bool") {
        bool* data = static_cast<bool*>(field.data);
        ImGui::Checkbox(field.name.c_str(), data);
    }
}

String-based type identification is suboptimal but necessary, as AngelScript's type system doesn't map cleanly to compile-time type info. The void* data pointer requires runtime casting based on the type string. I wanted to figure out a better way, but decided that it is not worth the time.

Attribute System

Script properties support metadata attributes for control over serialization or editor visibility:

class PlayerController : IAngelBehaviour {
    [Serialize]
    int serialized;
    
    [ShowInInspector]
    int editable;
    
    [ShowInInspector] [Serialize]
    int both;
}

The attribute system is generic, so changing from [ShowInInspector] to [Property] requires minimal engine changes and no library changes.

Serialization

Reflection-based serialization produces structured JSON:

"ScriptComponent": {
    "handles": [
        {
            "fields": [
                {
                    "name": "planet",
                    "value": 9760914450279739884
                },
                {
                    "name": "moveSpeed",
                    "value": 10.0
                }
            ],
            "name": "Enemy"
        }
    ]
}

Script state reconstruction uses only the typename and field values.

Hot Reloading

Script changes reload during editor runtime without restarting the application:

Process:

  1. Serialize all script state
  2. Restart scripting engine (recompiles scripts)
  3. Attempt compilation validation
  4. Deserialize state into new instances

Edge case handling:

  • Pre-compilation validation prevents broken hot-reloads
  • Scripts with compile errors at startup fall back to scene file serialization
  • Timeout mechanism (>1000ms) catches infinite loops

Cross-Script Communication

Scripts retrieve and invoke methods on scripts attached to other entities:

// Entity reference (drag-dropped in inspector)
Entity planet = [ ... ]

// Type-safe script retrieval with casting
Planet@ p = cast<Planet>(planet.GetScript("Planet"));

// Direct method invocation
p.Damage(1);

Implementation challenge: AngelScript doesn't support script classes inheriting from application classes.

Solution: use IAngelBehaviour interface that scripts implement, allowing C++ to return asIScriptObject* that casts to any implementing type.

Development Tooling

VSCode Integration

Integration with the AngelScript LSP extension provides syntax highlighting, autocompletion, and type checking. Seraph generates as.predefined files, which contain a full AngelScript VM dump and drive the autocomplete.

Custom Debugger

I built a full Debug Adapter Protocol (DAP) implementation from scratch to support script debugging.

Architecture:

  • VSCode debugger extension (TypeScript)
  • C++ debug adapter implementing DAP over TCP
  • AngelScript API integration for breakpoints, stepping, variable inspection

The adapter translates DAP requests into AngelScript VM operations and serializes responses back to VSCode.

Preprocessing

AngelScript's CScriptBuilder handles preprocessor directives and metadata extraction. I extend this by persisting metadata beyond build time:

std::vector<std::string> propertyMetadata = 
    builder.GetMetadataForTypeProperty(typeId, j);
if (!propertyMetadata.empty()) {
    m_engine->m_metadata[typeName][propNameStr] = propertyMetadata;
}

This enables runtime metadata queries for reflection and editor integration.

Script Loading

Simple builder-pattern API for script compilation:

srph::ScriptLoader loader(scripting);
loader.Module("Game");
FileIO::RecurseDirectory(
    m_fileIO->GetPath(FileIO::Directory::Assets, "scripts"),
    [&](const std::string& file) {
        if (FileIO::GetFileExtension(file) == ".as") {
            loader.LoadScript(file);
        }
    });

if (loader.Build()) {
    Notify(editor::NotificationType::Success, 
           "AngelScript built successfully.");
    return true;
}

Build failures automatically output detailed error messages with file, line, column, and error description.

Future Improvements

I plan to keep working on the library and ship some updates when I get to implement scripting support into my next custom engine. There are some of the things I have noted down:

  • Research a different approach for operator registration
  • Implement functional attributes: [Range(min, max)], [Group("name")], [Header("text")]
  • Extend attribute support for functions
  • Support registration of templated functions