Overview
Tested with: Godot 4.3+
When building games in Godot, implementing everything through inheritance leads to deep hierarchies that make code reuse and extension difficult. Design patterns provide flexible, maintainable architecture.
This article introduces three design patterns that are especially useful in Godot.
Overview of the Three Patterns
With inheritance-based design, adding features deepens class hierarchies—"moving enemy," "flying enemy," "moving and flying enemy"—and combinations explode. The three patterns in this article each solve this problem from a different angle.
| Pattern | In a Nutshell | When to Use |
|---|---|---|
| Composition | Combine functionality as modular parts | Reuse movement, attack, health across multiple characters |
| Decorator | Layer functionality onto existing objects | Dynamically modify stats with equipment and buffs |
| Factory | Centralize creation logic | Spawn enemies/items with type-specific configurations |
Composition — "Has-a" Design
Design around the relationship "a character has movement capability." Build movement, attack, health, and other features as independent Nodes or Resources, then combine them as needed. This pattern works especially well with Godot's scene tree.
Decorator — "Wrap and Extend"
Wrap the original object to add functionality on top. A sword adds +5 attack, then a buff adds +3—you can stack as many layers as you want. To unequip, simply unwrap a layer to restore the previous state.
Factory — "Centralized Creation"
Consolidate creation rules like "goblins get 50 HP and 100 speed, orcs get 100 HP and 80 speed" into a single class. Creation logic stays in one place, and adding a new enemy type is just a matter of adding one more configuration entry.
Composition Pattern
Let's dive into each pattern in detail, starting with Composition -- the one that fits Godot most naturally.
Godot's scene tree is built around the idea of "combining small parts to create something bigger." The Composition pattern aligns perfectly with this philosophy, making it the most straightforward pattern to adopt.
Problem
When implementing movement for both a player and an enemy, inheritance produces a hierarchy like this:
Character (base class)
+-- Player
+-- Enemy
However, expressing differences like "different movement speeds" or "different input methods" through inheritance leads to class explosion.
Solution: Component-Based Design
By extracting movement logic into an independent component, you create a design that can be reused for both players and enemies.
The key is separating "data (how fast?)" from "logic (how to move?)." In the following code, MovementStats holds the data as a Resource, while MovementComponent handles the processing as a Node.
# movement_stats.gd (Resource)
class_name MovementStats
extends Resource
@export var max_speed: float = 200.0
@export var acceleration: float = 800.0
@export var friction: float = 600.0
# movement_input.gd (Node)
class_name MovementInput
extends Node
func get_input_direction() -> Vector2:
# Override in subclasses
return Vector2.ZERO
# player_input.gd
class_name PlayerInput
extends MovementInput
func get_input_direction() -> Vector2:
return Input.get_vector("ui_left", "ui_right", "ui_up", "ui_down")
# movement_component.gd
class_name MovementComponent
extends Node
@export var stats: MovementStats
@onready var input: MovementInput = $"../PlayerInput" # Get from scene tree
func update_movement(actor: CharacterBody2D, delta: float):
var direction = input.get_input_direction()
if direction != Vector2.ZERO:
actor.velocity = actor.velocity.move_toward(
direction * stats.max_speed,
stats.acceleration * delta
)
else:
actor.velocity = actor.velocity.move_toward(
Vector2.ZERO,
stats.friction * delta
)
actor.move_and_slide()
# player.gd
extends CharacterBody2D
@onready var movement = $MovementComponent
func _physics_process(delta):
movement.update_movement(self, delta)
tips: While you can use
@exportto set Node references, using@onreadyto fetch from the scene tree is more idiomatic in Godot. Resource references (likeMovementStats) should use@export.
Benefits:
- Movement logic is easily reusable
- Tunable via
MovementStatsresources - Swap
inputto implement AI movement
Decorator Pattern
While Composition is about "assembling parts together," the Decorator is about "wrapping layers on top of something that already exists." Equip a sword for +5 attack, then cast a buff spell for +3 more -- the Decorator pattern lets you implement this kind of stacking without creating additional classes.
Problem
You want to apply equipment and buff modifiers to player stats. Using inheritance would create an explosion of classes: "base player," "sword-equipped player," "sword+shield player," etc.
Solution: Decorator
The Decorator pattern wraps the original object to add functionality. We start with a base interface class, then stack decorator layers on top of it.
# player_stats.gd (interface)
class_name PlayerStats
extends RefCounted
func get_attack() -> int:
return 0
func get_defense() -> int:
return 0
# base_player_stats.gd
class_name BasePlayerStats
extends PlayerStats
var base_attack: int = 10
var base_defense: int = 5
func get_attack() -> int:
return base_attack
func get_defense() -> int:
return base_defense
# stats_decorator.gd (decorator base class)
class_name StatsDecorator
extends PlayerStats
var wrapped_stats: PlayerStats
func _init(stats: PlayerStats):
wrapped_stats = stats
func get_attack() -> int:
return wrapped_stats.get_attack()
func get_defense() -> int:
return wrapped_stats.get_defense()
# attack_boost_decorator.gd
class_name AttackBoostDecorator
extends StatsDecorator
var bonus_attack: int
func _init(stats: PlayerStats, bonus: int):
super(stats)
bonus_attack = bonus
func get_attack() -> int:
return wrapped_stats.get_attack() + bonus_attack
# Usage example
var stats = BasePlayerStats.new()
print(stats.get_attack()) # 10
# Equip a sword (attack +5)
stats = AttackBoostDecorator.new(stats, 5)
print(stats.get_attack()) # 15
# Apply a buff (attack +3)
stats = AttackBoostDecorator.new(stats, 3)
print(stats.get_attack()) # 18
Removing Decorators (Unequipping)
Adding layers is only half the story -- removing them matters just as much. When the player unequips gear or a buff's duration expires, you need to strip off the corresponding decorator. This is done by unwrapping one level via wrapped_stats.
# Unequip: remove the last applied decorator
func unwrap(current_stats: PlayerStats) -> PlayerStats:
if current_stats is StatsDecorator:
return current_stats.wrapped_stats
return current_stats # If not a decorator, return as-is
# Usage example
stats = unwrap(stats)
print(stats.get_attack()) # 15 (buff removed, sword remains)
Benefits:
- Add or remove functionality at runtime
- Flexible equipment and buff combinations
- Extend without modifying the base class
Factory Pattern
Last up is the Factory pattern. Have you ever found yourself writing enemy spawn logic in multiple places across your codebase, then having to update every single one when you add a new enemy type? The Factory pattern solves this by consolidating all creation logic into one place.
Problem
When different enemy types require different setup, creation code ends up scattered everywhere.
# Anti-pattern
if enemy_type == "goblin":
var enemy = load("res://enemies/goblin.tscn").instantiate()
enemy.health = 50
enemy.speed = 100
elif enemy_type == "orc":
var enemy = load("res://enemies/orc.tscn").instantiate()
enemy.health = 100
enemy.speed = 80
Solution: Factory Class
Let's consolidate the creation logic into a dedicated class. Scene paths and initial configuration values are managed in dictionaries, so adding a new enemy type is as simple as adding one more dictionary entry.
# enemy_factory.gd
class_name EnemyFactory
extends Node
enum EnemyType { GOBLIN, ORC, DRAGON }
const ENEMY_SCENES = {
EnemyType.GOBLIN: preload("res://enemies/goblin.tscn"),
EnemyType.ORC: preload("res://enemies/orc.tscn"),
EnemyType.DRAGON: preload("res://enemies/dragon.tscn"),
}
const ENEMY_CONFIGS = {
EnemyType.GOBLIN: { "health": 50, "speed": 100 },
EnemyType.ORC: { "health": 100, "speed": 80 },
EnemyType.DRAGON: { "health": 300, "speed": 50 },
}
func create_enemy(type: EnemyType, position: Vector2) -> Node2D:
var scene = ENEMY_SCENES.get(type)
if not scene:
push_error("Unknown enemy type: %s" % type)
return null
var enemy = scene.instantiate()
var config = ENEMY_CONFIGS[type]
enemy.global_position = position
enemy.health = config["health"]
enemy.speed = config["speed"]
return enemy
# Usage example
@onready var factory = $EnemyFactory
func spawn_enemies():
var goblin = factory.create_enemy(EnemyFactory.EnemyType.GOBLIN, Vector2(100, 100))
add_child(goblin)
var orc = factory.create_enemy(EnemyFactory.EnemyType.ORC, Vector2(200, 100))
add_child(orc)
Benefits:
- Centralized creation logic
- Easy to add new enemy types
- Easier to write tests
tips: Using
const+preload()loads all scenes into memory at startup. If you're registering many scenes, consider lazy loading withload()or asynchronous loading withResourceLoader.load_threaded_request().
Pattern Selection Guide
Now that we've covered all three patterns, you might wonder which one to reach for in practice. Use this table as a decision-making guide.
| Pattern | Use Case | Benefits |
|---|---|---|
| Composition | Combining modular functionality | Flexible feature combinations, reusability |
| Decorator | Adding functionality at runtime | Dynamic additions, multi-layered decoration |
| Factory | Complex object creation | Centralized creation logic, extensibility |
Combining patterns:
- Create enemies with Factory -> assemble behavior with Composition -> apply buffs with Decorator
Other Patterns that Work Well with Godot
Godot's design incorporates several design patterns as built-in features.
| Pattern | Godot Implementation | Typical Use Case |
|---|---|---|
| Observer | Built-in signal system | Event notifications, UI updates |
| State | match + Enum / State Machine | Character state management |
| Singleton | Autoload | Game-wide data management |
Godot's signal system is the Observer pattern itself, providing loosely-coupled event notifications via signal declarations and connect(). For State pattern implementation, see the State Machine article.
Summary
- Composition builds functionality by combining components instead of inheritance
- Decorator dynamically adds functionality to existing objects
- Factory centralizes object creation logic
- Each pattern works well alone or in combination
- Godot's Nodes and Resources make these patterns easy to implement
- Avoid over-engineering; refactor when the need arises
- Other useful patterns: Observer (signals), State, Singleton (Autoload)