【Godot】OOP Design Patterns in GDScript (Composition, Decorator, Factory)

Created: 2026-02-08

How to implement three commonly used design patterns in Godot: Composition, Decorator, and Factory. Practical GDScript examples for building flexible, reusable, and extensible designs without relying on deep inheritance.

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.

PatternIn a NutshellWhen to Use
CompositionCombine functionality as modular partsReuse movement, attack, health across multiple characters
DecoratorLayer functionality onto existing objectsDynamically modify stats with equipment and buffs
FactoryCentralize creation logicSpawn 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 @export to set Node references, using @onready to fetch from the scene tree is more idiomatic in Godot. Resource references (like MovementStats) should use @export.

Benefits:

  • Movement logic is easily reusable
  • Tunable via MovementStats resources
  • Swap input to 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 with load() or asynchronous loading with ResourceLoader.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.

PatternUse CaseBenefits
CompositionCombining modular functionalityFlexible feature combinations, reusability
DecoratorAdding functionality at runtimeDynamic additions, multi-layered decoration
FactoryComplex object creationCentralized 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.

PatternGodot ImplementationTypical Use Case
ObserverBuilt-in signal systemEvent notifications, UI updates
Statematch + Enum / State MachineCharacter state management
SingletonAutoloadGame-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)

Further Reading