Written before

After the UI overhaul, the engine finally looks presentable. But having all logic hardcoded in C++ is still inflexible—every change requires recompilation, and the debugging cycle is too long. So the goal for this article is clear: get C# scripts running, where saving a file takes effect immediately—write and see, instant feedback.

The engine adopts the Mono Embedding approach: the C++ engine itself embeds the Mono runtime (managed through MonoRuntime.h/cpp), C# scripts are compiled to DLL and loaded by the engine, and lifecycle methods are driven by C++. This approach keeps the runtime fully under the engine’s control while scripts serve as dispatched business logic—aligned with Unity’s philosophy, but skipping the complexity of IL compilation and AOT.

Packaging is also covered, since an engine that only runs on your own machine doesn’t count as shippable—gotta be distributable to make some money.

Mono Runtime Integration

Written before

Embedding Mono in C++ comes in two flavors:

  1. Mono Embedding API (this article’s approach): C++ directly calls mono_* series APIs to manage domains, assemblies, and objects. Lower flexibility but sufficient, with less code.
  2. Mono Runtime Hosting API: Closer to Unity’s approach, gives control over AppDomain, IL compilation, thread model, etc. More complex but flexible.

Initializing the Mono Runtime

Initializing Mono requires providing the path to mono.dll and CLR data directories:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// MonoRuntime.cpp
bool MonoRuntime::Initialize(const std::string& monoLibPath)
{
// Load mono.dll
HMODULE monoModule = LoadLibraryA((monoLibPath + "/mono.dll").c_str());
if (!monoModule)
{
std::cerr << "[MonoRuntime] Failed to load mono.dll from: " << monoLibPath << std::endl;
return false;
}

// Fetch core function pointers
mono_set_dirs = (MonoSetDirs)GetProcAddress(monoModule, "mono_set_dirs");
mono_jit_init = (MonoJitInit)GetProcAddress(monoModule, "mono_jit_init");
mono_domain_set_config = (MonoDomainSetConfig)GetProcAddress(monoModule, "mono_domain_set_config");
mono_domain_unload = (MonoDomainUnload)GetProcAddress(monoModule, "mono_domain_unload");
// ... more function pointer fetching

// Set CLR search paths
mono_set_dirs(corlibPath.c_str(), configPath.c_str());

// Create JIT domain
monoDomain = mono_jit_init("DittoRuntime");

// Register internal call functions
RegisterInternalCalls();

s_initialized = true;
return true;
}

The core idea is treating mono.dll as a regular dynamic library, loading it and retrieving function pointers one by one. The benefit is not needing compile-time linking against mono.lib—just make sure mono.dll is in the same directory at runtime.

Registering Internal Calls

For C++ functions to be callable from C#, they need to be registered with Mono via mono_add_internal_call:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
// CSharpScript.cpp
void CSharpScriptSystem::RegisterInternalCalls()
{
// Transform
MonoRuntime::AddInternalCall("DittoEngine.Transform::GetPosition",
(void*)&Internal_Transform_GetPosition);
MonoRuntime::AddInternalCall("DittoEngine.Transform::SetPosition",
(void*)&Internal_Transform_SetPosition);

// GameObject
MonoRuntime::AddInternalCall("DittoEngine.GameObject::GetTransform",
(void*)&Internal_GameObject_GetTransform);
MonoRuntime::AddInternalCall("DittoEngine.GameObject::GetComponentByType",
(void*)&Internal_GameObject_GetComponentByType);

// Renderer
MonoRuntime::AddInternalCall("DittoEngine.Renderer::GetColor",
(void*)&Internal_Renderer_GetColor);
MonoRuntime::AddInternalCall("DittoEngine.Renderer::SetColor",
(void*)&Internal_Renderer_SetColor);

// Light
MonoRuntime::AddInternalCall("DittoEngine.Light::GetColor",
(void*)&Internal_Light_GetColor);
MonoRuntime::AddInternalCall("DittoEngine.Light::SetColor",
(void*)&Internal_Light_SetColor);
MonoRuntime::AddInternalCall("DittoEngine.Light::GetIntensity",
(void*)&Internal_Light_GetIntensity);
MonoRuntime::AddInternalCall("DittoEngine.Light::SetIntensity",
(void*)&Internal_Light_SetIntensity);

// Rigidbody
MonoRuntime::AddInternalCall("DittoEngine.Rigidbody::GetBodyType",
(void*)&Internal_Rigidbody_GetBodyType);
MonoRuntime::AddInternalCall("DittoEngine.Rigidbody::SetBodyType",
(void*)&Internal_Rigidbody_SetBodyType);
MonoRuntime::AddInternalCall("DittoEngine.Rigidbody::GetMass",
(void*)&Internal_Rigidbody_GetMass);
MonoRuntime::AddInternalCall("DittoEngine.Rigidbody::SetMass",
(void*)&Internal_Rigidbody_SetMass);
MonoRuntime::AddInternalCall("DittoEngine.Rigidbody::GetUseGravity",
(void*)&Internal_Rigidbody_GetUseGravity);
MonoRuntime::AddInternalCall("DittoEngine.Rigidbody::SetUseGravity",
(void*)&Internal_Rigidbody_SetUseGravity);
// ... more

// Debug
MonoRuntime::AddInternalCall("DittoEngine.Debug::Log",
(void*)&Internal_Debug_Log);

// Time
MonoRuntime::AddInternalCall("DittoEngine.Time::GetDeltaTime",
(void*)&Internal_Time_GetDeltaTime);
}

