May 14, 2026 · 5 min read
How I Structure Godot Projects: Components, State Machines, and Test Scenes
- Godot
- Game Dev
- Architecture
- Testing
Game projects rot in a specific way: everything works while the game is small, then one day the player script is 800 lines, every object knows about every other object, and adding a feature means re-testing the whole game by hand. Godot's scene system is genuinely great at preventing this — but only if you organize the project around composition from day one. Here's the structure I use, with my current farm game as the running example.
The folder layout
farm-game/
├── asset/ # raw art, fonts, audio — organized by domain
│ ├── game/ # characters, objects, tilesets
│ └── ui/
├── scenes/
│ ├── characters/ # player/, chicken/, cow/ — scene + script + states together
│ ├── components/ # reusable behavior scenes (the good stuff)
│ ├── objects/ # trees/, rocks/, plants/corn/, plants/tomato/
│ ├── houses/
│ ├── ui/
│ └── test/ # one runnable test scene per feature
└── scripts/
├── globals/ # autoload singletons + shared enums
└── state_machine/ # generic NodeStateMachine + NodeState baseTwo rules drive this layout. First: things that change together live together. The player folder holds player.tscn, player.gd, and one script per state — idle, walk, tilling, watering, chopping. When I touch player behavior, everything is in one place. Second: scripts/ is only for genuinely cross-cutting code. If a script belongs to one scene, it sits next to that scene, not in a global scripts dump.
Components, not inheritance
The heart of the structure is scenes/components/: small, single-purpose scenes that give any entity a capability by being added as a child node. My farm game currently has eight — among them HurtComponent, HitComponent, DamageComponent, CollectableComponent, InteractableComponent, and GrowthCycleComponent. Each is a few dozen lines at most. Here's HurtComponent in its entirety:
class_name HurtComponent
extends Area2D
@export var tool: DataTypes.Tools = DataTypes.Tools.None
signal hurt
func _on_area_entered(area):
var hit_component = area as HitComponent
if tool == hit_component.current_tool:
hurt.emit(hit_component.hit_damage)The pattern: @export variables for per-instance configuration in the inspector, signals as the only output, and no knowledge of what owns it. A tree doesn't inherit from a Damageable base class — it composes HurtComponent (tuned to respond to the axe) and DamageComponent (tracking hit points), and wires the signals together in a few lines:
# small_tree.gd
func _ready() -> void:
hurt_component.hurt.connect(on_hurt)
damage_component.max_damage_reached.connect(on_max_damage_reached)
func on_hurt(hit_damage: int) -> void:
damage_component.apply_damage(hit_damage)
func on_max_damage_reached() -> void:
call_deferred("add_log_scene") # tree becomes collectable logs
queue_free()Making rocks mineable after trees were choppable took minutes: the same components, exported to respond to the pickaxe instead of the axe. That's the payoff of composition — the Nth feature costs less than the first, instead of more.
State machines as nodes
Character logic uses the same composition idea. A generic NodeStateMachine (about 50 lines, in scripts/state_machine/) discovers its NodeState children at runtime, runs the current state's process and physics callbacks, and handles transitions. Each concrete state is its own small script living in the entity's folder: the chicken has idle and walk; the player adds tilling, watering, and chopping.
Because states are nodes, adding a behavior means adding a child scene and a script — not extending a switch statement in an ever-growing update loop. And because the machine prints every transition with its parent's name, watching a cow wander a navmesh is self-documenting in the output console.
Test scenes are the test suite
This is the part I'd defend hardest. scenes/test/ holds sixteen scenes, one per feature: test_scene_player, test_scene_crops, test_scene_npc_navigation, test_scene_inventory_management, test_scene_day_and_night_cycle, and so on. Each contains the minimum world needed to exercise one system — a player and some tilled dirt, a chicken and a navigation region. Press F6 and you're running exactly the feature you're working on.
- Iteration speed: no clicking through the farm to reach the thing you changed. The crop test scene boots straight into a field.
- Regression checks: when the inventory refactor lands, running three small scenes tells me what broke, feature by feature.
- Honest design pressure: a component you can't drop into an empty test scene is a component with hidden dependencies. The test folder finds coupling before it hardens.
There are proper unit-test frameworks for Godot (GUT, gdUnit4), and for gameplay math they're worth it. But for a solo or small-team 2D project, a runnable scene per feature delivers most of the value at none of the ceremony — and it only works because the components are genuinely self-contained. Testability isn't something you add; it falls out of the composition.
Autoloads: few and boring
The farm game has exactly three autoload singletons — ToolManager, InventoryManager, DayAndNightCycleManager — plus a DataTypes script holding shared enums. The bar for adding one is high: it must be state that genuinely spans the whole game. Everything else stays local to its scene.
The managers communicate outward by signal, which keeps them decoupled from their consumers. GrowthCycleComponent connects to the day-cycle manager's time_tick_day signal and advances crops when a watered day passes; the manager has no idea crops exist. And shared enums like DataTypes.Tools are the contract that lets HitComponent and HurtComponent agree on what an axe is without ever referencing each other's scenes.
Small config, big readability
- Name your physics layers (Ground, Player, Interactable, Tool, Collectable, NPC) — collision matrices stop being a guessing game.
- Define input actions (walk_up, hit, remove_dirt) instead of hard-coding keys; remapping and gamepad support come almost free.
- Use folder colors in the editor. Trivial, but scenes/ in green and scripts/ in pink means never hunting for the right pane.
- Set pixel-art projects to integer scaling and nearest-neighbor filtering up front, before art decisions bake in the wrong assumptions.
The shape of it
None of this is clever, and that's the point. Entities are folders. Capabilities are component scenes with exports in and signals out. Behavior is a state machine made of nodes. Every feature has a scene that runs it in isolation, and global state is three boring managers. It's the same discipline I push in enterprise work — small units, explicit contracts, testable in isolation — it just happens to be more fun when the unit under test is a chicken.
If you can't drop it into an empty test scene and run it, it isn't a component yet — it's a dependency you haven't met.