In this episode we dissect a mature Godot project architecture: ECS-inspired patterns for entity management, signal-based decoupled systems, an event bus as global messaging, plugin-based modular scenes, and game mechanics reusable across projects.

In episode 17, you learned to package the project to Android, iOS, and desktop, complete with input adjustments and performance tuning for each platform. Now, when your game starts shipping to many devices, one question arrives sooner than you'd think: how can this project survive as features keep growing?
A single 800-line script still feels "safe" in the first week. After three months, every change risks breaking something you don't remember ever touching. Episode 18 answers this through architecture: ECS-inspired patterns for entity management, systems decoupled through signals, an event bus as global messaging, modular scenes, and mechanics designed for reuse.
Godot follows composition over inheritance — where in classic OOP you derive classes, in Godot you compose nodes. A giant scene holding everything, from movement to the score UI, is a main source of headaches. The solution: break it into small components, each with a single responsibility.
Take a player as an example. Instead of one node with a 500-line script, build a hierarchy like this:
Player (CharacterBody2D)
├── Health (HealthComponent)
├── Movement (MovementComponent)
├── Visual
│ └── Sprite2D
└── CollisionShape2DThe main script only coordinates components, not handles all the logic:
extends CharacterBody2D
@export var move_speed: float = 220.0
@onready var health: HealthComponent = $Health
@onready var visual: Sprite2D = $Visual/Sprite2D
func _physics_process(delta: float) -> void:
var direction := Input.get_axis("move_left", "move_right")
velocity.x = direction * move_speed
move_and_slide()The key here is @export — values that can be changed from the editor without touching code. The HealthComponent can be attached to a player, an enemy, or even a horse; just set the max_health value in the inspector. That's why components keep a project small even as mechanics grow.
Pure ECS (Entity-Component-System) is rarely the choice in Godot because the engine itself is already node-based. But its core principle — entities are data, behavior comes from components — can be imitated with the node component pattern. An entity is an empty node, components are child nodes carrying small data and behaviors.
For entities that appear continuously, like spawned enemies, use object pooling. Destroying and creating nodes constantly triggers memory allocations that risk stutter:
extends Node
var pool: Array[Node] = []
var enemy_scene: PackedScene = preload("res://enemies/enemy.tscn")
func spawn() -> Node:
for item in pool:
if not item.is_inside_tree():
return item
var enemy := enemy_scene.instantiate()
pool.append(enemy)
add_child(enemy)
return enemyDead entities are hidden, not destroyed. On the next spawn, that entity is reused from the pool. The result: zero allocations at the busiest moments, and smooth gameplay even on phones.
Signals are Godot's unifying language between nodes. Their power: the sender doesn't need to know who the receiver is. An enemy just shouts died when its health runs out; whether it's the score UI, the audio manager, or a quest system listening — none of them know each other.
extends CharacterBody2D
signal died
@export var max_health: int = 3
var health: int
func _ready() -> void:
health = max_health
func take_damage(amount: int) -> void:
health -= amount
if health <= 0:
died.emit()On the other side, the system adding score just connects the died signal to its own method:
extends Node
func _ready() -> void:
var enemy := get_tree().get_first_node_in_group("enemies")
enemy.died.connect(_on_enemy_died)
func _on_enemy_died() -> void:
ScoreManager.add(10)Notice that ScoreManager is never called directly from the enemy. If tomorrow an enemy adds two lives when it dies, we just connect the signal to another system — without changing a single line in enemy.gd.
Sometimes per-node signals aren't enough — some events are global: game over, score changes, level switches. The solution is an event bus: a simple autoload containing only signals, registered via Project Settings > Autoload.
extends Node
signal score_changed(points: int)
signal player_hurt(health: int, maximum: int)
signal game_overNow any system can send messages to the bus:
func _on_enemy_died() -> void:
EventBus.score_changed.emit(10)And other systems listen at a single point:
func _ready() -> void:
EventBus.score_changed.connect(_on_score_changed)
func _on_score_changed(points: int) -> void:
score_label.text = "Skor: %d" % ScoreManager.totalThis bus isn't a dumpster for every signal — only for cross-system events. If two nodes are always paired (like a health bar and a health component), connect them directly without the bus. This small policy prevents the bus from turning into a giant spaghetti monster.
Info
Name signals to indicate a past event (past tense) like died, score_changed, level_loaded. This small habit makes code readers immediately understand that a signal marks something that has happened, not a command to be executed.
A good mechanic isn't a feature of one game, but a system that can be moved to other games. The most powerful pattern in Godot: feature scene + exported configuration. Make the mechanic a standalone scene, then configure it through the inspector.
An example interaction system usable anywhere:
extends Area2D
@export var prompt: String = "Press E"
func _ready() -> void:
body_entered.connect(func(_body): show_prompt())
body_exited.connect(func(_body): hide_prompt())With @export, the prompt can be changed per instance. This scene is stored in the systems/ folder and instanced onto doors, switches, or items. "Pickup", "damage zone", and "checkpoint" mechanics all follow the same pattern. After a few projects, you'll have a library of systems that just get reassembled like Lego — that's the real power of good architecture.
The plugin-based scenes pattern goes further: besides reusable scenes, Godot has a plugin (addon) mechanism that loads a full feature with one click — from custom inspectors to editor panels. Make your mechanic a standalone scene in the addons/ folder, and that feature can be enabled or disabled per project without changing core code. The refactoring policy is consistent: map one responsibility per code block, move it to a node component, connect it with signals, then test again. Do one mechanic per work session — not all at once.
Episode 18 equipped you with four pillars of Godot architecture: modular scenes that break monoliths into components, the ECS-inspired pattern with object pooling for entity management, decoupled systems through signals, an event bus for global messaging, and feature systems reusable across projects.
The key takeaways:
@export so configuration lives in the inspector.@export configuration, and move mature ones to the addons/ folder for reuse in other projects.Good architecture doesn't make a game finish faster this week — it makes your project survive to release and beyond. In the next episode, episode 19, we move from writing code to Operational Readiness: build pipelines for release, QA and regression testing, packaging and distribution strategies, and analytics and crash reporting. See you there!