The implementation of AddInternalCall is essentially a wrapper around mono_add_internal_call:

1
2
3
4
void MonoRuntime::AddInternalCall(const std::string& name, void* method)
{
mono_add_internal_call(name.c_str(), method);
}

C++ Function Implementation

Taking Transform as an example, C++ functions receive pointers from C# and operate on engine-internal data:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// CSharpScript.cpp
void Internal_Transform_GetPosition(void* transform, float* outPos)
{
if (!transform || !outPos) return;
TransformComponent* t = static_cast<TransformComponent*>(transform);
outPos[0] = t->position.x;
outPos[1] = t->position.y;
outPos[2] = t->position.z;
}

void Internal_Transform_SetPosition(void* transform, float x, float y, float z)
{
if (!transform) return;
TransformComponent* t = static_cast<TransformComponent*>(transform);
t->position = glm::vec3(x, y, z);
}

void* Internal_GameObject_GetTransform(void* gameObject)
{
if (!gameObject) return nullptr;
GameObject* go = static_cast<GameObject*>(gameObject);
return go->GetComponent<TransformComponent>();
}

void Internal_Debug_Log(void* msg)
{
if (!msg) return;
MonoString* monoStr = static_cast<MonoString*>(msg);
std::string str = MonoRuntime::GetStringFromMono(monoStr);
std::cout << "[C#] " << str << std::endl;
}

The key insight: the C++ side doesn’t directly expose C++ classes to C#. Instead, it passes void* pointers, and the C# side receives them as IntPtr. This avoids complex cross-language type mapping.

Script Instance Lifecycle

The lifecycle of C# objects on the Mono side is managed by C++ through MonoRuntime::ScriptInstance:

1
2
3
4
5
6
7
8
9
10
11
12
// MonoRuntime.h
struct ScriptInstance
{
MonoObject* instance = nullptr; // C# object instance
MonoMethod* startMethod = nullptr;
MonoMethod* updateMethod = nullptr;
MonoMethod* onDestroyMethod = nullptr;
std::string className;
std::string assemblyPath;
bool started = false;
uint32_t gcHandle = 0; // GC handle prevents object from being collected
};

When loading a script, locate the class’s Start/Update/OnDestroy methods and cache them:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
std::shared_ptr<ScriptInstance> MonoRuntime::LoadScript(
const std::string& dllPath, const std::string& className)
{
auto instance = std::make_shared<ScriptInstance>();
instance->assemblyPath = dllPath;
instance->className = className;

// Load assembly
MonoAssembly* assembly = LoadAssembly(dllPath);
MonoImage* image = GetAssemblyImage(assembly);

// Locate class
MonoClass* klass = GetClass(image, "DittoEngine", className);
if (!klass) klass = GetClass(image, "", className);

// Create instance
instance->instance = CreateInstance(klass);

// Acquire GC handle to prevent GC collection
instance->gcHandle = mono_gchandle_new(instance->instance, false);

// Locate lifecycle methods
instance->startMethod = GetMethod(klass, "Start", 0);
instance->updateMethod = GetMethod(klass, "Update", 0);
instance->onDestroyMethod = GetMethod(klass, "OnDestroy", 0);

return instance;
}

In the C++ engine’s Play loop, each script-bearing object invokes at the appropriate timing:

1
2
3
4
5
6
7
8
9
10
11
void CSharpScriptSystem::CallStart()
{
for (auto& [id, script] : activeScripts)
MonoRuntime::CallStart(script);
}

void CSharpScriptSystem::CallUpdate()
{
for (auto& [id, script] : activeScripts)
MonoRuntime::CallUpdate(script);
}

C#-Side Base Library

