2.8 · Scripting
Gameplay logic written in C# on .NET 9, hosted inside the engine through Coral interop.
Scripts attach to entities via ScriptComponent, get a lifecycle, and call into the
engine through a tightly curated set of InternalCalls.
InternalCalls.cs declarations that
match a HZ_ADD_INTERNAL_CALL registration in ScriptGlue.cpp.
Overview
Scripting is hosted by a single ScriptEngine singleton that owns a .NET runtime via the
Coral interop library. User C# code lives in a project DLL built by ScriptBuilder; the
engine instantiates a managed object per ScriptComponent and drives it through a
well-defined lifecycle. All access from C# back into the engine flows through ScriptGlue,
which is the single registration point for InternalCalls.
ScriptEngine
Singleton. Hosts the .NET runtime, loads project assemblies, instantiates managed objects per
ScriptComponent, and drives lifecycle calls (OnCreate,
OnUpdate, …).
ScriptGlue
Registers all InternalCalls (engine APIs callable from C#). Categories: Entity,
Transform, Physics, Audio, Input, Scene, MuJoCo, etc. New InternalCalls go here — never in
ScriptEngine.h/.cpp itself.
ScriptBuilder
Invokes dotnet build to compile the user’s C# project into the script DLL the
engine will load.
ScriptEntityStorage
Per-entity field storage backed by a DataType enum, plus a per-scene
ScriptStorage map. Lets serialized field values survive between sessions and across
hot reload.
Lifecycle
Scripts ride the same timeline as the rest of the simulation — they bootstrap with the scene
and step on the TimeManager’s update phase.
-
OnRuntimeStart →
ScriptEngine::Initializespins up the .NET host and loads project assemblies. -
For each
ScriptComponent,Instantiatecreates the matching C# object and callsOnCreate. -
Each step, the
TimeManagercallsUpdateScriptsOnUpdate(StepContext), which routes through to C#OnUpdate(dt)on every live instance. -
On hot reload, the script DLL is detected via filewatch and the assembly is reloaded.
MonoObject*and other managed references are invalidated.
Key types
| Type | Header | Role |
|---|---|---|
ScriptEngine |
Hazel/src/Hazel/Script/ScriptEngine.h |
Singleton. .NET host + assembly loader + per-component instantiation + lifecycle dispatch. |
ScriptGlue |
Hazel/src/Hazel/Script/ScriptGlue.h /ScriptGlue.cpp |
Registers all InternalCalls. Entity, Transform, Physics, Audio, Input, Scene, MuJoCo, … All new bindings go here. |
ScriptBuilder |
Hazel/src/Hazel/Script/ScriptBuilder.h |
Drives dotnet build for the user’s C# project. |
ScriptEntityStorage |
Hazel/src/Hazel/Script/… |
Per-entity field storage; uses a DataType enum. Per-scene map: ScriptStorage. |
InternalCalls.cs |
Hazel-ScriptCore/Source/Hazel/InternalCalls.cs |
The C# side — [InternalCall] declarations that mirror ScriptGlue.cpp. Names must match exactly. |
Pitfalls
Hot reload invalidates MonoObject* and any other handle to managed objects.
If a C++ subsystem stashes a managed reference and the script DLL reloads, the next access is undefined.
Look the reference up by entity each frame — ScriptEngine already maintains the mapping.
Adding bindings to ScriptEngine.h/.cpp bypasses the registration pattern and makes
them invisible to tooling and code review. Put them in ScriptGlue.cpp, register with
HZ_ADD_INTERNAL_CALL, and add the matching [InternalCall] declaration in
InternalCalls.cs.
Coral pairs C# [InternalCall] declarations to C++ registrations by exact name. A typo
on either side resolves to a runtime NotImplementedException, not a build error.
Extending
Add a new InternalCall
- Implement the function in
Hazel/src/Hazel/Script/ScriptGlue.cpp. - Register it next to its siblings with
HZ_ADD_INTERNAL_CALL(ClassName_MethodName). - Add the matching
[InternalCall]declaration inHazel-ScriptCore/Source/Hazel/InternalCalls.cs. - Call it from a C# wrapper method on the appropriate component / class.
Use ClassName_MethodName consistently. For example: a Transform getter goes in as
TransformComponent_GetTranslation on both sides. Group registrations in
ScriptGlue.cpp by category to keep the file scannable.
Add a new lifecycle event
Lifecycle events are driven by TimeManager phase callbacks registered in Scene.
To add a new one (e.g. a OnLateUpdate equivalent):
- Add a phase callback in
Scenethat walks all entities with aScriptComponent. - From the callback, invoke the new managed method on each script instance via the
ScriptEngine-owned dispatch path used byUpdateScriptsOnUpdate. - Declare the method on the C# base script class (so user scripts can override it).