Compatibility, IL2CPP Patterns, and Troubleshooting
SCP: Secret Laboratory is an IL2CPP build of Unity. MelonLoader’s IL2CPP path papers over most of the cross-language friction, but a handful of patterns are non-obvious and bite every first-time Anomaly mod author. This chapter is a catalogue of the ones you’ll hit.
Coroutines
Section titled “Coroutines”StartCoroutine(...) is a member of MonoBehaviour in regular Unity. In IL2CPP it still exists, but calling it on an IL2CPP-registered MonoBehaviour from managed code is unreliable. Prefer one of:
// MelonLoader's static entry pointMelonLoader.MelonCoroutines.Start(MyCoroutine());MelonLoader.MelonCoroutines.Stop(handle);
// Or Anomaly's thin wrapperAnomaly.Client.Api.Coroutines.Start(MyCoroutine());Anomaly.Client.Api.Coroutines.CallDelayed(0.5f, () => DoThing());If you only need time-based logic — “after 3 seconds, run X” or “every second, tick Y” — skip coroutines entirely and use Scheduler.In / Scheduler.Every. Coroutines are for patterns that yield on Unity frame events (WaitForEndOfFrame, WaitForFixedUpdate, etc.).
Keeping state across scene loads
Section titled “Keeping state across scene loads”Unity destroys GameObject instances when a scene unloads unless you mark them:
UnityEngine.Object.DontDestroyOnLoad(myGameObject);Apply this on any object whose lifetime spans rounds (a HUD root, a persistent state manager). Forgetting it leads to a fun class of bug where everything works until the first scene transition, then your mod’s state silently disappears.
Custom MonoBehaviour types
Section titled “Custom MonoBehaviour types”When you write your own MonoBehaviour subclass for use in the IL2CPP game, you must register the type so IL2CPP knows it exists:
using Il2CppInterop.Runtime.InteropTypes;using Il2CppInterop.Runtime.Injection;using UnityEngine;
[Il2CppInterop.Runtime.Attributes.RegisterTypeInIl2Cpp]public class MyHudController : MonoBehaviour{ // IL2CPP requires both constructors. They do nothing; that's fine. public MyHudController(IntPtr ptr) : base(ptr) { } public MyHudController() : base(ClassInjector.DerivedConstructorPointer<MyHudController>()) { ClassInjector.DerivedConstructorBody(this); }
private void Awake() { /* your init */ } private void Update() { /* per-frame work */ }}The two-constructor pattern is mandatory. Without it, AddComponent<MyHudController> either fails outright or creates a half-initialized object.
Managed collections on registered MonoBehaviours
Section titled “Managed collections on registered MonoBehaviours”IL2CPP rewrites methods of registered types so they can be called from the IL2CPP runtime. If one of those methods touches a managed Dictionary<K,V>, List<T>, or Queue<T>, the rewriter can’t represent the generic instantiation correctly.
The fix is to mark the method as “skip IL2CPP rewrite” — managed code still calls it normally, but IL2CPP doesn’t try to expose it:
using Il2CppInterop.Runtime.Attributes;
[Il2CppInterop.Runtime.Attributes.RegisterTypeInIl2Cpp]public class MyStateTracker : MonoBehaviour{ public MyStateTracker(IntPtr ptr) : base(ptr) { }
private readonly Dictionary<int, string> _cache = new();
[HideFromIl2Cpp] public void AddEntry(int id, string value) { _cache[id] = value; }}Rule of thumb: any MonoBehaviour method (on a registered class) that uses a managed generic collection gets [HideFromIl2Cpp]. Unity won’t call it (IL2CPP’s Unity-facing surface doesn’t know about it), but your own C# code calls it fine.
Binding to IL2CPP-declared events
Section titled “Binding to IL2CPP-declared events”SCP:SL and MelonLoader expose events via IL2CPP types. Subscribing a managed delegate directly to such an event fails at runtime — you need to convert:
using Il2CppInterop.Runtime.InteropTypes.Arrays;using Il2CppInterop.Runtime;
ShotEventManager.OnShot += DelegateSupport.ConvertDelegate<ShotEventManager.ShotEventManagerDelegate>( new Action<Il2CppSystem.Object>(OnShot));
private void OnShot(Il2CppSystem.Object evt) { /* ... */ }If you hit a “cannot convert System.Action to Il2Cpp…” compile error, you’re missing a DelegateSupport.ConvertDelegate<T> step. Prefer public MelonLoader and Il2CppInterop examples for event-binding patterns.
Common footguns
Section titled “Common footguns”Forgetting [assembly: MelonAdditionalDependencies("Anomaly")]
Section titled “Forgetting [assembly: MelonAdditionalDependencies("Anomaly")]”MelonLoader calls OnInitializeMelon in dependency-declared order. If your mod calls any Anomaly API during its own OnInitializeMelon and hasn’t declared the dependency, load order is undefined — on some runs it works, on others it hits a NullReferenceException deep inside an Anomaly registry. Always declare the dependency.
Subscribing to one-shot events too late
Section titled “Subscribing to one-shot events too late”ClientEvents.CanvasReady, Connected, and Ready fire once per session. If you subscribe after they’ve fired, you do not get a late-dispatch call. Always subscribe in OnInitializeMelon (which runs before any of these fire).
Touching LocalPlayer before Ready
Section titled “Touching LocalPlayer before Ready”Client.LocalPlayer is null until ClientEvents.Ready. Reading it during OnInitializeMelon, SceneLoaded, or Connected returns null — NullReferenceException if you didn’t null-check.
Mutating game state from a client mod
Section titled “Mutating game state from a client mod”Client.LocalPlayer.Health = 100 compiles, but has no authoritative effect — the next server health update overwrites it. If you need authoritative state, see Server Companion Quickstart.
Not re-subscribing after disconnect
Section titled “Not re-subscribing after disconnect”The Scheduler clears all tasks owned by your mod on disconnect, and event subscriptions persist across disconnect — but any state you accumulated in handler-local fields still lives. Be explicit about what you reset on Disconnected:
ClientEvents.Disconnected += _ =>{ _myCache.Clear(); // Scheduler tasks already cleared automatically};Reading cross-language stack traces
Section titled “Reading cross-language stack traces”When your mod crashes, stack traces often span IL2CPP and managed frames. Rules:
- Managed frames start with your namespace. They’re readable normally.
- IL2CPP frames show as
Il2CppSystem.*orIl2CppInterop.Runtime.*. Skim past them — they’re the glue, not the bug. - Unity engine frames (
UnityEngine.Object.Instantiate, etc.) are usually the interop boundary; the frame immediately above is often where your bug lives.
If a stack trace shows a pure IL2CPP crash with no managed frames, the bug is almost certainly a MonoBehaviour registration or delegate-conversion issue — recheck the patterns at the top of this chapter.
When in doubt
Section titled “When in doubt”Prefer public MelonLoader, Il2CppInterop, and Anomaly API examples. If a needed pattern is missing from these docs, open a documentation issue with the use case.
End-to-End Tutorial — build a small Client + Server + Shared feature from scratch.