Composition in Godot

Hi! I’m having a problem. I’ve just started experimenting with components in Godot and don’t understand how to use properties with multipliers.
Right now, I have a component like this; it determines the experience gain radius:

class_name ExpCollectorComponent
extends Area2D


@onready var exp_collector_area: CollisionShape2D = %ExpCollectorArea

var current_exp_collection_radius: float
var max_exp_collection_radius: float

func set_stats(radius: float, max_radius: float) -> void:
	current_exp_collection_radius = radius
	max_exp_collection_radius = max_radius
	
	set_exp_collection_area_radius()

func set_exp_collection_area_radius() -> void:
	exp_collector_area.shape.radius = current_exp_collection_radius

I also have a resource containing my character’s stats, which includes variables for the base experience gain radius and the multiplier for that radius. That is, if in the game I, for example, take an upgrade that increases my experience gain radius by 20%, then the multiplier becomes 1.2 and the radius itself should change and become 20% larger:

class_name PlayerBasicStatsData
extends Resource


@export var collection_exp_radius: float = 80.0
@export var max_collection_exp_radius: float = 1000.0
@export var collection_exp_radius_multiply: float = 1.0

And now I just can’t figure out where to do these calculations—something like current_exp_collection_radius * collection_exp_radius_multiply?
Also, considering that I’d like to make this component as independent as possible. That is, it could be used, for example, by a player who has an experience collection radius multiplier, and by an NPC who might not have that multiplier.
Please let me know where this needs to be calculated and how to do it correctly.

i think you’re overthinking it a little, composition is a tool, and it’s a tool not used to make your game perfect, rather to make already entangled code cleaner by removing repetitions

composition works on simple basis, you have a executer that executes code outside the main code, like this:


@export var component: Component

func get_xp():
	var xp = component.get_xp_radius(...)
    do_something()
    return xp

see, you use component in this case to do the math, because let’s say you have 2 classes that aren’t related that need this math

BUT! if you don’t need to remove code repetition, don’t use composition, you can just place all of this inside a function

So there are two ways to apply percentage bonuses. The first is to add them. The second is to multiply them.

So, taking a bonus of 10% and 20%, let’s see the differences.

Adding Percentage Bonuses

First Bonus

  • Your starting bonus is 100%.
  • You gain a bonus multiplier of 10%.
  • 1.00 + 0.10 = 1.10
  • Your new bonus is 110%.

Second Bonus

  • Your bonus is 110%.
  • You gain a bonus multiplier of 20%.
  • 1.10 + 0.20 = 1.30
  • Your new bonus is 130%

Multiplying Percentage Bonuses

First Bonus

  • Your starting bonus is 100%.
  • You gain a bonus multiplier of 10%.
  • 1.00 * 0.10 = 0.10 or %10
  • 1.00 + 0.10 = 1.10
  • Your new bonus is 110%. (Same result as above.)

Second Bonus

  • Your bonus is 110%.
  • You gain a bonus multiplier of 20%.
  • 1.10 * 0.20 = 0.22 or 22%
  • 1.10 + 0.22 = 1.32
  • Your new bonus is 132% (Slightly higher than above.)

Which approach are you wanting to take?

If you are adding, you need to track the total in a separate value. If you are multiplying, you can just do the math.

As for where to track it, track it in the component. It shouldn’t exist at all in the PlayerBasicStatsData object, because you are factoring it out of there and placing it into a component.

Having said that, naming something “Component” is a surefire way to mess up your thinking when trying to figure these things out. So let’s change the name from ExpCollectorComponent to ExperienceCollector. Now, it’s very clear that this thing’s job is to handle collecting expereience.

class_name ExperienceCollector extends Area2D

@export var base_radius: float = 80.0
@export var max_radius: float = 1000.0
@export var radius_multiplier: float = 1.0:
	set(value):
		radius_multiplier = value
		exp_collector_area.shape.radius = clampf(base_radius * radius_multiplier, 0.0, max_radius)

@onready var exp_collector_area: CollisionShape2D = %ExpCollectorArea

Whenever you change the radius_multiplier, the radius gets set, but never above the max_radius or below 0.0. You don’t need a separate value to store that information. It’s already stored in the Shape.

All three of those values should come out of PlayerBasicStatsData, because you don’t need them.

A note on naming: When you are naming classes, functions and variables, think about how you will use them. ExpCollectorComponent.max_exp_collection_radius is very verbose. ExperienceCollector.max_radius communicates the same information, but is easier to read. Naming is very important, not just for readability, but can also affect the way you think about the functionality of a class/object.

