Lucky Robots Blog Open Roles

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.

Module: Hazel/src/Hazel/Script/ ScriptEngine.h ScriptGlue.h Hazel-ScriptCore .NET 9 · Coral
C# managed C++ native Coral / P/Invoke Entity.OnUpdate(dt) user script (C#) InternalCalls.cs [InternalCall] decls ScriptGlue.cpp HZ_ADD_INTERNAL_CALL(...) Engine subsystem Scene / Physics / Audio / MuJoCo return value (struct / nint / primitive)
A C# call hops the managed/native boundary through 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, …).

ScriptEngine.h singleton

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.

ScriptGlue.h all bindings live here

ScriptBuilder

Invokes dotnet build to compile the user’s C# project into the script DLL the engine will load.

ScriptBuilder.h dotnet build

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.

ScriptEntityStorage DataType enum

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.

1 OnRuntimeStart ScriptEngine::Initialize load assemblies 2 Instantiate new C# object + OnCreate() 3 UpdateScriptsOnUpdate TimeManager → OnUpdate(dt) every step loop user script body scene, physics, audio, … 4 · hot reload DLL changes on disk filewatch → reload assembly MonoObject* / managed refs invalidated re-instantiate
Startup → per-component instantiation → per-step update. Hot reload tears managed state down and rebuilds it — native code holding raw managed pointers across the reload is undefined behaviour.
  1. OnRuntimeStartScriptEngine::Initialize spins up the .NET host and loads project assemblies.
  2. For each ScriptComponent, Instantiate creates the matching C# object and calls OnCreate.
  3. Each step, the TimeManager calls UpdateScriptsOnUpdate(StepContext), which routes through to C# OnUpdate(dt) on every live instance.
  4. On hot reload, the script DLL is detected via filewatch and the assembly is reloaded. MonoObject* and other managed references are invalidated.

Key types

TypeHeaderRole
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

Never cache managed references across frames

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.

All InternalCalls go in ScriptGlue

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.

Names must match exactly

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

  1. Implement the function in Hazel/src/Hazel/Script/ScriptGlue.cpp.
  2. Register it next to its siblings with HZ_ADD_INTERNAL_CALL(ClassName_MethodName).
  3. Add the matching [InternalCall] declaration in Hazel-ScriptCore/Source/Hazel/InternalCalls.cs.
  4. Call it from a C# wrapper method on the appropriate component / class.
Naming convention

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):

  1. Add a phase callback in Scene that walks all entities with a ScriptComponent.
  2. From the callback, invoke the new managed method on each script instance via the ScriptEngine-owned dispatch path used by UpdateScriptsOnUpdate.
  3. Declare the method on the C# base script class (so user scripts can override it).