Back to Blog
Game DevelopmentMarch 14, 202612 min read

How I Manage 123+ Scripts in Godot Without Losing Control

DR

Doug Ribeiro

12 min read

Most Godot tutorials show you how to build a small game. Very few show you what happens when the project grows. When you have more than a handful of scenes, when your scripts start referencing each other in ways you did not plan for, when a change to one system breaks something in a system you forgot was connected. I have been building a tactical RPG in Godot 4 with C# for some time now, and the project has grown. The counts I use here are a snapshot I took in February 2026: 123+ scripts, 334+ tests across unit, integration and performance suites, and 28 shaders. This post is about the architectural decisions that have kept the project manageable at that scale.

I want to be direct about the context: I am a solo developer. The patterns I use are optimized for one person who needs to understand the entire codebase and maintain it across months of intermittent development. Some of these patterns would look different in a team context.

Folder Structure: Reflect the Domain, Not the Engine

The default Godot project structure puts everything in a flat or lightly organized directory. For small projects, this is fine. For a project at scale, you need a folder structure that makes navigation intuitive, where you can find any script without searching because its location follows from what it does.

My top-level organization reflects the game's domains, not Godot's technical categories. I do not have a folder called Scripts at the top level. I have folders called Combat, Units, Grid, Skills, AI, UI, Campaign, Audio, and Shaders. Within each domain folder, I do have sub-organization by technical type: models, services, events, components. But the primary axis of organization is the game domain.

  • src/Combat/. Damage calculation, attack resolution, status effect application, combat log
  • src/Units/. Unit data, stat systems, unit state machines, unit factory
  • src/Grid/. Grid map representation, pathfinding, movement range calculation, tile queries
  • src/Skills/. Skill definitions as Resources, skill execution pipeline, targeting logic
  • src/AI/. Enemy decision trees, threat evaluation, action selection
  • src/UI/. All UI components, organized by screen or panel
  • src/Campaign/. Battle sequencing, campaign state, save/load
  • assets/resources/. All .tres Resource files, organized to mirror src/
  • tests/. Mirrors src/ structure exactly for easy test discovery
Game Entry / AutoloadsGameManager, EventBus, AudioManager. Global singletons initialized once 1 Core SystemsCombat, Grid, Units, AI. Domain namespaces with pure C# logic classes 2 Scene NodesBattleScene, UnitNode, TileNode. Thin Godot wrappers that delegate to c… 3 Tests (mirrors src/)xUnit for pure logic, GdUnit4 for engine-facing tests. 334+ tests total 4

The tests/ directory mirroring src/ is intentional. When I open CombatCalculator.cs, I can immediately navigate to tests/Combat/CombatCalculatorTests.cs without thinking about where the test file might be. The predictability of the structure reduces cognitive overhead when context-switching between implementation and tests.

Autoload vs Dependency: Knowing When to Use Each

Godot's Autoload system is effectively global singletons. You register a scene or script as an Autoload and it is accessible from anywhere in the game without any explicit reference management. This is powerful and dangerous in equal measure.

My rule is that Autoloads are for application-level concerns that genuinely span the entire game lifecycle: the audio manager, the save system, the input manager, the event bus. These are things that every system might legitimately need to reference, and making them globally accessible is appropriate because they represent cross-cutting infrastructure, not domain logic.

Domain-specific services are not Autoloads. The CombatResolver does not need to be globally accessible. Only systems involved in combat need a reference to it. I inject references to these services through scene composition: a BattleScene node holds references to CombatResolver, MovementController, TurnManager, and SkillExecutor, and passes those references down to child nodes that need them. This keeps dependencies explicit and testable.

csharp
// BattleScene.cs - Composition root for battle context
public partial class BattleScene : Node
{
    [Export] private BattleConfiguration _config;

    // Services constructed and owned here
    private CombatResolver _combatResolver;
    private MovementController _movementController;
    private TurnManager _turnManager;
    private SkillExecutor _skillExecutor;

    public override void _Ready()
    {
        // Build the service graph
        var gridQuery = new GridQueryService(GetNode<GridMap>("GridLayer"));
        var pathfinder = new AStarPathfinder(gridQuery);
        var movementRange = new MovementRangeCalculator(gridQuery);

        _movementController = new MovementController(pathfinder, movementRange, gridQuery);
        _combatResolver = new CombatResolver(_config.CombatRules);
        _skillExecutor = new SkillExecutor(_combatResolver, gridQuery);
        _turnManager = new TurnManager();

        // Inject into child nodes that need them
        GetNode<UnitCommandController>("UnitCommandController")
            .Initialize(_movementController, _skillExecutor, _turnManager);
        GetNode<EnemyAIController>("EnemyAIController")
            .Initialize(_movementController, _skillExecutor, _turnManager, gridQuery);
    }
}

The Signal Bus Pattern

