Am I doing Components/composition right?

Godot Version

4.5.1

Question

Hi! Trying to implement my own component system for quickly prototyping lots of different items and other prefabs within my game. I currently have a basic prefab that looks like this

extends CharacterBody2D
# Component manager, assumed always present
@onready var components: Components = $Components

func _ready() -> void:
	# Add component as a child, store reference to it in "health"
	var health = components.add_component("health")
	# Configure component
	health.max = 300
	health.regen_rate = 10

	var hunger = components.add_component("hunger")
	hunger.max = 100
	hunger.drain_rate = 5

Wasn’t sure what the best way to do this was, this is what I came up with as a newbie.
Things I was thinking of doing:

  • Move the component manager into a parent class, so I can inherit the component manager instead of defining it every time.
  • Define a class_name for each component individually, so I can get auto-completion/intellisense for component class members.

Apologies if this post doesn’t make any sense. I have trust issues with my skill whenever I learn new things. Would really appreciate the help.

What the Component manager does?
If I ever take on building a component system I’ll attempt to implement components as child nodes, just so I could add, inspect and configure them in the editor.

So Godot itself is a system built on the idea of components. A Node is a component. A CharacterBody2D is a component. The CollisionShape2D that you have to attach to the CharacterBody2D is a component.

Now imagine that to use ANY of those nodes, you had to go through a ComponentManager object. Imagine how frustrating and complicated that would be.

Componentization is the process of splitting a larger system into smaller re-usable pieces that can be re-used. In object-oriented programming, it’s creating something that you can add to an object to give it new functionality.

Initial Design

You’ve got the concept of a health component and a hunger component. So let’s look at your game concept. I’m assuming that you have a player and enemies and they will both have health. I’m going to guess that while you want to track hunger for your player, you are not going to be tracking it for your enemies.

With that information, I’m going to say that health makes sense as a component. If you add it to players and enemies, they can die. If you don’t add it to NPCs, players (and enemies) cannot accidentally kill them and create a soft-lock bug in your game.

But hunger only affects the player. What do you get by creating a hunger component? Is anyone else going to use it? No. However, by creating it as a component, you encapsulate the code. All the hunger code will be in your component.

Implementation Design

So we could create a ComponentManager to handle all components, but really we don’t need that. Think of the Godot editor as your component manager. It allows you to add them and track them.

You can make components pure code, or since this is Godot, you can make them Nodes. I recommend Nodes because then your component manager (the Godot engine) has a way to display them and even create them.

Naming

As Nodes, they can be hung off of anything to give it Health or Hunger functionality. So we can name them as such. Just as CharacterBody2D isn’t named CharacterBody2DComponent, we don’t need to give components names like HealthComponent.

Communication

Another thing to think about is how these nodes communicate. When a player gets attacked, how do we know if it can take damage? There are many ways of dealing with this. @Exported variables is one. Either attaching the player as a variable of the component, or the component as a variable of the player. However, both of these break the encapsulation principle of Object-Oriented Programming.

Instead, we can just search an object to see if it has a component, and interact with it if it does, and not if it doesn’t.

Likewise, when the component needs to communicate, it can do so through signals. (More on that later.)

Making a Health Component

So here’s a look at a possible Health component node. First, create a script named health.gd. It does not need to be attached to a scene.

@icon("res://assets/textures/icons/heart-svgrepo-com.svg")
## A Health Component to add to players and enemies.
class_name Health extends Node

signal death

@export var health: int = 1:
	set(value):
		health = value
		if health <= 0:
			death.emit()


func damage(amount: int) -> void:
	health -= amount

You can download the heart icon here. You’ll want to change the icon path in the code if you store it somewhere other than res://assets/textures/icons/. (You made need to reload your project to see the icon in the editor.)

Then, you can select your CharacterBody2D and click the Add Node (+) button. And search for Health.

Voila! We now have our re-usable component that can be added to any object in the game, like so:

And this is what it looks like in the inspector:

Using the Health Component

So we have a health component attached to the player. Right now, we just want to know if the player dies and queue_free() the object. So in our player code:

@onready var health: Health = $Health


func _ready() -> void:
	health.death.connect(_on_death)


func _on_death() -> void:
	queue_free()

Then we need a way to damage them so in our enemy or enemy weapon object:

