Quick answer: Godot’s .NET export pipeline runs the IL trimmer to shrink builds. Custom types referenced only through inspector serialization look unused to the trimmer, so it removes their constructors and properties — and the scene loader produces an empty array at runtime. Disable trimming to confirm the cause, then add [DynamicallyAccessedMembers] attributes or a TrimmerRootDescriptor XML so the linker keeps your types.
You define a C# Resource called EnemySpawnEntry and add an [Export] array of those resources to your spawner script. In the editor you fill in fifteen entries and the inspector shows them all. You hit play and it works perfectly. You export to Windows or Linux, run the build, and the spawner crashes because the array is empty. Nothing about your code or your scene changed — only the export pipeline ran — and yet the data is gone.
The Symptom
You have an exported field with a non-trivial element type:
using Godot;
using Godot.Collections;
public partial class Spawner : Node {
[Export] public Array<EnemySpawnEntry> Entries { get; set; } = new();
}
public partial class EnemySpawnEntry : Resource {
[Export] public PackedScene Scene { get; set; }
[Export] public int Weight { get; set; } = 1;
[Export] public float SpawnDelay { get; set; } = 0.5f;
}
The scene file (spawner.tscn) on disk contains the entries serialized as sub-resources. The editor reads them perfectly. The exported game logs Entries.Count = 0. No error, no warning — just an empty array. Sometimes the array contains the right number of slots but every element is null; sometimes the slots have an EnemySpawnEntry instance but every property is the default value.
What Causes This
Godot 4’s .NET integration runs each export through the .NET ILLinker (formerly known as the trimmer). The trimmer scans your IL for explicit references and removes any type, method, or property it cannot reach. This shrinks builds significantly — often by 30 to 60 percent — but it relies on an assumption that everything you use is reachable through normal call graphs.
Inspector serialization breaks that assumption. The scene file is data, not IL. When the scene loader deserializes EnemySpawnEntry, it uses reflection to find the type, call its parameterless constructor, and assign properties. The trimmer cannot see any of this. From its point of view, EnemySpawnEntry is never instantiated and its property setters are never called, so it removes them.
Three specific failure modes appear depending on what got trimmed:
1. Type completely removed. The class itself is gone from the assembly. Deserialization fails to resolve the type and the array stays empty.
2. Constructor removed. The parameterless constructor was stripped because no IL calls it. Deserialization throws internally and is swallowed; the array gets zero elements.
3. Property setters removed. The class survives but the public setters are stripped. Deserialization succeeds in creating instances but cannot assign values, so every property is the default.
This is fundamentally the same class of bug as Unity’s IL2CPP “managed code stripping” problem — and the fix uses the same conceptual tools.
The Fix
Step 1: Confirm trimming is the culprit. Open the export preset, go to Mono Tools, and set Trim Mode to None. Re-export and run. If the array now contains data, trimming is the cause. If it is still empty, the issue is elsewhere — check that the scene file actually contains the data you expect.
Step 2: Preserve your serialized types with attributes. Annotate every class used as an exported field, array element, or Resource property. The simplest preservation is the [DynamicallyAccessedMembers] attribute pointing at the class itself.
using System.Diagnostics.CodeAnalysis;
using Godot;
[DynamicallyAccessedMembers(
DynamicallyAccessedMemberTypes.PublicConstructors |
DynamicallyAccessedMemberTypes.PublicProperties |
DynamicallyAccessedMemberTypes.PublicFields)]
public partial class EnemySpawnEntry : Resource {
[Export] public PackedScene Scene { get; set; }
[Export] public int Weight { get; set; } = 1;
[Export] public float SpawnDelay { get; set; } = 0.5f;
}
This tells the trimmer: keep all public constructors, properties, and fields on this type because something will access them dynamically. The cost is a small build size increase only for these specific types.
Step 3: For broader coverage, use a TrimmerRootDescriptor. When you have many serializable types, list them in an XML file that the linker treats as roots.
<!-- File: TrimmerRoots.xml -->
<!-- Place at project root and reference in your .csproj. -->
<linker>
<assembly fullname="MyGameAssembly">
<type fullname="MyGame.EnemySpawnEntry" preserve="all" />
<type fullname="MyGame.LootTableEntry" preserve="all" />
<type fullname="MyGame.DialogLine" preserve="all" />
</assembly>
</linker>
Reference it in your .csproj:
<ItemGroup>
<TrimmerRootDescriptor Include="TrimmerRoots.xml" />
</ItemGroup>
Step 4: Add a startup sanity check. The hardest part of this bug is that it produces no error message. Add an explicit check during scene init so a regression triggers a clear log line, not a crash twenty minutes into gameplay.
public override void _Ready() {
if (Entries == null || Entries.Count == 0) {
GD.PushError($"Spawner.Entries is empty after deserialization. " +
"Likely caused by trimmer stripping EnemySpawnEntry.");
return;
}
GD.Print($"Spawner loaded with {Entries.Count} entries.");
}
Step 5: Verify the project setting matches the build target. In Project Settings > Dotnet > Project, ensure Assembly Reload Attempts is greater than 1 and that you are not accidentally using Mono Tools > Trim Mode = Aggressive. Aggressive strips even more than the default and is a frequent source of this bug for new projects.
Step 6: Re-export and verify. With preservation in place, re-export and run the game. Confirm the startup print shows the expected count.
Why This Works
The .NET trimmer is a static analysis pass that follows the IL. It cannot look inside .tscn or .tres data files, so it cannot infer that a type will be deserialized at runtime. Preservation attributes and TrimmerRoots descriptors are the contract you sign with the trimmer: “trust me, this type matters even if no IL calls it directly.”
The reason Godot does not preserve [Export] types automatically is that an exported field can hold a type from a third-party assembly the engine has no way to introspect. The conservative default would be to preserve every public type in every referenced assembly, which would defeat the purpose of trimming. Opt-in preservation gives you small, fast builds where it is safe, and explicit guarantees where it matters.
The same pattern shows up in every AOT-compiled or trimmed runtime — Unity IL2CPP, .NET NativeAOT, MAUI, Blazor. The vocabulary changes ([Preserve], link.xml, DynamicDependency) but the underlying cause is identical: static analysis cannot see through reflection-based serialization.
"The trimmer is doing its job. It is removing things you did not tell it to keep. Inspector serialization happens at the data layer, beneath the IL the trimmer can see — you have to surface those types explicitly."
Related Issues
If your exported variables are missing entirely (not just empty arrays), see Fix: Godot Export Variable Resource Null at Runtime. For dictionary-typed exports that come back null, check Fix: Godot Export Variable Dictionary Null at Runtime.
If the trimmer cannot see the call, you have to spell out the type.