Godot's built-in signal system connects signals from a specific emitter to a specific receiver. This works well for direct parent-child relationships, but it creates tight coupling when two systems that have no business knowing about each other need to communicate, which happens frequently in a game with many interacting systems.

The Signal Bus pattern solves this with a single Autoload that acts as a message broker. Systems emit events to the bus; other systems subscribe to events on the bus. Neither system has a direct reference to the other. This is effectively an event bus or a pub/sub system, familiar territory for anyone who has worked with Angular's services or Redux.

csharp
// GameEvents.cs - Autoload signal bus
public partial class GameEvents : Node
{
    public static GameEvents Instance { get; private set; }

    // Combat events
    [Signal] public delegate void UnitDamagedEventHandler(Unit target, DamageResult result);
    [Signal] public delegate void UnitDefeatedEventHandler(Unit unit);
    [Signal] public delegate void StatusEffectAppliedEventHandler(Unit target, StatusEffect effect);
    [Signal] public delegate void StatusEffectRemovedEventHandler(Unit target, StatusEffect effect);

    // Movement events
    [Signal] public delegate void UnitMovedEventHandler(Unit unit, Vector2I fromCell, Vector2I toCell);
    [Signal] public delegate void UnitTeleportedEventHandler(Unit unit, Vector2I toCell);

    // Turn events
    [Signal] public delegate void TurnStartedEventHandler(Unit activeUnit);
    [Signal] public delegate void TurnEndedEventHandler(Unit unit);
    [Signal] public delegate void BattleEndedEventHandler(BattleOutcome outcome);

    public override void _Ready()
    {
        Instance = this;
    }
}

// Usage - emitting
GameEvents.Instance.EmitSignal(GameEvents.SignalName.UnitDamaged, unit, damageResult);

// Usage - subscribing
GameEvents.Instance.UnitDamaged += OnUnitDamaged;

One constraint the sample above depends on: Godot C# signal parameters have to be Variant-compatible. Built-in types, Godot structs like Vector2I, and anything deriving from GodotObject or Resource are fine. A plain C# class is not, and the source generator rejects the [Signal] declaration outright. Payload types like DamageResult or StatusEffect either derive from GodotObject or Resource, or you flatten them into primitives before you emit.

The Signal Bus has a meaningful benefit for testing: in tests, I can connect to the bus to verify that a system emitted the expected event, without needing to have a full scene tree assembled. I can also inject a test double of the bus to verify that specific signals were emitted with specific parameters.

Resource-Based Configuration: The Data-Driven Game Pattern

The most impactful architectural decision I made early in the project was committing to Resource-based configuration for all game content. Every ability, every unit template, every terrain type, every item, and every encounter configuration is a Godot Resource, a serializable data object stored as a .tres file and editable in the Godot inspector.

This means I almost never hard-code game data in scripts. A warrior unit's base stats are not defined in a C# class. They are defined in a UnitDefinition.tres file. A fireball ability's damage formula, range, area of effect, and visual effects are defined in an AbilityDefinition.tres file. When I want to balance the game, I open the resource file in the inspector and change the values. When I want to add a new ability, I duplicate an existing AbilityDefinition resource and modify the properties. No code changes required.

csharp
// AbilityDefinition.cs - A Resource subclass for ability configuration
[GlobalClass]
public partial class AbilityDefinition : Resource
{
    [Export] public string AbilityId { get; set; } = string.Empty;
    [Export] public string DisplayName { get; set; } = string.Empty;
    [Export(PropertyHint.MultilineText)] public string Description { get; set; } = string.Empty;

    [ExportGroup("Cost")]
    [Export] public int ActionPointCost { get; set; } = 1;
    [Export] public int ManaCost { get; set; } = 0;

    [ExportGroup("Targeting")]
    [Export] public TargetingType TargetingType { get; set; } = TargetingType.SingleEnemy;
    [Export] public int Range { get; set; } = 1;
    [Export] public int AreaOfEffect { get; set; } = 0;

    [ExportGroup("Effects")]
    [Export] public AbilityEffect[] Effects { get; set; } = Array.Empty<AbilityEffect>();

    [ExportGroup("Visual")]
    [Export] public PackedScene AnimationScene { get; set; }
    [Export] public AudioStream SoundEffect { get; set; }
}

Scene Composition vs Inheritance: A Hard-Won Lesson

Early in the project, I leaned heavily on scene inheritance. Godot allows you to create a base scene and inherit from it to create variations. I had a BaseUnit scene, and then WarriorUnit, MageUnit, and ArcherUnit inherited from it. This seemed clean at first. It became a maintenance problem quickly.

The problem with inheritance-heavy scene hierarchies is that changes to the base scene propagate to all children in ways that are not always predictable. Adding a new node to the base scene restructures the hierarchy of every inheriting scene. When inheriting scenes have overridden properties, those overrides can conflict with base scene changes in subtle ways. Debugging a behavior that exists in five inheriting scenes but not the base scene is painful.