My next suggestions would be to consider storing experience points in the ExperienceCollector and consider naming it just Experience.

class_name Experience extends Area2D

@export var base_radius: float = 80.0
@export var max_radius: float = 1000.0
@export var radius_multiplier: float = 1.0:
	set(value):
		radius_multiplier = value
		exp_collector_area.shape.radius = clampf(base_radius * radius_multiplier, 0.0, max_radius)

@onready var exp_collector_area: CollisionShape2D = %ExpCollectorArea

var points: float = 0.0

Then you can reference it in your Player:

class_name Player extends CharacterBody2D

@onready var experience: Experience = %Experience

func _ready() -> void:
	print(experience.points)

It appears you have conceptualized the exp collector as something that the player uses as an appendage, and the player stats as a per-character definition (as in: each character starts with their own defaults for collector radius). Following this model, you can do:

## on player script
var collector: ExpCollectorComponent
var stats: PlayerBasicStatsData
var radius_multiplier: float = 1

func on_exp_boost_collected(boost_rate: float):
  radius_multiplier += boost_rate
  var upper_limit = stats.max_collection_exp_radius
  var boosted_radius = radius_multiplier * stats.collection_exp_radius
  var capped_boosted_radius = min(boosted_radius, stats.max_collection_exp_radius)
  collector_component.set_stats(min(stats.collection_exp_radius * ), stats.max_collection_exp_radius)

The player character is what decides the final radius because each different character sets its corresponding exp collector component’s current radius and its upper limit.
The collector doesn’t need to know about multipliers or upper limits. It just checks for exp in its area and reports to the player character.

Except that following that model is tightly coupling the component to the Player. Because without the component, the Player is calculating all this information it doesn’t need to be calculating.

Without the component, the player just doesn’t run the exp boost method.
In fact, players who are not supposed to have an EXP magnet field can simply use a script that does not have the collector callback method or the collector variables.

I must have misunderstood the point here. Maybe OP wants optional hotswappable chunks of behaviour on each character?
Like that ECS thing people have been talking about?

Pseudocode i shouldn't have spent 40 minutes writing
# i never did stuff this way, don't use this as reference

# Thing that holds data and behavior
class Character extends Node3D:
# all the little goblins that operate on the thing's data 
  var all_the_components: Dictionary = {}
# all the bits of data that the thing has
  var all_the_behaviours: Dictionary = {}


# Most common feature of a playable character
class Health_data extends Object:
  static const COMPONENT_NAME = "Health_data"
  var HP: float = 0
  var alive: bool = false


# Basic XP counting state
class XP_stats extends Object:
  static const COMPONENT_NAME = "XP_stats"
  var XP: int = 0
  var xp_boost: float = 0
  
# XP collector state
class XP_collector_features extends Object:
  static const COMPONENT_NAME = "XP_collector_features"
  var XP_collection_radius: float = 0
  var max_radius_cap: float = 0
 
# the XP collector behaviour
class XP_collector extends Object:
  const behavior_name = "XP_collector"
 
  const requires: array[String] = [
  XP_stats.COMPONENT_NAME, 
  XP_collector_features.COMPONENT_NAME]

  #the thing that hosts components used by this behaviour
  var host

  func _init(new_host):
    host = new_host

  func do_your_thing():
    var xp = host.all_the_components[XP_stats.COMPONENT_NAME]
    var collector = host.all_the_components[XP_collector_features.COMPONENT_NAME]
    # don't ask me how to actually do this
    if _check_for_nearby_XP_orbs():
        _attract_XP_orbs()
    for element in _get_XP_collision_list():
      xp.XP += element.XP_worth * (1 + xp.xp_boost)
    
# ..........

func assemble_character_with_XP_collector():
  var character = Character.new()
  character.all_the_components = {"Health_data": Health_data.new(),
  "XP_stats": XP_stats.new(),
  "XP_Collector_features": XP_Collector_features.new()}
  character.all_the_behaviors = {"XP_Collector": XP_collector.new(character)}
  #step exactly once just to say i did something
  for behavior in character.all_the_behaviors:
    behavior.do_your_thing()

I’m going to answer your question with a really long post that I already wrote:

Your assumptions about components are common. But the idea is they’re like nodes. You add them, they add functionality. Take this scene:

This Enemy is a CharacterBody2D. It has four nodes attached to it. Those nodes are completely separate and have their own responsibilities. The Enemy doesn’t need any of them. But the Sprite2D allows you to see it in the game. The CollisionShape2D attached to it keeps it from falling through the floor or passing through walls. The DetectionArea checks for the Player. The Enemy script handles what happens when the Player is detected, but that’s custom code.