The DittoEngine.dll on the C# side only handles internal call declarations and usage—no P/Invoke needed:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
namespace DittoEngine
{
public class Transform
{
private IntPtr _ptr;

[MethodImpl(MethodImplOptions.InternalCall)]
private static extern void GetPosition(IntPtr transform, float[] outPos);

[MethodImpl(MethodImplOptions.InternalCall)]
private static extern void SetPosition(IntPtr transform, float x, float y, float z);

public Vector3 position
{
get { float[] p = new float[3]; GetPosition(_ptr, p); return new Vector3(p[0], p[1], p[2]); }
set { SetPosition(_ptr, value.x, value.y, value.z); }
}
}

public abstract class MonoBehaviour
{
public GameObject gameObject;
public Transform transform => gameObject?.transform;

public virtual void Start() { }
public virtual void Update() { }
public virtual void OnDestroy() { }
}

public class GameObject
{
public string name;
private IntPtr _ptr;

[MethodImpl(MethodImplOptions.InternalCall)]
private static extern IntPtr GetTransform(IntPtr go);
[MethodImpl(MethodImplOptions.InternalCall)]
private static extern IntPtr GetComponentByType(IntPtr go, string typeName);

public Transform transform => new Transform { _ptr = GetTransform(_ptr) };

public T GetComponent<T>() where T : class
{
IntPtr comp = GetComponentByType(_ptr, typeof(T).Name);
if (comp == IntPtr.Zero) return null;
// C# generics side wraps this, returning an instance of the corresponding type
return default; // Fully implemented by DittoEngine.dll
}
}

public static class Debug
{
[MethodImpl(MethodImplOptions.InternalCall)]
private static extern void LogInternal(IntPtr msg);

public static void Log(string msg) => LogInternal(msg);
}

public static class Time
{
[MethodImpl(MethodImplOptions.InternalCall)]
private static extern float GetDeltaTimeInternal();

public static float deltaTime => GetDeltaTimeInternal();
}
}

[MethodImpl(MethodImplOptions.InternalCall)] paired with extern declaration means the method’s implementation is not on the C# side—it lives in the C++ function registered via mono_add_internal_call.

Written Behind

The Mono embedding approach skips AppDomain isolation, IL compilation, AOT, and other complex steps, focusing on the shortest path: “Load DLL → Locate class → Invoke method”. All C++↔C# communication goes through void*/IntPtr + internal call functions—clear logic, easy debugging. The tradeoff is that every new component requires writing a C++ function and a corresponding C# declaration—but for a personal engine, that’s way easier than hand-rolling IL weaving or CLR Hosting.

Script Compilation and Hot Reload

Written before

Having C# running is not enough—we need changes to take effect immediately after saving. The core idea: FileSystemWatcher monitors script files, recompiles via csc.exe on change, then reloads through Mono.

Dynamic Compilation

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// CSharpScript.cpp
bool CSharpScriptSystem::LoadScript(const std::string& csPath, CSharpScriptComponent* component)
{
// 1. Locate Roslyn/csc.exe path
std::string roslynPath = FindMSBuildPath();
std::string cscExe = roslynPath.empty() ? "csc" : roslynPath + "/csc.exe";

// 2. Build compilation command
std::string outputDll = GetTempDllPath(csPath);
std::string dittoDll = FindDittoEngineDll();

std::string cmd = "\"" + cscExe + "\" "
"/target:library "
"/nostdlib "
"/out:\"" + outputDll + "\" "
"/reference:\"" + dittoDll + "\" "
"/reference:\"C:/Windows/Microsoft.NET/Framework64/v4.0.30319/mscorlib.dll\" "
"\"" + csPath + "\"";

// 3. Execute compilation
int result = system(cmd.c_str());
if (result != 0)
{
std::cerr << "[CSharpScript] Compilation failed for: " << csPath << std::endl;
return false;
}

// 4. Load into Mono (this creates the C# object instance and caches method pointers)
component->scriptInstance = MonoRuntime::LoadScript(outputDll, component->scriptName);

// 5. Parse public fields (after C# object creation, so fields can be properly located)
component->ParseScriptFields();
return component->scriptInstance != nullptr;
}

File Monitoring

After successful compilation, the editor needs to monitor script file changes:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// Editor.cpp - Check if scripts were modified in Play mode
void Editor::CheckScriptHotReload()
{
for (auto* obj : scene->gameObjects)
{
auto* script = obj->GetComponent<CSharpScriptComponent>();
if (!script || script->scriptPath.empty()) continue;

// Detect script modifications and trigger reload through CSharpScriptComponent internals
// Actual implementation relies on the editor layer monitoring script file timestamps + recompile-and-reload flow
if (script->ShouldReload())
{
CSharpScriptSystem::LoadScript(script->scriptPath, script);
script->started = false; // Force re-Start
}
}
}

Written Behind

The core of hot reload is checking file timestamps every frame in Play mode—on change, rerun the compile→parse→load flow. Field mapping follows the implementation from Article II; after reload, field values are re-parsed without extra handling. The actual experience is already quite close to Unity—change a number, save, see the effect directly in the engine.

