The first two posts got the editor interface and basic rendering in place, but the engine is still an empty shell — you can only hardcode logic in C++. To make a genuinely usable game engine, you have to support scripting so developers can iterate on game logic quickly. Unity uses C# scripts, UE uses Blueprints + C++, Godot uses GDScript. I went with C# — simple syntax, good enough performance, and the Mono runtime is open-source.
This post covers two things: the C# scripting system and shader programming support. The first makes game logic programmable; the second makes rendering effects programmable. Together, they bring real extensibility to the engine.
C# Scripting System
Introduction
The C# scripting system is built on the Mono runtime. Mono is the open-source implementation of .NET, able to run C# code on Windows, Linux, and Mac. Our engine embeds Mono into the C++ program, and from there we can load and execute C# scripts.
Dynamic Loading of Mono
Since I don’t want Mono statically linked into the engine (avoids dragging a pile of libs into the release and version conflicts are a pain to deal with), we use dynamic loading: first LoadLibraryA to load mono-2.0-sgen.dll (the sgen variant, which uses the SGen GC), then GetProcAddress to resolve the function pointers one by one. Mono has a lot of API (30+ functions), all turned into function pointers via typedef:
The upside of this approach: no need to ship mono-2.0-sgen.lib in the release, and DLL version conflicts are easier to deal with. The downside is one extra DLL lookup and function resolution at startup, but that overhead is negligible.
mono_jit_init("DittoEngine") — the "DittoEngine" here is just the domain name, name it whatever you like. After this, you can use p_mono_domain_assembly_open to load DittoEngine.dll (the engine API assembly), which contains the C# wrappers for GameObject, Transform, Rigidbody, and other engine APIs. Game scripts inherit from the MonoBehaviour base class and override Start, Update, etc. to implement game logic.
Script Compilation
C# script compilation involves two DLLs that you need to keep straight:
DittoEngine.dll: the engine’s C# API (Transform, Rigidbody, MonoBehaviour, etc.), analogous to Unity’s UnityEngine.dll. This DLL is pre-built once using 3rdParty/Mono/Build DLL.cmd from DittoEngine.cs:
voidMonoRuntime::CallStart(std::shared_ptr<ScriptInstance> scriptInstance) { if (!scriptInstance || !scriptInstance->instance) return; MonoMethod* method = mono_class_get_method_from_name(scriptInstance->klass, "Start", 0); if (method) { MonoObject* exception = nullptr; mono_runtime_invoke(method, scriptInstance->instance, nullptr, &exception); if (exception) DITTO_LOG_ERROR("[Mono] Exception in Start()"); } }
voidMonoRuntime::CallUpdate(std::shared_ptr<ScriptInstance> scriptInstance) { if (!scriptInstance || !scriptInstance->instance) return; MonoMethod* method = mono_class_get_method_from_name(scriptInstance->klass, "Update", 0); if (method) { MonoObject* exception = nullptr; mono_runtime_invoke(method, scriptInstance->instance, nullptr, &exception); if (exception) DITTO_LOG_ERROR("[Mono] Exception in Update()"); } }
Internal Calls
Scripts need to be able to access engine functionality — get a Transform, modify a Rigidbody, and so on. This is done via Internal Calls: register some functions on the C++ side, and the C# side can then call them:
voidCSharpScriptComponent::ParseFieldDeclaration(const std::string& statement) { std::string s = Trim(statement); // Parse attributes: [SerializeField], [HideInInspector] bool hasSerializeField = false, hideInInspector = false; while (!s.empty() && s[0] == '[') { size_t close = s.find(']'); std::string attr = s.substr(1, close - 1); if (attr.find("SerializeField") != std::string::npos) hasSerializeField = true; if (attr.find("HideInInspector") != std::string::npos) hideInInspector = true; s = Trim(s.substr(close + 1)); } if (hideInInspector) return;
// Parse modifiers: public, private, static, const std::vector<std::string> tokens; std::stringstream ss(s); std::string token; while (ss >> token) tokens.push_back(token); bool isPublic = false, isStatic = false, isConst = false; size_t i = 0; for (; i < tokens.size(); ++i) { if (tokens[i] == "public") isPublic = true; elseif (tokens[i] == "static") isStatic = true; elseif (tokens[i] == "const") isConst = true; elseif (!IsModifier(tokens[i])) break; // Hit the type name } // Only serialize public or [SerializeField] fields, skip static/const if (isStatic || isConst) return; if (!isPublic && !hasSerializeField) return; if (i >= tokens.size()) return; std::string typeName = tokens[i++]; // Map the type ScriptFieldType fieldType; if (!MapFieldType(typeName, fieldType)) return; // Parse field name and default value if (i >= tokens.size()) return; std::string fieldName = tokens[i]; std::string defaultValue; // Extract default value (if any) size_t eqPos = s.find('='); if (eqPos != std::string::npos) defaultValue = Trim(s.substr(eqPos + 1)); ScriptField field(fieldName, fieldType); AssignDefault(field, defaultValue); fields.push_back(field); }
Supported field types: float, int, bool, string, Vector2, Vector3, Vector4. These show up in the Inspector as drag bars, input fields, checkboxes, etc.
Hot Reload
Modifying a script doesn’t require restarting the engine — recompile and reload, and the changes take effect immediately. Each frame we check the script file’s modification time; if it changed, hot reload kicks in:
Drop this script onto a GameObject and it’ll start rotating at runtime. Change speed or axis and the change takes effect immediately.
Shader Programming
Introduction
Beyond game logic, rendering effects also need to be programmable. The engine ships with a few built-in shaders (Phong lighting, sprite rendering, etc.), but users will inevitably want custom effects — toon shading, outlines, dissolves, and so on. So we need to support writing and loading custom shaders.
Shader Resource Management
Shader files (.shader) live under Assets/Shaders. When the engine starts, it scans that directory and loads all shaders:
The shader name uses the Category/Name format (e.g. Ditto/Lit_Toon)
The Pass is wrapped in CGPROGRAM/ENDCG, written in standard HLSL/CG
Parameters declared in the Properties block (_MainTex, _Color) are exposed in the Inspector
Tags mark the render type and queue
Fallback specifies a fallback shader
Shader Parsing
Properties parsing lives in its own module, Engine/Graphics/Shaders/ShaderAsset.cpp. Plain string::find doesn’t cut it because the Properties block contains nested braces (e.g. texture default values like "white" {}); a naive search for } will terminate too early. So we use std::regex combined with brace-depth tracking:
// ... iterate over each type using std::sregex_token_iterator }
Why not just use string::find("}") to terminate? Because the Properties block contains nested {} (e.g. the texture default value "white" {}). For this kind of lexical nesting problem, the right approach is brace-depth tracking — which is also why this parser lives in its own ShaderAsset module: it’s complex enough that it doesn’t belong scattered inside Renderer.
The CGPROGRAM/ENDCG blocks in a Pass use the same idea: start from CGPROGRAM, track depth until the matching ENDCG, and everything in between is the complete vertex+fragment shader source.
Material
A Material is bound to a Shader and stores the values of the shader’s parameters:
Call CheckShaderReload periodically in the editor’s update loop, and you’ve got hot reload.
Closing Notes
At this point, the engine’s scripting system is in pretty good shape. C# scripts can access all the engine’s functionality — Transform, Rigidbody, Input, Physics, Audio, etc. — and the hot reload loop makes iteration fast. The shader system makes rendering effects programmable, so users can write custom lighting models, post-processing effects, and so on.
Still a long way from Unity and UE, but plenty for a personal project. If I keep filling in the gaps, the next targets would be animation, particle systems, navigation meshes, and so on — though probably not anytime soon. The thesis is done, but spring recruitment continues, so I can’t really stop and rest just yet.