Part of this depends on what you see the point of the component you are adding. Which is why I made a big deal about naming. Calling the node ExperienceCollector is different from calling is ExperienceDetector. Likewise, calling it Experience would mean something different.

The first just collects the XP, the second just detects it, and the third does both and also stores it. It depends on how you want to build your components. With the current stat system OP has, your pseudocode makes sense. But as you can see, it’s needlessly complex. Which is why I suggested moving it all into an Experience component.

Your pseudocode turns the Player into a ComponentManager, which is an anti-pattern. The Player * need to know the XP value to do calculations, but that’s it. It doesn’t need to know how it is collected, how far away it collects it from, or any other implementation details. Just like our CharacterBody2D doesn’t need to know what Shape its CollisionShape2D actually is. Is it a circle, rectangle, capsule? Doesn’t matter. It just needs to know if it collides.

I agree that a player or any other entity shouldn’t need to know how certain values are calculated. I understand your example with the `class_name Experience`.

At first, I did exactly the same thing, calculating the final experience gain radius directly in the “experience gain component.”

It worked for me both there and in the player’s MovementComponent, where I calculated the final speed value.

You sent a link to a good example where the Health component is created, and that’s where my problems began.

I want to use this component for both the player and the enemy. However, enemies don’t have maximum health multipliers, while the player does. An enemy cannot increase its maximum health at runtime, but the player can.

So simply adding something like `max_up_multiply` won’t work—enemies simply don’t have it.

So my idea was to possibly create a separate component where the final values for all stats would be calculated and sent to the appropriate systems.

How correct would this be from an architectural standpoint? And would it be possible to notify components of value changes without direct references between them? As I understand it, this would have to be done through the player, which would already be an anti-pattern?

This is where Inheritance comes in. So here’s a basic Health component from a game I am working on. I use it for all my Enemy objects.

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

signal damaged
signal healed
signal zeroed


## Maximum Health
@export var maximum: float = 1.0: set = _set_maximum
## Current Health
@export var current: float = 1.0: set = _set_current


func damage(amount: float) -> void:
	current -= amount


func heal(amount: float) -> void:
	current += amount


func full_heal() -> void:
	current = maximum


func increase_max(amount: float) -> void:
	maximum += amount


func _set_maximum(value: float) -> void:
	maximum = value
	current = maximum


func _set_current(value: float) -> void:
	if value <= 0:
		zeroed.emit()
		value = 0
	elif current > value:
		damaged.emit()
	elif current < value:
		healed.emit()
	current = value

And then this is a PlayerHealth node. It handles things that enemy health does not need to, such as playing low health sounds when the Player’s health is low, and saving the Player’s health between sessions. But it inherits from Health and has all the same functionality.

@tool
class_name PlayerHealth extends Health

@export var low_health_audio_stream_player: AudioStreamPlayer
@export var low_health_sound: AudioStream
@export var really_low_health_sound: AudioStream


func _ready() -> void:
	get_parent().ready.connect(_on_ready)


# Makes the hearts in the HUD appear.
func _on_ready() -> void:
	if not Engine.is_editor_hint():
		Game.player_max_health_changed.emit(maximum)
		Game.player_health_changed.emit(current)


# Save player health.
func save_node() -> Dictionary:
	var save_data: Dictionary = {
		"current": current,
		"maximum": maximum,
	}
	
	return save_data


# Load player health.
func load_node(save_data: Dictionary) -> void:
	if save_data:
		var loaded_maximum: float = save_data["maximum"]
		if loaded_maximum:
			maximum = loaded_maximum
		var loaded_current: float = save_data["current"]
		if loaded_current:
			current = loaded_current


func _set_maximum(value: float) -> void:
	super(value)
	#var temp = maximum
	if not Engine.is_editor_hint():
		Game.player_max_health_changed.emit(maximum)


func _set_current(value: float) -> void:
	super(value)
	#var temp = current
	if not Engine.is_editor_hint():
		Game.player_health_changed.emit(current)
	
	if low_health_audio_stream_player:
		if current < maximum * 0.166:
			low_health_audio_stream_player.set_stream(really_low_health_sound)
			low_health_audio_stream_player.play()
		elif current < maximum * 0.33:
			if not (low_health_audio_stream_player.stream == low_health_sound and low_health_audio_stream_player.playing == true):
				low_health_audio_stream_player.set_stream(low_health_sound)
				low_health_audio_stream_player.play()
		elif current <= 0:
			low_health_audio_stream_player.stop()
		else:
			low_health_audio_stream_player.stop()