Solo Godot projects tend to fail one of two ways.
The first is a spaghetti explosion — everything reaches into everything else, the scene tree becomes a lookup table, and around the 20,000-line mark you can't add a feature without breaking two others. The second is over-engineering — every subsystem becomes an interface, every interface gets a factory, and around the 20,000-line mark you can't add a feature because you're still building the framework.
DungeonCrawl is 87,356 lines of GDScript at the time of writing. It doesn't do either of those things. The shape that kept it upright is boring on purpose: 13 autoloaded singletons, each with a job you could explain in one sentence, and a rule that any subsystem talking to another goes through one of them.
This post walks the actual autoload list from project.godot, what each one owns, and what I learned about picking them.
What an autoload actually is
Godot's autoload is a scene or script that Godot instantiates once, attaches to the root of the scene tree, and exposes as a global by name. If I write Autoload > GameManager > res://scripts/systems/game_manager.gd, then anywhere in the codebase I can write GameManager.pause() and it just works. No dependency injection. No service locator. No import ceremony.
That's also the trap. Autoloads are so easy to add that the natural failure mode is to autoload half the codebase and end up with a global soup that's worse than the object graph you were trying to escape. The rule I converged on: an autoload has to be truly shared, or it doesn't earn the slot. Everything else lives in the scene tree or in a resource file.
Thirteen made the cut.
The 13, and why
Ordered as they appear in project.godot because that's the order they load, and load order matters more than you'd expect.
GameManager
Owns run state. Pause, resume, floor transitions, save-on-death, seed. Everything that says "the game as a whole is doing X" lives here.
Load order: first. Nothing else can be right if GameManager isn't right.
QuestManager
Every questline is a Resource on disk. QuestManager holds the active roster, tracks completion, and fires the signals other systems listen for (quest_completed, stage_advanced).
Kept separate from GameManager because pause/resume shouldn't touch quest logic, and vice versa.
SFXManager
One pooled AudioStreamPlayer bank, exposed as SFXManager.play("hit_light"). The reason this is autoloaded and not per-scene: I don't want a scene transition to cut a sound mid-play. Pooling at the root fixes it once.
VFXManager
Same reasoning as SFXManager. One pool of GPUParticles that scenes can borrow. Explosion in the current room, room unloads, explosion still finishes on the root.
CombatWorld
The one autoload that doesn't sound like a manager. CombatWorld owns the query surface for anything that wants to ask "what enemies are within radius R of point P?" — a spatial index rebuilt every physics tick, backed by a bucket grid.
The reason it's autoloaded: every combat script (bullet, splash, aoe, aura) asks this question. Threading a shared reference through 40 scripts is worse than putting it at the root.
FactionManager
Relationships between the ~30 in-game factions live here as a matrix. FactionManager.are_hostile(a, b) is called by targeting, AI, and dialogue. Cold data, hot lookup — perfect autoload shape.
LightingManager
Time-of-day + biome lighting. Sunset in the swamp is different from sunset on the ash flats. Autoloaded because both the world scene and the UI (compass, minimap) query the same source.
NoiseSystem
Global deterministic noise. Every subsystem that needs "random but seeded" — procgen, AI wander, drop chance — pulls from here so a given seed produces the same run.
PerkManager
Player perks and mutations are Resources. PerkManager loads them, applies them, and exposes the modifier stack (get_damage_mult(), get_move_speed_mult()). Any system that reads a player stat asks PerkManager, not the player node — because perks can be gained mid-run and I don't want every reader to hot-swap references.
CoopSession
Multiplayer session state. Whether we're in solo, hosting, or joined. Owns the peer table.
CoopRuntime
The tick loop for authoritative multiplayer. CoopSession is identity — who's connected. CoopRuntime is time — replicating state at 60Hz to peers. Splitting them was a refactor I did around 30k LoC when the "one giant multiplayer manager" started catching too many bugs.
PerfTelemetry
Ring buffer of frame times, physics costs, draw calls. Cheap to write, queryable from the debug overlay. Kept as an autoload so anywhere in the codebase can PerfTelemetry.mark("ai_step") and it shows up on the frame graph.
EnemyObservability
The autoload I'm most fond of. It watches the scene tree for enemies spawning, dying, and taking damage, and emits structured events that a headless debugger can consume. When an AI regresses I don't have to reproduce the situation live — I can replay events from a log.
What I didn't autoload
The list of "almost but no" is longer than the list above. Here's what I kept out and where it lives instead:
- UI state → per-scene
UIControllernodes. UI is a display of state, not a source of it. - Inventory → owned by the player node. There's only ever one player-instance at a time, and inventory travels with them.
- Dialogue → per-conversation resource + a scene-local
DialogueRunner. Making it global would tempt me to make dialogue reach into game state directly; keeping it scoped forces conversations to go through QuestManager for state changes. - Camera controllers → scene-local, because different scenes want fundamentally different camera behavior and I don't want a "SetCameraMode" enum growing forever.
- Save/load → a plain static class, called by GameManager. Not autoloaded because it has no state of its own; it's a namespace of functions.
The pattern that fell out: an autoload owns state that outlives any single scene. If the state can live inside a scene, that's where it goes.
The load-order footgun
Autoloads instantiate in the order they appear in project.godot. This is fine when each one is self-contained. It stops being fine when one autoload's _ready() calls into another that hasn't loaded yet.
I hit this once, hard, around the 40k LoC mark. PerkManager was reading save data from GameManager during _ready(), but I'd reordered the autoloads for readability and now PerkManager ran first. Result: perks silently applied against a null save state, and the bug only surfaced three floors in when a specific perk multiplied by zero.
Two rules that made this stop happening:
- No cross-autoload calls in
_ready(). If you need data from another autoload, wait until the first_process()frame or listen for a signal. - Every autoload gets an
is_ready()method if it has any deferred setup. Callers check it. This is boilerplate — I'd love to not have it — but the cost of forgetting was too high.
The counter-example: things I regret autoloading
Not everything on the list would go on the list if I started over.
LightingManager should probably be a scene-tree node under the world root, not a global. The reason it's global is historical: the compass UI wanted to sample sun angle, and I picked "make lighting global" over "give UI a reference to the world." I'd revisit that now.
FactionManager is close to the edge. It works, but a factions.gd static file with the matrix as a constant would do the same job with less runtime cost. Autoloading it means the whole thing sits in memory as a Node with a scene tree presence for no reason.
Why 13 and not 20, or 5
This is the actual question. Anyone can hand-wave "keep it small." What's the number that survives contact with the codebase?
The answer I converged on is not a number — it's a rule. An autoload has to be asked-of from at least three unrelated systems, and have state that outlives any single scene. If it's asked-of by two, one of those two should own it. If its state dies with a scene, it belongs in that scene.
Every autoload on the list above passes both bars. The ones I removed over time (and there were a few) failed one of the two.
What I'd tell 20k-lines-ago me
- Split identity from time early.
CoopSession(who's connected) vsCoopRuntime(what's being replicated) is a five-minute refactor at 5k LoC and a two-day refactor at 30k. The reason to do it early is not because the split is architecturally elegant. It's because every future addition attaches to the wrong axis if you don't. - PerfTelemetry from day one. The cost of not knowing where your frames go compounds. Adding it late means every performance question involves adding an instrumentation point first, which means you don't ask the question.
- Autoloads are not the enemy. Bad autoloads are. The naive rewrite advice is "reduce autoloads." The real advice is: reduce autoloads that don't earn their slot, keep the ones that do.
The rest is scene-tree discipline and not writing dialogue systems that reach into inventory.
DungeonCrawl case study: thesyedabbas.xyz/projects/solo-godot-engine