Windows Packaging

Written before

The engine runs and scripts hot-reload—last step is packaging for distribution. The more polished approach is an installer with an icon and uninstaller, rather than just a zip of files.

Collecting Dependencies

Use Dependencies or dumpbin to scan DittoEngine.exe’s dependencies:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# PowerShell script: collect all dependencies
$OutputDir = "Packaging/Build"
$EngineBin = "Build/bin"

$deps = @(
"opengl32.dll", "gdi32.dll", "user32.dll", "kernel32.dll" # System DLLs don't need copying
)

# Copy engine core
Copy-Item "$EngineBin/DittoEngine.exe" "$OutputDir/"

# Copy third-party libraries
Copy-Item "$EngineBin/glfw3.dll" "$OutputDir/"
Copy-Item "$EngineBin/glew32.dll" "$OutputDir/"

# Copy Mono runtime
Copy-Item "$EngineBin/mono.dll" "$OutputDir/"
Copy-Item "$EngineBin/Mono/" "$OutputDir/Mono/"

# Copy C# base library
Copy-Item "$EngineBin/DittoEngine.dll" "$OutputDir/"

Generating Directory Structure

The packaged directory looks roughly like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
DittoEngine/
├── DittoEngine.exe # Main program
├── glfw3.dll # Windowing library
├── glew32.dll # OpenGL extension library
├── mono.dll # Mono runtime
├── Mono/ # Mono-related libraries
│ └── ...
├── DittoEngine.dll # C# base library
├── Assets/ # Game assets
│ ├── Scenes/
│ ├── Models/
│ ├── Scripts/ # C# source (for hot reload)
│ └── Shaders/
├── Layouts/ # Editor layout files
└── Projects/ # Project directories

Asset Paths

The engine resolves scene files at runtime using relative paths:

1
2
3
4
5
6
7
8
9
std::string Engine::GetAssetsPath()
{
char exePath[MAX_PATH];
GetModuleFileName(NULL, exePath, MAX_PATH);
std::string basePath = exePath;
size_t pos = basePath.find_last_of("\\/");
basePath = basePath.substr(0, pos);
return basePath + "/Assets";
}

NSIS Installer

For a more polished result, use NSIS to write an installer script:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
!include "MUI2.nsh"

Name "Ditto Engine"
OutFile "DittoEngine-Setup.exe"
InstallDir "$PROGRAMFILES64\DittoEngine"
InstallDirRegKey HKLM "Software\DittoEngine" "InstallPath"

!define MUI_ICON "Assets\icon.ico"
!define MUI_UNICON "Assets\icon.ico"

Section "Install"
SetOutPath "$INSTDIR"
File /r "Packaging\Build\*.*"

WriteRegStr HKLM "Software\DittoEngine" "InstallPath" "$INSTDIR"
WriteUninstaller "$INSTDIR\Uninstall.exe"

; Create Start Menu shortcuts
CreateDirectory "$SMPROGRAMS\DittoEngine"
CreateShortcut "$SMPROGRAMS\DittoEngine\Ditto Engine.lnk" "$INSTDIR\DittoEngine.exe"
SectionEnd

Section "Uninstall"
Delete "$INSTDIR\*.*"
RMDir /r "$INSTDIR"
Delete "$SMPROGRAMS\DittoEngine\*.*"
RMDir "$SMPROGRAMS\DittoEngine"
DeleteRegKey HKLM "Software\DittoEngine"
SectionEnd

Compiling produces DittoEngine-Setup.exe—double-click to install, complete with icon and uninstaller.

Written Behind

Packaging turns out to be more involved than expected—dependency collection, path handling, Mono runtime deployment, NSIS script authoring, every step has its pitfalls. Fortunately, NSIS encapsulates the hardest parts; just follow the docs. If NSIS feels too steep, Inno Setup is a solid alternative with simpler syntax.

Summary

This article covers the C# scripting system and Windows packaging end-to-end:

  • Mono Embedding: C++ loads and manages the C# scripting runtime via the Mono Embedding API
  • Internal Calls: C++ functions registered via mono_add_internal_call, C# declares them with InternalCall
  • Lifecycle: MonoRuntime manages ScriptInstance, C++ drives Start/Update/OnDestroy
  • Hot Reload: Detects file timestamp changes in Play mode, auto recompiles and reloads
  • Packaging: Dependency collection + Mono runtime deployment + NSIS installer

By now the engine has basic scripting capability and a distribution path. Future additions could include async resource loading, multi-threaded rendering, or an audio system—but those are problems for another day. The thesis passed, but spring recruitment isn’t over yet; never a moment’s rest…