Just browsed for some videos and spotted this video( https://www.youtube.com/watch?v=e3H_nw4w5U8&t=304s ) it’s beginner tutorial, but first thought its about Compositor but it’s about breaking down code into components and connecting them.
I found it quite interesting, but is it something to recommend for starters ?
Have built it along the video, and it’s quite neat.
What do you think about it to un-spaghetti codes with this approach ?
stone
August 3, 2026, 1:33pm
3
Composition is the preferred architectural approach to add new features into your game.
You could make each component responsible for itself. This means moving the logic from the player class to each component’s respective location.
For example, move input_component.update() into the Input_Component.gd file, similarly for the health and movement components.
1 Like
Definitely - a lot of engines go the composition over inheritance route and it works well with a scene tree.
It comes into its own if you can write independent behaviours as a node that don’t depend too much on its exact parent, as you can then customise entities without extra code. Drop in any enemy, drop in a chasing behaviour as a child, and you’ve got a chasing enemy.
2 Likes
I used to do that. But now I load the scripts like this:
# Controllers
var movement_controller:MovementController
var stats_controller:StatsController
var reaction_controller:ReactionController
func _ready() -> void:
movement_controller = MovementController.new(self)
stats_controller = StatsController.new(self)
reaction_controller = ReactionController.new(self)
Where the scripts are classes like this:
class_name MovementController
extends RefCounted
And I use them like this:
func _process(delta: float) -> void:
movement_controller.update_velocity(delta)
It keeps my scenes free of a lot of node clutter and these become superbly easy to work with as I can override any functions for special cases without entangling the core class code.
I have not encountered any downsides to this at all yet.
Edit:
Some of my enemies have lots of such scripts. Previously they were all on nodes but when I needed hundreds of enemies I was able to handle many more doing it like this. I went from 300 max to nearly 600 before FPS started dropping.
1 Like
That’s nice, not sure why you used .new(self) .
I recommend learning what composition is in broader programming context. You might get a wrong or very limited idea if you learn it just in the context of Godot nodes from a Godot specific tutorial.
Composition is nothing more than a relation between classes in which one class refers to other via an instance (instead for example by inheriting it). There is really no versus between inheritance and composition. You’ll typically use both in a complementary manner in a project.
Composition is one of the key concepts of object-oriented programming languages, like Java.
Est. reading time: 6 minutes
I haven’t looked at the tutorial you linked but if the instructor is naming the component classes using the Component suffix for each, they might not be very experienced. It’s completely redundant, the equivalent of adding the Node suffix to every node class in Godot, e.g. Sprite2DNode, MultiMeshInstance3DNode, etc… You can imagine how tedious this would be.
Note that composition is already widely present in Godot. Much more than inheritance in fact. For example when a Sprite2D class has a Texture2D property, those two classes are in has-a relationship, which by definition is - composition.
1 Like
Nice examples.
What was my essential take from the Composition in sense of building blocks like, Unity like.
The Coffee Machine with internal classes Grinder and BrewingUnit are nice as examples.
So in which scenario is best use Inheritance and in which Composition ?
to make it easier, you making 3D RPG Game solo Player with NPC’s, inventory, quest system, standard block built world.
For different types of NPC use inherited from BasicNPC to Warrior, Mage, Rouge …?
To make Player modular and easy to save state of game into resources use Composition components exposed in Player script?
Well the common rule of thumb that’s often cited is:
composition = has-a relationship
inheritance = is-a relationship
In the semantic and functional organization of your classes, if A is B - it’s inheritance, conversely if A has B, it’s composition. This can of course be misleading in some cases. There will be situations where you can’t be certain about which relation is better fitting. The solution in such cases is to pick one and start building. If later turns out you were wrong - you refactor.
1 Like
That’s not a very good description of composition, at least not in programming context.
1 Like
Enemy script:
extends CharacterBody3D
class_name Enemy
const RUN_VELOCITY_THRESHOLD := 2.0
@export var max_health: float = 20.0
@export var xp_value := 25
@export var crit_rate := 0.05
@export var speed := 5.0
@export var shields: Array[PackedScene]
@export var weapons: Array[PackedScene]
# Get the gravity from the project settings to be synced with RigidBody nodes.
var gravity: float = ProjectSettings.get_setting("physics/3d/default_gravity")
@onready var rig: Node3D = $Rig
@onready var health_component: HealthComponent = $HealthComponent
@onready var collision_shape_3d: CollisionShape3D = $CollisionShape3D
@onready var player_detector: ShapeCast3D = $Rig/PlayerDetector
@onready var area_attack: ShapeCast3D = $Rig/AreaAttack
@onready var navigation_agent_3d: NavigationAgent3D = $NavigationAgent3D
@onready var player: Player = get_tree().get_first_node_in_group("Player")
func _ready() -> void:
rig.set_active_mesh(
rig.villager_meshes.pick_random()
)
rig.replace_shield(
shields.pick_random()
)
rig.replace_weapon(
weapons.pick_random()
)
health_component.update_max_health(max_health)
func _physics_process(delta: float) -> void:
var velocity_target := Vector3.ZERO
navigation_agent_3d.target_position = player.global_position
if rig.is_idle():
check_for_attacks()
if not navigation_agent_3d.is_target_reached():
velocity_target = get_local_navigation_direction() * speed
orient_rig(navigation_agent_3d.get_next_path_position())
# Add the gravity.
if not is_on_floor():
velocity_target.y -= gravity * delta
navigation_agent_3d.velocity = velocity_target
func check_for_attacks() -> void:
for collision_id in player_detector.get_collision_count():
var collider = player_detector.get_collider(collision_id)
if collider is Player:
rig.travel("Overhead")
navigation_agent_3d.avoidance_mask = 0
func _on_health_component_defeat() -> void:
player.stats.xp += xp_value
rig.travel("Defeat")
collision_shape_3d.disabled = true
set_physics_process(false)
navigation_agent_3d.target_position = global_position
navigation_agent_3d.velocity = Vector3.ZERO
func _on_rig_heavy_attack() -> void:
area_attack.deal_damage(20.0, crit_rate)
navigation_agent_3d.avoidance_mask = 1
func orient_rig(target_position: Vector3) -> void:
target_position.y = rig.global_position.y
if rig.global_position.is_equal_approx(target_position):
return
rig.look_at(target_position, Vector3.UP, true)
func get_local_navigation_direction() -> Vector3:
var destination = navigation_agent_3d.get_next_path_position()
var local_destination = destination - global_position
return local_destination.normalized()
func _on_navigation_agent_3d_velocity_computed(safe_velocity: Vector3) -> void:
if safe_velocity.length() > RUN_VELOCITY_THRESHOLD:
rig.run_weight_target = 1.0
else:
rig.run_weight_target = 0.0
velocity = safe_velocity
move_and_slide()
Player script:
extends CharacterBody3D
class_name Player
const JUMP_VELOCITY = 4.5
const DECAY := 8.0
# Get the gravity from the project settings to be synced with RigidBody nodes.
var gravity: float = ProjectSettings.get_setting("physics/3d/default_gravity")
# Stores the x/y direction the player is trying to look in
var _look := Vector2.ZERO
# Stores the direction the player moves when attacking
var _attack_direction := Vector3.ZERO
@export var mouse_sensitivity: float = 0.00075
@export var min_boundary: float = -60
@export var max_boundary: float = 10
@export var animation_decay: float = 20.0
@export var attack_move_speed: float = 3.0
@export_category("RPG Stats")
@export var stats: CharacterStats
@onready var horizontal_pivot: Node3D = $HorizontalPivot
@onready var vertical_pivot: Node3D = $HorizontalPivot/VerticalPivot
@onready var rig_pivot: Node3D = $RigPivot
@onready var rig: Node3D = $RigPivot/Rig
@onready var attack_cast: RayCast3D = %AttackCast
@onready var health_component: HealthComponent = $HealthComponent
@onready var collision_shape_3d: CollisionShape3D = $CollisionShape3D
@onready var area_attack: ShapeCast3D = $RigPivot/AreaAttack
@onready var user_interface: Control = $UserInterface
@onready var interaction_cast: ShapeCast3D = $RigPivot/InteractionCast
func _ready() -> void:
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
health_component.update_max_health(stats.get_max_hp())
stats.level_up_notification.connect(
func(): health_component.update_max_health(stats.get_max_hp())
)
stats.update_stats.connect(user_interface.update_stats_display)
user_interface.update_stats_display()
user_interface.inventory.armor_changed.connect(
health_component.update_armor_value
)
if PersistentData.current_health:
health_component.current_health = PersistentData.current_health
SceneTransition.fade_in()
func _physics_process(delta: float) -> void:
frame_camera_rotation()
var direction := get_movement_direction()
rig.update_animation_tree(direction)
handle_idle_physics_frame(delta, direction)
handle_slashing_physics_frame(delta)
handle_overhead_physics_frame()
interaction_cast.check_interactions()
# Add the gravity.
if not is_on_floor():
velocity.y -= gravity * delta
move_and_slide()
func _unhandled_input(event: InputEvent) -> void:
if event.is_action_pressed("ui_cancel"):
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
if Input.mouse_mode == Input.MOUSE_MODE_CAPTURED:
if event is InputEventMouseMotion:
_look = -event.relative * mouse_sensitivity
if rig.is_idle():
if event.is_action_pressed("click"):
slash_attack()
if event.is_action_pressed("right_click"):
rig.travel("Overhead")
if event.is_action_pressed("debug_gain_xp"):
stats.xp += 10000
func get_movement_direction() -> Vector3:
var input_dir := Input.get_vector("move_left", "move_right", "move_forward", "move_back")
var input_vector := Vector3(input_dir.x, 0, input_dir.y).normalized()
return horizontal_pivot.global_transform.basis * input_vector
func frame_camera_rotation() -> void:
horizontal_pivot.rotate_y(_look.x)
vertical_pivot.rotate_x(_look.y)
vertical_pivot.rotation.x = clampf(
vertical_pivot.rotation.x,
deg_to_rad(min_boundary),
deg_to_rad(max_boundary)
)
_look = Vector2.ZERO
func look_toward_direction(direction: Vector3, delta: float) -> void:
var target_transform := rig_pivot.global_transform.looking_at(
rig_pivot.global_position + direction, Vector3.UP, true
)
rig_pivot.global_transform = rig_pivot.global_transform.interpolate_with(
target_transform,
1.0 - exp(-animation_decay * delta)
)
func slash_attack() -> void:
rig.travel("Slash")
_attack_direction = get_movement_direction()
if _attack_direction.is_zero_approx():
_attack_direction = rig.global_basis * Vector3(0, 0, 1)
attack_cast.clear_exceptions()
func handle_idle_physics_frame(delta: float, direction: Vector3) -> void:
if not rig.is_idle() and not rig.is_dashing():
return
velocity.x = exponential_decay(
velocity.x,
direction.x * stats.get_base_speed(),
DECAY,
delta
)
velocity.z = exponential_decay(
velocity.z,
direction.z * stats.get_base_speed(),
DECAY,
delta
)
if direction:
look_toward_direction(direction, delta)
func handle_slashing_physics_frame(delta: float) -> void:
if not rig.is_slashing():
return
velocity.x = _attack_direction.x * attack_move_speed
velocity.z = _attack_direction.z * attack_move_speed
look_toward_direction(_attack_direction, delta)
attack_cast.deal_damage(user_interface.inventory.get_weapon_value(), stats.get_crit_chance())
func handle_overhead_physics_frame() -> void:
if not rig.is_overhead():
return
velocity.x = 0.0
velocity.z = 0.0
func _on_health_component_defeat() -> void:
rig.travel("Defeat")
collision_shape_3d.disabled = true
set_physics_process(false)
func _on_rig_heavy_attack() -> void:
area_attack.deal_damage(user_interface.inventory.get_weapon_value(), stats.get_crit_chance())
func exponential_decay(a: float, b: float, decay: float, delta: float) -> float:
return b + (a - b) * exp(-decay * delta)
In this example what would you consider as inheritance and composition?
That’s for you to answer.
1 Like
Health component - composition
Rest be inheritance.
What could enemy and player share in common?
Hitbox and Hurtbox, scene inheritance is interesting
1 Like
@artemisia
To trigger in the init function in the class script.
var creature:Node2D
func _init(creature_node:Node2D) -> void:
creature = creature_node
In this example the self tells the class what creature it belongs to.
creature.change_state(creature.State.IDLE)
So I can reuse it on any creature. (You can pass any variables to the init function.)
1 Like