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:
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.
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:
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:
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:
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) returnnull; // C# generics side wraps this, returning an instance of the corresponding type returndefault; // Fully implemented by DittoEngine.dll } }
[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.
// 3. Execute compilation int result = system(cmd.c_str()); if (result != 0) { std::cerr << "[CSharpScript] Compilation failed for: " << csPath << std::endl; returnfalse; }
// 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 voidEditor::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:
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
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…