Godot 4 + C# Pitfall Notes: Five Deep Traps, with a Cheat Sheet and Minimal Reproductions
Environment: Godot 4.7.2 (mono) / .NET SDK 9 / C# (net8.0) / Linux
๐ฏ Audience: developers who can already compile and run a Godot C# project and are currently debugging script loading or build issues. Beginners: follow the official “Hello World” tutorial first, then come back. This article assumes you know your way around csproj files and a terminal; sections with unfamiliar terms can be skipped without losing the main thread.
๐ง The collapsible blocks below are source-level deep dives โ feel free to expand them โ they’re the real meat of this article. The main thread only needs “symptom โ fix”.
๐ Not covered here (our own workflow never hit these, and we don’t write about traps we haven’t stepped in; contributions welcome):
- Hot Reload / runtime debugging
- Community-known traps like
Godot.Collections.Array<T>generic constraints orStringNamenull behavior- C# traps on the mobile/web export pipeline
๐จ Cheat Sheet (read this first)
| # | What you did | Symptom keywords | One-line fix |
|---|---|---|---|
| 1 | Created/renamed a C# script | Script “class not found / does not inherit Node” | Filename (case-sensitive) must exactly match the class name (PascalCase-aligned) |
| 2 | Edited project.godot externally | Config silently overwritten, clicks dead | Close the editor before editing project.godot |
| 3 | Built repeatedly / built while editor open | “Duplicate attribute CS0579” | Close editor + exclude .godot/ in csproj |
| 4 | Ran unit tests (xUnit etc.) | “You must install or update .NET” | Add <RollForward>LatestMajor</RollForward> to test projects |
| 5 | Wrote a Timer variable |
CS0104 ambiguous reference | Fully qualify Godot.Timer, or <ImplicitUsings>disable</ImplicitUsings> |
๐ก๏ธ Survival Rules (6)
- C# filename = class name (PascalCase, case-sensitive)
- Close the editor before editing
project.godot - Avoid CLI builds while the editor is open (avoiding concurrent writes is the key โ locally, closing the editor before building is the least hassle; in CI there’s no editor, so builds are inherently safe; see trap 3 for the suspend trick)
- Add
<RollForward>LatestMajor</RollForward>to test/console projects - Fully qualify Godot types (
Godot.Timer), or disable ImplicitUsings - In multi-project roots, exclude other projects’ bin/obj and
.godot/**
๐ก๏ธ Preventive Coding Standards (reactive debugging โ proactive avoidance)
- Naming: C# scripts are always PascalCase and match the class name; create new scripts via the editor’s “New Script” template, never hand-write filenames
- Config: edit
project.godotonly through the editor’s Project Settings panel; if you must text-edit it, close the editor first - Builds: avoid CLI builds while the editor is open; build scripts (CI) should handle the editor state (close or suspend) up front
- Multi-project: every root-level csproj must exclude other projects’ bin/obj and
.godot/** - Namespaces: when a Godot type collides with a BCL type, always fully qualify โ or adopt a single
ImplicitUsingspolicy
๐ One-Click Scaffolding: Three Files (don’t want to read the trade-offs? Just copy these)
Want the easy path: drop these three files into your project root and you’re done. Want to understand why: see traps 3 and 4.
โ Directory.Build.props (global excludes, auto-inherited by every subproject):
<!-- Place in the project root; MSBuild auto-imports it into every project below -->
<Project>
<PropertyGroup>
<DefaultItemExcludes>$(DefaultItemExcludes);.godot/**;engine/bin/**;engine/obj/**;tests/**;tools/**</DefaultItemExcludes>
</PropertyGroup>
<ItemGroup>
<Compile Remove=".godot/**" />
<Compile Remove="engine/bin/**" />
<Compile Remove="engine/obj/**" />
</ItemGroup>
</Project>
โก build.sh (detect editor โ build โ test):
#!/usr/bin/env bash
set -e
# 1. Detect whether the Godot editor is running (it auto-builds and races the CLI on .godot/mono)
if pgrep -f "Godot.*--editor" > /dev/null; then
echo "โ ๏ธ Godot editor detected. Close it before building (avoid concurrent writes)"
exit 1
fi
# 2. Build + test
dotnet build tianxing.sln
dotnet test tianxing.sln
๐ A more general detection approach: process-name patterns vary by platform/distribution (the Steam build of Godot may use a different process name). Replace
"Godot.*--editor"inbuild.shwith your own pattern (e.g. aGODOT_PROC_PATTERNenv var); when unsure, self-check first:ps aux | grep -i godot(Linux/macOS) orGet-Process | Where-Object {$_.ProcessName -like "*godot*"}(Windows). The core principle is simply: no other process should be writing to.godot/monowhile you build.๐ช Windows (PowerShell): use
build.ps1โbuild.shonly applies to Linux/macOS:
# build.ps1
# 1. Detect the Godot editor process (auto-build races the CLI on .godot/mono)
if (Get-Process -Name "Godot*" -ErrorAction SilentlyContinue) {
Write-Host "โ ๏ธ Godot editor detected. Close it before building (avoid concurrent writes)"
exit 1
}
# 2. Build + test
dotnet build tianxing.sln
dotnet test tianxing.sln
โข .gitignore additions (keep build artifacts out of the repo):
bin/
obj/
.godot/mono/temp/
*.user
Trap 1: Filename == Class Name (PascalCase) โ Otherwise the Script Is Silently Ignored
Symptom: the autoload script won’t start; headless runs fail the same way:
ERROR: Failed to instantiate an autoload, script 'res://autoload/game_manager.cs' does not inherit from 'Node'.
ERROR: Cannot instantiate C# script because the associated class could not be found.
Make sure the script exists and contains a class definition with a name that matches
the filename of the script exactly (it's case-sensitive).
Minimal reproduction: filename game_manager.cs, class name GameManager (looks perfectly normal, but always fails):
// File: res://autoload/game_manager.cs
using Godot;
public partial class GameManager : Node { }
โ Dead ends we tried: three namespace variations (Tianxing.GameManager โ Autoload.GameManager โ no namespace), all failed; --verbose confirmed the assembly loads fine, yet the class can’t be found.
๐ง Deep dive: root cause (source-level) โ skippable for beginners; platforms without folding support will just show it inline
Godot's source generator `ScriptPathAttributeGenerator.cs` only emits a `[ScriptPath]` registration for classes whose "filename (without extension) == class name": > In plain words: classes whose filename doesn't match the class name are filtered out right here and never get any registration code generated. ```csharp .Where(x => // Ignore classes whose name is not the same as the file name Path.GetFileNameWithoutExtension(x.cds.SyntaxTree.FilePath) == x.symbol.Name) ``` `game_manager.cs` with a `GameManager` class โ filename โ class name โ the class is **silently ignored**, no registration is generated, and at runtime you naturally get "class not found" โ with a highly misleading error message. Note: if a class is split across multiple `partial` files, the filter runs per syntax tree โ **as long as at least one file has a filename that matches** the class name, the class gets registered; the other partial files simply don't participate in registration. **Recommendation: keep the main class file aligned with the filename, and don't declare classes in other partial files whose names don't match their filenames** (it makes debugging needlessly confusing).Fix:
git mv autoload/game_manager.cs autoload/GameManager.cs # just align the filename with the class name
โ TL;DR: the filename (case-sensitive) of a Godot C# script must match the class name exactly, or the script is silently discarded.
Trap 2: Close the Editor Before Editing project.godot โ Otherwise Your Config Gets Overwritten by the Stale In-Memory Copy
Symptom: you press F5 in the editor and nothing is clickable; the log has one line:
ERROR: System.NullReferenceException ... at GameManager.Instance...
git diff project.godot shows the file was rewritten wholesale: the [autoload] section deleted (singleton gone, clicks naturally dead), the renderer reverted, window size lost.
Minimal reproduction:
- Open the Godot editor and load the project
- Modify
project.godotexternally (e.g. add an[autoload]section) - Editor saves/exits โ the on-disk file is overwritten by the stale in-memory copy, your changes vanish
Root cause: the editor holds an in-memory copy of project.godot while a project is open; when you modify the file on disk, the editor writes back from its own stale copy โ your config is silently overwritten with no warning at all (nothing to do with version control; pure editor behavior).
Fix (safe modification workflow):
- โ Recommended: edit via the editor’s Project Settings panel
- โ Or: close the editor โ edit with a text editor โ reopen the editor (only now will it read the new config)
# Close editor โ edit project.godot โ reopen editor
โ
TL;DR: project.godot should only ever be changed by the editor itself; for external edits, close the editor first (otherwise your changes get clobbered by the editor’s stale in-memory copy).
Trap 3: CS0579 Duplicate Attributes โ Build Pollution Needs Excludes + Isolated Builds
Symptom: dotnet build fails reliably, pointing at generated files under .godot/mono/temp/obj/:
error CS0579: Duplicate 'System.Reflection.AssemblyCompanyAttribute' attribute
error CS0579: Duplicate 'global::System.Runtime.Versioning.TargetFrameworkAttribute' attribute
Minimal reproduction:
- Project uses Godot.NET.Sdk (intermediates land in
.godot/mono/temp/obj) - First build succeeds (generates
*.AssemblyInfo.cs) - Second build โ the previous round’s generated files get swept into compilation by the default
**/*.csglob, duplicating what the SDK generates this time โ CS0579 - If the Godot editor is open (it auto-builds), it races the CLI on the same directory, worsening the pollution
๐ง Deep dive: root cause (source-level) โ skippable for beginners; platforms without folding support will just show it inline
Godot.NET.Sdk redirects `BaseIntermediateOutputPath` to `.godot/mono/temp/obj` but doesn't sync that into MSBuild's default exclude list โ so generated files get double-collected by the project's own glob. This is a design flaw at the SDK level; clearing caches only treats the symptom.Fixes (ordered by safety):
โ Most reliable: close the editor and build. No concurrent writes, no pollution source.
โก Suspend the editor before CLI builds (๐ ๏ธ advanced trick, not required; Linux/macOS only):
pgrep -af Godot # find the editor PID
kill -STOP <PID> # suspend (freeze)
dotnet build tianxing.sln
kill -CONT <PID> # resume
โ ๏ธ Warning:
kill -STOP/CONTis Linux/macOS only โ not available on Windows; if the editor UI misbehaves after resuming (rendering/input stuck), just restart the editor (project state is not lost). Also note: suspending for too long may cause the GPU context (Vulkan/OpenGL) to reset on resume and crash the editor (possible loss of editor state, not data). Core principle: avoid concurrent writes โ locally, closing the editor before building is the least hassle (in CI there’s no editor, so builds are inherently safe). If you don’t need to preserve editor state (scene layout, dock panels, etc.), just close it; this trick is only for when you really don’t want to reopen the editor.๐ช Windows users: just close the editor and build (most reliable), or use the in-editor build (F5 auto-builds); CLI builds are not the normal path on Windows.
โข Global excludes: Directory.Build.props (recommended for multi-project, best practice):
<!-- Root-level Directory.Build.props: MSBuild auto-imports into every project below, one-shot global excludes -->
<Project>
<PropertyGroup>
<DefaultItemExcludes>$(DefaultItemExcludes);.godot/**;engine/bin/**;engine/obj/**;tests/**;tools/**</DefaultItemExcludes>
</PropertyGroup>
<ItemGroup>
<Compile Remove=".godot/**" />
<Compile Remove="engine/bin/**" />
<Compile Remove="engine/obj/**" />
</ItemGroup>
</Project>
๐ฆ Place it in the project root; every subproject (including engine/, tests/) inherits it automatically; exclude paths resolve relative to each project’s own root, so non-existent directories are harmless. Multi-project maintenance cost drops sharply โ zero config for new projects. If you want it to affect only a single project, use option โฃ’s per-csproj excludes (more explicit).
โฃ Local excludes: explicit per-csproj config (more explicit; enough for single-project setups):
<!-- Exclude paths are relative to the project root -->
<PropertyGroup>
<DefaultItemExcludes>$(DefaultItemExcludes);.godot/**;engine/bin/**;engine/obj/**;tests/**;tools/**</DefaultItemExcludes>
</PropertyGroup>
<ItemGroup>
<Compile Remove=".godot/**" />
<Compile Remove="engine/bin/**" />
<Compile Remove="engine/obj/**" />
</ItemGroup>
๐ก We verified
DefaultItemExcludesworks on our setup; still, using it together withCompile Removeas belt and suspenders is recommended (if an older Godot.NET.Sdk doesn’t honor the property,Compile Removehas your back). If the excludes still don’t take effect: with the SDK shorthand (<Project Sdk="...">) the project body is already positioned before default-item evaluation; with explicit<Import>style projects, place the PropertyGroup after the Sdk.props import. Exclusion mechanics may differ across Godot.NET.Sdk versions โ defer to the docs for your version (we verified traditional excludes on 4.7.2).๐ฆ Multi-csproj projects: if you don’t use Directory.Build.props (option โข), every root-level csproj must add the excludes individually โ the main project’s generated outputs (
.godot/**,engine/obj/**) can be swept in by other projects’ default globs.๐ค Export scenario: to package, use
godot --headless --export-release <preset>(you must configure an export preset in the editor first). Note: a headless export also triggers a C# build internally (via the editor build callback), so the concurrent-write risk with a GUI editor still exists โ close the GUI editor before exporting too.
(There’s also a community trick of symlinking .godot/mono/temp to /tmp to isolate it; we haven’t verified it, so we won’t recommend it.)
โ TL;DR: the build pollution comes from Godot.NET.Sdk’s intermediate directory not being in the default excludes; the most reliable fix is “close the editor before building + explicit csproj excludes”.
Trap 4: Test Host Missing the .NET 8 Runtime โ RollForward Declaration
Symptom: the test project compiles but crashes the moment it runs:
Testhost process exited with error: You must install or update .NET to run this application.
Framework: 'Microsoft.NETCore.App', version '8.0.0' (x64)
The following frameworks were found: 9.0.19 at [...]
Minimal reproduction: only the .NET 9 runtime is installed, and you run dotnet test on a net8.0-targeted xUnit project.
Root cause: the .NET SDK 9 can compile net8.0 targets (the compiler is forward-compatible), but running testhost requires the net8.0 runtime; the SDK won’t roll forward to a major version on its own.
Fix: declare roll-forward in the test csproj:
<PropertyGroup>
<RollForward>LatestMajor</RollForward>
</PropertyGroup>
Note:
RollForwardonly affects runtime version selection; it does not change the compile-time target framework (<TargetFramework>net8.0</TargetFramework>stays as-is). Rolling forward to .NET 9 carries a very small risk of API behavior differences; fine for development, but deploy the correct runtime in production.๐ก CI advice: in critical environments like CI/CD, the best practice is to pin the SDK via
global.jsonand install the target runtime, rather than relying onRollForwardโ avoid implicit roll-forward, prevent “works locally, fails in CI”, and keep behavior fully reproducible.
โ
TL;DR: when the target framework doesn’t match the installed runtime, add <RollForward>LatestMajor</RollForward> to the projects that need to run.
Trap 5: Timer Name Collision โ Fully Qualify Godot Types
Symptom:
error CS0104: 'Timer' is an ambiguous reference between 'Godot.Timer' and 'System.Threading.Timer'
Minimal reproduction: a C# project with ImplicitUsings enabled (which implicitly brings in System.Threading), writing new Timer { ... } alongside using Godot;.
Root cause: both namespaces define a Timer; the compiler can’t decide for you.
Fix (pick one):
โ Fully qualify both the field and the constructor:
private Godot.Timer _timer = null!;
_timer = new Godot.Timer { OneShot = true, WaitTime = 2.0f };
โก If the project uses System types heavily and qualifying everything is tedious, disable implicit usings and add the ones you need manually:
<PropertyGroup>
<ImplicitUsings>disable</ImplicitUsings>
</PropertyGroup>
Note: Godot 4’s
Timer.WaitTimeis adouble(seconds); if you actually meantSystem.Timers.TimerorSystem.Threading.Timer, those are a completely different API (callback model, threading semantics) โ don’t mix them up.
โ TL;DR: when a Godot type collides with a BCL type, fully qualify it โ or disable ImplicitUsings project-wide.
Appendix 1: Debugging Tools (how we actually located these)
godot --headless --verbose: the startup log prints .NET module init, API hashes, and assembly paths โ the fastest way to confirm whether “the assembly even loaded”- Read the Godot source directly (when the official docs fail you):
modules/mono/editor/Godot.NET.Sdk/Godot.SourceGenerators/ScriptPathAttributeGenerator.csโ trap 1’s root cause (key filter at#L54-L57: blob link)modules/mono/glue/GodotSharp/GodotSharp/Core/Bridge/ScriptManagerBridge.csโ the pathโtype registration mechanismmodules/mono/godotsharp_dirs.cpp/modules/mono/mono_gd/gd_mono.cppโ assembly directory & loading logic (corroborates trap 3)- Fetching:
https://raw.githubusercontent.com/godotengine/godot/<version-tag>/modules/mono/...(a release tag such as4.7.2-stableis pinned to the commit it was released from, safe to reference; for absolute stability, swap in a commit hash yourself)
pgrep -af Godot+kill -STOP/CONT: handle the editor’s concurrent builds (suspend rather than kill)- Divide and conquer: shrink the problem to the smallest scenario (one script, one scene, one build) before locating it
Appendix 2: Order of Checks for Weird C# Errors
Check in this order and you’ll locate the vast majority of issues within five minutes:
- Filename == class name? (case-sensitive) โ the most common reason a script is silently ignored
- Was
project.godotchanged externally? โgit diff project.godotfor unexpected regressions (autoload/rendering/window settings) - Is a build directory being swept in by a glob? โ confirm the csproj excludes
.godot/**and other projects’ bin/obj - Does the runtime version match? โ add
RollForwardto test/console projects, or install the matching runtime - Namespace collision? โ fully qualify Godot types that collide with BCL types, or adopt a single ImplicitUsings policy

