Build a Game Engine

Scripts and Components - Mono

Prerequisites

Article

Now that we've covered off the structure of our scripting module and some details on how we're going to implement parts of Mono, let's put it all together.

Objects

In our Scripting Module Structure article, we looked at the UScriptObject class, which represents an interface between a local C++ UObject and a "remote" object that exists in C#:

class UScriptObject : public UObject {
public:
    UScriptObject() : localObject(0) { }
    virtual ~UScriptObject() = default;

public:
    // Our local object
    UObject* localObject;
};

At the time, we also said that the remote part was missing, since it was implementation-dependent. Here is the Mono-specific version of that:

class UMonoObject : public UScriptObject {
public:
    UMonoObject() : remoteObject(0) { }
    ~UMonoObject() = default;

    /**
     * Get C# type instead of C++ type
     */
    UObject::Type* UType();

public:
    MonoObject* remoteObject;
};

You can see we now have a remoteObject, a MonoObject. We are also overriding the UType() function, which various parts of the engine will use to determine what type of class this is.

A MonoObject is an instance of a MonoClass. When we load a C# library, we're going to load all of the classes (MonoClasses) in it. The first library we load will be the one we write and it will contain classes that largely mirror C++ classes in our engine. Any subsequent libraries will be written by other developers building their game. While that may contain all sorts of classes, the ones we really care about are those that inherit from a base class we'll call UMonoScript- those classes will have overrides of our Initialize, Update, and Destroy functions that will control the game world and player experience.

Scripts

When developers are writing game logic, they will be creating "scripts". In JavaScript, that script might be a file, like "player.js". In C#, we'll compile all of the user's scripts into a library (DLL) and each "script" will actually be a class. Here is our Script base class covered in the Implementing Scripting article:

class UScript : public UObject {
public:
    UScript() = default;
    virtual ~UScript() = default;

public:
    // Class name
    std::string className;

    // Variables in class (and type)
    std::map<std::string, uint32_t> vars;
};

Nothing fancy here - just a class name, which indicates what type of class this is in C# (so we can serialize and deserialize it), and a map of variable names to UVariant variable types. We'll show these later in our game editor and give game designers and artists more control over the game play.

Now let's look at our Mono-specific implementation, called UMonoScript:

class UMonoScript : public UScript {
public:
    UMonoScript();
    ~UMonoScript() = default;

public:
    // Key functions
    UMonoScriptInitFunction initFunc;
    UMonoScriptUpdateFunction updateFunc;
    UMonoScriptFixedUpdateFunction fixedUpdateFunc;
    UMonoScriptDestroyFunction destroyFunc;
};