@export var weapon_damage = 1

func _on_hit(body: Node2D) -> void:
	var target_health = body.get_node_or_null("Health") as Health
	if target_health:
		target_health.damage(weapon_damage)

Making a Hunger Component

The Hunger component follows the same pattern, but is self-contained in that as time passes, the player gets more and more hungry. As the hunger increases it emits hungry then starving then death signals.

@icon("res://assets/textures/icons/poultry-leg-svgrepo-com.svg")
## A Hunger Component to add to the player.
class_name Hunger extends Node

signal hungry
signal starving
signal death

## The amount hunger is decreased everyt time the hunger timer expires.
@export var hunger_amount: float = 5.0
## How long the hunger timer runs before expiring
@export var hunger_time: float = 10.0


var hunger: float = 100.0:
	set(value):
		hunger = value
		if hunger <= 0:
			death.emit()
		elif hunger <= 25.0:
			starving.emit()
		elif hunger <= 50.0:
			hungry.emit()
var hunger_timer: Timer


func _ready() -> void:
	hunger_timer = Timer.new()
	add_child(hunger_timer)
	hunger_timer.start(hunger_time)
	hunger_timer.timeout.connect(_on_hunger_timeout)


func _on_hunger_timeout() -> void:
	hunger -= hunger_amount
	if hunger >= 0:
		hunger_timer.start(hunger_time)

You can download the hunger icon here. You’ll want to change the icon path in the code if you store it somewhere other than res://assets/textures/icons/. (You made need to reload your project to see the icon in the editor.)

Again, we can add the component to anything:

Even if it’s just the player:

And we can change how fast the hunger goes down:

I will leave the exercise of hooking up the signals to the reader, as it’s the same as the Health component.

Conclusion

There’s lots of ways to do components. Personally I find them overkill unless the component is more complex, like you’re dealing with 5 elemental damage types and resistances, etc. But in those cases it’s really helpful.

Another implementation is to use Resource objects. Create a base Stat object, an @export array to hold them, and then use the the code inside them that way. IMO the Node implementation in Godot makes the most sense because of how Godot is structured.

14 Likes

I learned a lot from thispost. Thank you.

2 Likes

I like it, I do something similar with “survival” stats for my characters.
Instead of using drain_rate and regen_rate, you can just do change_rate or something and just set a negative value to drain. A little simpler that way.
You can pass a callable that will be called when they’re completly depleted. Something like:

func _ready() -> void:
	var health = components.add_component("health")
	health.max = 300
	health.change_rate = 10
	health.on_depleted = die

	var hunger = components.add_component("hunger")
	hunger.max = 100
	hunger.change_rate = -5
	hunger.on_depleted = func() -> void: health.change_rate = -10

func die() -> void:
	# stuff that happens when character dies...

Just some tips…

4 Likes

Thanks for the amazing explanation of this system! The initially did this in script as I learned to code by modding other games, where I generally only had access to scripts and no other tools, but given the amount of work using nodes seems to save, I think I’ll do it like that then!

Signals seem easy enough to use, though Callables make more sense to me in general. I’ve seen the convention “Call Down, Signal Up” around the forum, but I don’t exactly get that part. Should I be calling functions on components (child nodes here), and signalling other data up (such as hunger delta)?

1 Like

The call down, signal up method is what I used. You’ll notice that the damage function is called by whatever is damaging it, but the signals are listened to upstream by the player.

The idea of call down/signal up, is smaller objects shouldn’t need to know what they are a party of. If the health component for example, was calling the die() function on the player, it would then need to know how to check if the player had a die function. It’s not important to health to know what happens after it reaches zero, just that it’s significant.

2 Likes

Got it! Thanks for being so helpful, this was the perfect amount of explanation! The style of breakdown you did was perfect for my brain.

1 Like

Glad to help.

TBH it was just a fun little thing for me to do this morning. I threw in the icons and stuff just because I thought it’d be helpful to see a full (if simple) implementation.

2 Likes

That’s probably the best explanation of nodes-as-components I’ve seen. Thanks!

2 Likes

Thanks. Ironically, I’m not a fan of componentizing things like health because I prefer inheritance for something like that. But it’s popular and I think it’s helpful for people to understand.

1 Like