I refactored to composition: all unit types use the same base Unit scene, and their specific behavior is defined entirely through their UnitDefinition resource and through component nodes added to the scene tree for specific capabilities. A unit that can fly has a FlyingMovementComponent attached. A unit that has a passive ability trigger has a PassiveAbilityComponent attached. The base scene is not subclassed. It is configured.

This is the same principle as component-based design in game engines like Unity, and it is the right principle. Composition over inheritance is not just good advice for object-oriented software. It is especially important in scene-graph-based game development.

C# Namespaces in Godot: Do Not Skip This

Godot C# projects do not enforce namespaces by default. Early in the project, I wrote classes without namespaces, and by the time I had 30-40 scripts, I had naming collision problems. A Unit class, a UnitManager class, a Unit resource class. Without namespaces, these either needed awkward long names or they collided.

I refactored everything to use namespaces that mirror the folder structure. All classes in src/Combat/ are in the TacticalRPG.Combat namespace. All classes in src/Grid/ are in TacticalRPG.Grid. This is standard C# practice but it requires intentional setup in Godot because the engine does not enforce it.

One caveat: namespaces stop at the C# compiler. Godot registers a [GlobalClass] type under its short class name, not its namespaced name, so global Resource classes still need names that are unique across the whole project.

Keeping 334+ Tests Under Control

334+ tests across unit, integration and performance suites sounds like a lot for a game project, and it is uncommon in the indie game space. But at the complexity level of a tactical RPG, it is genuinely necessary. Here is how I organize and maintain them without the test suite becoming a burden.

  • Tests are organized by the domain they test, in a tests/ tree that mirrors src/, not in one flat folder. Finding tests for a specific system is immediate.
  • Test class naming: [SystemName]Tests.cs. Method naming: [MethodName]_[Scenario]_[ExpectedOutcome]. This makes test failure messages self-documenting.
  • Tests are tagged by category: Unit, Integration, Performance. I rerun the Unit category from the Rider test runner while I work, Integration tests before committing, and Performance tests periodically.
  • Shared test fixtures and factories are in a TestUtilities/ directory that both unit and integration tests can use. A GridMapFactory that builds small test grids is a typical example.
  • I never test Godot node behavior in unit tests. Those cover pure C# logic only, under xUnit, with no engine process. Node behavior is tested through GdUnit4 integration tests that instantiate a minimal scene. This keeps the unit suite fast.

The Performance Consideration Nobody Mentions

C# in Godot 4 uses the .NET runtime, which means garbage collection is a reality you have to manage for performance-sensitive game logic. In a tactical RPG, the most GC-sensitive operations are pathfinding (which allocates intermediate collections) and the combat resolution pipeline (which creates many short-lived objects during a single resolution).

My approach: profile before optimizing, and use object pooling only where profiling shows it is necessary. Godot's built-in profiler does not profile C# scripts, so finding GC pressure hotspots takes .NET tooling; the Godot docs point to JetBrains Rider and dotTrace. For pathfinding specifically, I pre-allocate the open and closed sets and clear them between calls rather than allocating new collections on each pathfinding request. For damage result objects that are created and discarded rapidly, I use a simple pool.

The tactical RPG turn structure actually helps here: most complex calculations happen during transition moments (when a turn begins, when an action resolves), not during every frame. I am not doing pathfinding for 50 units 60 times per second. I am doing it once when a player selects a unit, and once per enemy unit during the AI turn. The performance budget is generous, and the GC pressure is manageable with modest attention.

Architecture is the set of decisions that become expensive to change. In a game project, the expensive decisions are the ones that touch your data model and your event system. Get those right early, and the rest is iteration.

Lessons for Other Solo Devs Scaling a Godot Project

  • Set up namespaces on day one. Retrofitting them later is tedious and error-prone.
  • Use typed Resources for all game content from the start. The inspector integration is worth the upfront setup cost.
  • Establish the Signal Bus before you have more than five or six systems. Adding it later requires touching many existing scripts.
  • Write tests for deterministic logic from the start. Adding tests to existing untested code is significantly harder than writing tests alongside new code.
  • Prefer composition over inheritance in scene design. You will refactor this anyway. Better to start with the right pattern.
  • Domain-organized folders beat technical-category folders at any non-trivial scale.
  • Make the Autoload list as short as possible. Every Autoload is a global dependency that makes testing harder.

Managing a large Godot project solo is a systems design problem as much as a game design problem. The patterns I have described are not exotic. They are standard software engineering practices applied to a game development context. Coming from a professional software background, I had a significant advantage in knowing these patterns. If you are coming from a game development background and the software engineering concepts are new, investing time in understanding dependency management, event-driven design, and data-driven configuration will pay dividends that compound over the entire lifetime of your project.

References

GodotC#Game DevelopmentArchitecture