In addition to our base class, UMonoScript has 4 function pointers that come from Mono's managed to unmanaged thunks. You can find the implementation of getting those function pointers in the Embedding Mono article or we'll cover them again when we get to our ScriptSystem. In short, each one is a function pointer that looks like void FunctionName(MonoObject* object, ... some parameters ..., MonoException* ex) where "object" is the object you want to call the function on (in the C# world), "ex" is any exception that gets returned by the function, and any actual parameters to the function go in the middle.

Here are the 4 functions we want to implement:

void Initialize();
void Update(float delta);
void FixedUpdate(float delta);
void Destroy();

And here are the Mono function pointer equivalents of those functions:

typedef void (*UMonoScriptInitFunction)(MonoObject*, MonoException**);
typedef void (*UMonoScriptUpdateFunction)(MonoObject*, float, MonoException**);
typedef void (*UMonoScriptFixedUpdateFunction)(MonoObject*, float, MonoException**);
typedef void (*UMonoScriptDestroyFunction)(MonoObject*, MonoException**);

You can see above that these function pointers are properties of our Script class. They will be zero if not implemented by the C# class and non-zero if they are implemented and callable.

Finally, we'll need a C# base class that our game developers can extend. This isn't technically necessary from the C++/C# integration perspective... you could have game developers implement Initialize, Update, and Destroy functions in their classes that matched the signatures we are expecting, but obviously having a base class adds structure and will throw compiler errors if not implemented correctly. Here is our UMonoScript base class:

using System;
using System.Runtime.CompilerServices;

namespace UEngine
{
    public partial class UMonoScript : UComponent
    {
        // Declare our overridable functions
        public virtual void Initialize() { }
        public virtual void Update(float delta) { }
        public virtual void FixedUpdate(float delta) { }
        public virtual void Destroy() { }
    }
}

Great! We now have a Script class that can store the class name of the "script" we're running, variables and types, and function pointers to our 4 primary functions. We also have a C# representation of that developers can inherit from to create their own classes.

Next let's talk about how we're going to assign this script to objects.

ScriptComponents

While a script will be represented by a class in C#, that may be shared among many objects all running the same script. Following with our Entity-Component-System design pattern, let's create a ScriptComponent class to represent an instance of a class that is attached to an object:

class UScriptComponent : public UComponent {
public:
    UScriptComponent() : m_script(0) { };
    virtual ~UScriptComponent() = default;

    /**
    * Overridable functions
    */
    virtual void Initialize(UScript* script) { }
    virtual void Update(float delta) { }
    virtual void FixedUpdate(float delta) { }
    virtual void Destroy() { }

    /**
     * Serialize / deserialize
     */
    void SerializeComponent(UDataRecord* record);
    void DeserializeComponent(UDataRecord* record);

    /**
     * Get C# type instead of C++ type
     */
    UObject::Type* UType();

public:
    // Variable settings
    std::map<std::string, UVariant> vars;

protected:
    // Script
    UScript* m_script;
};

Our ScriptComponent base class does a few things: first, since we'll be calling Initialize, Update, Destroy, and other functions on objects, the ScriptComponent is where we will implement them. When we create our ScriptSystem class, it will find all active ScriptComponents attached to Entity objects in our World and run the Update and FixedUpdate functions on them.

To do that, we also have a reference to the Script that is attached to our component. You'll also notice that the Initialize function here takes a Script parameter. Our implementation will assign script to m_script and then be able to leverage the function pointers discussed above. We'll see more on this in a moment.

While a Script had a map of variable names and types (int, bool, object, etc.), a ScriptComponent maintains the values of those variables. As variables are exposed and serialized in scripts, our game artists and designers will be able to set the value of those per-object. We will also serialize and deserialize those values to show in the editor and to save to disk.

Our ScriptComponent class takes care of serialization and deserialization. The first thing we'll need to serialize is the class name of the Script, which is straightforward. However, we also want to serialize the variables and their values. We could do this in two ways:

  1. We could have a "variables" column / property of the record and serialize the data into it (for example in JSON). If we use this approach, we can have a single "ScriptComponent" type and only the serialized variable data will change, which is cleaner. However, it kind of treats our C# classes as "second class" and limits the ability to change the variable values easily on disk.
  2. We could treat each class as a separate type and the variables as "first class" properties of the class - that is, if a class called PlayerInput has 2 variables (tagged SerializeField) named moveSpeed and turnSpeed, we can have treat those as if they were properties of our PlayerInput class (which they are). This approach treats our C# classes the same as our C++ classes, but if we had a lot of one-off scripts, it would create a lot of different "types".

We'll use approach #2, so our ScriptComponent class also overrides the UType() function to return the name of the C# class instead of the name of our C++ ScriptComponent class for all components.

Let's take a look at our Serialize and Deserialize functions:

void UScriptComponent::SerializeComponent(UDataRecord* record) {
    // Save script type
    record->Set("type", this->m_script->className);

    // Treat variables as "first class" properties
    for (auto it = vars.begin(); it != vars.end(); it++) {
        record->Set(it->first, it->second);
    }
}

void UScriptComponent::DeserializeComponent(UDataRecord* record) {
    // Load script
    std::string type = record->Get("type").AsString();

    // Deserialize variables
    for (auto it = m_script->vars.begin(); it != m_script->vars.end(); it++) {
        this->vars[it->first] = record->Get(it->first);
    }

    this->Initialize(GetUSystem<UScriptSystem>()->GetScript(type));
}

As we laid out, first we serialize or deserialize the class name. If we're deserializing, we call the ScriptSystem's GetScript function (which we'll see in the next article) that returns a Script. Then, we set or get our variables. Finally, we call Initialize. We do the calls in this order in case the Initialize function happens to load or use the variable values (hint: we're about to do this in our Mono implementation).

Finally, we have the GetType override, which returns a Type based on the C# class name instead of using the C++ class name and type.

UObject::Type* UScriptComponent::UType() {
    return(GetUSystem<UMetaSystem>()->GetTypeByName(m_script->className));
}

Now let's take a look at our Mono-specific implementation, the UMonoScriptComponent class:

class UMonoScriptComponent : public UScriptComponent {
public:
    UMonoScriptComponent();
    ~UMonoScriptComponent() = default;

    /**
    * Overridable functions
    */
    void Initialize(UScript* script);
    void Update(float delta);
    void FixedUpdate(float delta);
    void Destroy();

protected:
    // Reference to script class
    UMonoObject* m_object;
};

This Mono-specific class overrides our 4 basic functions and contains a reference to our UMonoObject. When we Initialize our MonoScriptComponent, we'll set our script, create a remote object (based on the script type) and we'll set the local object to ourselves (the "this" pointer). Here it is:

void UMonoScriptComponent::Initialize(UScript* script) {
    // Save script
    this->m_script = script;

    // Get or create a remote object
    UMonoScriptSystem* scriptSystem = GetUSystem<UMonoScriptSystem>();
    m_object = (UMonoObject*)scriptSystem->GetRemoteObject(script->className, this);

    // Set variables in C# for variables (from variant values)
    auto it = vars.begin();
    for (; it != vars.end(); it++) {
        MonoClass* monoClass = mono_object_get_class(m_object->remoteObject);
        MonoClassField* field = mono_class_get_field_from_name(monoClass, it->first.c_str());
        mono_field_set_value(m_object->remoteObject, field, scriptSystem->VariantToMonoObject(it->second));
    }

    // Call object Initialize function
    UMonoScript* monoScript = (UMonoScript*)this->m_script;
    if (monoScript->initFunc) {
        MonoException* ex = NULL;
        monoScript->initFunc(m_object->remoteObject, &ex);
        if (ex) {
            UASSERT(true, "Mono object Initialize function error.");
        }
    }
}

The first thing we do is save our script into our m_script property. Then we call our ScriptSystem to get a remote object. We'll look at the implementation of this in our next article, but in essence this function takes a class type and a local object to tie it to, searches for an existing remote object, and if it doesn't find one, creates one.

Next, we set our variables on the remote object. First, we retrieve the class type, then get the MonoClassField, and finally set the value. This calls another ScriptSystem function called VariantToMonoObject. As you might guess by the name, this function converts a UVariant to a MonoObject that will set a C# value.

Finally, with the object created and the variables set, we call the C# Initialize function (if implemented) on the MonoScript object by using the function pointer.

The Update, FixedUpdate, and Destroy functions are all similar to each other and the last part of the Initialize function, calling a function pointer (if implemented) on the Script:

void UMonoScriptComponent::Update(float delta) {
    UMonoScript* monoScript = (UMonoScript*)this->m_script;

    if (monoScript->updateFunc) {
        MonoException* ex = NULL;
        monoScript->updateFunc(m_object->remoteObject, delta, &ex);
        if (ex != NULL) {
            UASSERT(true, "Mono object Update function error.");
        }
    }
}

void UMonoScriptComponent::FixedUpdate(float delta) {
    UMonoScript* monoScript = (UMonoScript*)this->m_script;
    if (monoScript->fixedUpdateFunc) {
        MonoException* ex = NULL;
        monoScript->fixedUpdateFunc(m_object->remoteObject, delta, &ex);
        if (ex) {
            UASSERT(true, "Mono object FixedUpdate function error.");
        }
    }
}

void UMonoScriptComponent::Destroy() {
    UMonoScript* monoScript = (UMonoScript*)this->m_script;
    if (monoScript->destroyFunc) {
        MonoException* ex = NULL;
        monoScript->destroyFunc(m_object->remoteObject, &ex);
        if (ex) {
            UASSERT(true, "Mono object Destroy function error.");
        }
    }
}

The only difference here is that the Update and FixedUpdate functions take a single parameter, which you can see in the middle of the function calls, that represents the delta time between frames.

Conclusion

We have now discussed almost all of the pieces that bring our scripting system together: we have a basic architecture of objects (that exist in both C++ and C#), scripts, and components, a solid grasp of our integration points with Mono, and now a Mono-specific implementations of our architecture.

The last article will cover our ScriptSystem, which does a lot of the heavy lifting around conversions of Mono types and tracking our UMonoObject interfaces.