Overview
Almost every game needs health: the player, enemies, destructible crates, bosses. Copying health code into every script quickly becomes a mess. A better approach — used widely in professional Godot projects — is a small reusable "component" node that you add as a child of anything that can be damaged.
Your HealthComponent registers itself as a global class with class_name, exposes its settings to the Inspector with @export, clamps health with a property setter, and reports what happens through custom signals: health_changed, damaged, healed and died. The UI and the rest of the game simply listen — the component never needs to know who is listening.
- A
HealthComponentnode registered withclass_nameso it appears in the Add Node dialog. - Four custom signals with typed parameters.
- A
healthproperty with a setter that clamps the value and emits signals automatically. take_damage()andheal()methods, plus invincibility frames using aSceneTreeTimer.- A test scene where keys deal damage and heal, a
ProgressBarhealth bar, and a red damage flash.
Prerequisites
- Functions and lifecycle callbacks — lesson 7.
- Signals, including custom signals with parameters — lesson 8.
- Classes,
class_nameand inheritance — lesson 9. @exportvariables and the Inspector — lesson 11.
Project Structure
res://├── health_component.gd # reusable class: HealthComponent├── test_dummy.tscn # a test scene that uses the component└── test_dummy.gdhealth_component.gd is not attached to a scene of its own; because it declares class_name HealthComponent, you can add it to any scene from the Add Child Node dialog like a built-in node. test_dummy.gd plays the role of "the game": it calls take_damage() and heal() and connects UI to the component's signals.
The code on this page is indented with spaces. If Godot reports "Mixed use of tabs and spaces for indentation" after you paste, use the script editor's Edit > Indentation > Convert Indent to Tabs.
Step 1: A Named Class with Signals
class_name HealthComponent registers a global type. Other scripts can then use it in type hints (var hp: HealthComponent) and checks (if node is HealthComponent). Signals with typed parameters document exactly what listeners receive.
class_name HealthComponentextends Node
signal health_changed(current: int, maximum: int)signal damaged(amount: int)signal healed(amount: int)signal died
@export var max_health: int = 100@export var invincibility_time: float = 0.5Step 2: A Clamped Health Setter
In GDScript 2.0 a property can declare a set(value) block that runs every time the property is assigned — even from inside the class. Here it clamps the value between 0 and max_health, ignores assignments that change nothing, emits health_changed, and emits died exactly once when health first reaches zero. died is emitted with call_deferred(), which delays it until the end of the current frame: take_damage() changes health before it emits damaged, so without the delay listeners would hear "defeated" before they hear about the hit that caused it.
var is_dead: bool = false
var health: int = 0: set(value): var clamped := clampi(value, 0, max_health) if clamped == health: return health = clamped health_changed.emit(health, max_health) if health == 0 and not is_dead: is_dead = true died.emit.call_deferred() # after this hit's damaged signal
func _ready() -> void: health = max_healthStep 3: Damage, Healing & Invincibility
After taking damage the component ignores further hits for invincibility_time seconds. get_tree().create_timer() returns a one-shot SceneTreeTimer; await-ing its timeout signal pauses only this function, not the game. Both methods return the amount actually applied, so callers know if a hit was blocked.
var _invincible: bool = false
func take_damage(amount: int) -> int: if is_dead or _invincible or amount <= 0: return 0 var before := health health -= amount var dealt := before - health damaged.emit(dealt) if invincibility_time > 0.0 and not is_dead: _start_invincibility() return dealt
func heal(amount: int) -> int: if is_dead or amount <= 0: return 0 var before := health health += amount var restored := health - before if restored > 0: healed.emit(restored) return restored
func _start_invincibility() -> void: _invincible = true await get_tree().create_timer(invincibility_time).timeout _invincible = falseStep 4: Connect a Health Bar & a Test Scene
Create test_dummy.tscn with a Node2D root named TestDummy. Add a Sprite2D (texture icon.svg) positioned in the middle of the screen, a HealthComponent child (search for it by name in the Add Node dialog), and a CanvasLayer named UI containing a ProgressBar named HealthBar and a Label named InfoLabel.
TestDummy (Node2D) ← test_dummy.gd├── Sprite2D ← Texture: icon.svg, Position: (576, 324)├── HealthComponent ← Max Health: 100, Invincibility Time: 0.5└── UI (CanvasLayer) ├── HealthBar (ProgressBar) ← Size: 300 × 30 └── InfoLabel (Label)Complete Code
class_name HealthComponentextends Node
signal health_changed(current: int, maximum: int)signal damaged(amount: int)signal healed(amount: int)signal died
@export var max_health: int = 100@export var invincibility_time: float = 0.5
var is_dead: bool = falsevar _invincible: bool = false
var health: int = 0: set(value): var clamped := clampi(value, 0, max_health) if clamped == health: return health = clamped health_changed.emit(health, max_health) if health == 0 and not is_dead: is_dead = true died.emit.call_deferred() # after this hit's damaged signal
func _ready() -> void: health = max_health
func take_damage(amount: int) -> int: if is_dead or _invincible or amount <= 0: return 0 var before := health health -= amount var dealt := before - health damaged.emit(dealt) if invincibility_time > 0.0 and not is_dead: _start_invincibility() return dealt
func heal(amount: int) -> int: if is_dead or amount <= 0: return 0 var before := health health += amount var restored := health - before if restored > 0: healed.emit(restored) return restored
func revive() -> void: is_dead = false health = max_health
func _start_invincibility() -> void: _invincible = true await get_tree().create_timer(invincibility_time).timeout _invincible = falseThe test scene connects the UI to the component's signals and maps keys to actions. Child nodes become ready before their parent, so by the time this script's _ready() runs, the component has already set health = max_health — its first health_changed signal fired before anything was listening. That is why the script calls _on_health_changed() once by hand to initialise the bar.
extends Node2D
@onready var health: HealthComponent = $HealthComponent@onready var sprite: Sprite2D = $Sprite2D@onready var health_bar: ProgressBar = $UI/HealthBar@onready var info_label: Label = $UI/InfoLabel
func _ready() -> void: health.health_changed.connect(_on_health_changed) health.damaged.connect(_on_damaged) health.healed.connect(_on_healed) health.died.connect(_on_died) _on_health_changed(health.health, health.max_health) # initialise the bar info_label.text = "Space: hit for 15 H: heal 10 R: revive"
func _unhandled_input(event: InputEvent) -> void: if event.is_action_pressed("ui_accept"): # Space / Enter var dealt := health.take_damage(15) if dealt == 0 and not health.is_dead: print("Blocked - still invincible") elif event is InputEventKey and event.pressed and not event.echo: match event.keycode: KEY_H: health.heal(10) KEY_R: health.revive() sprite.modulate = Color.WHITE
func _on_health_changed(current: int, maximum: int) -> void: health_bar.max_value = maximum health_bar.value = current
func _on_damaged(amount: int) -> void: print("Took %d damage, %d left" % [amount, health.health]) sprite.modulate = Color(1, 0.3, 0.3) # flash red var tween := create_tween() tween.tween_property(sprite, "modulate", Color.WHITE, 0.3)
func _on_healed(amount: int) -> void: print("Healed %d, now %d" % [amount, health.health])
func _on_died() -> void: print("The dummy has been defeated!") sprite.modulate = Color(0.3, 0.3, 0.3) # grey outTesting the Game
Run test_dummy.tscn with F6 (Run Current Scene) and press the keys in this order while watching the Output panel:
Click Run to see what this code prints.
The last hit only deals 10 damage because the setter clamps health at zero, and take_damage() reports the damage actually dealt rather than the damage requested.
Extend This Project
- Add an
@export var armor: intthat reduces incoming damage, with a minimum of 1. - Add health regeneration in
_process()that only starts after a few seconds without damage. - Make the health bar change colour (green → yellow → red) using a
StyleBoxFlatoverride. - Add the component to the Coin Collector player and create a spike
Area2Dthat callstake_damage(). - Create a
HurtboxArea2D component that finds a siblingHealthComponentand applies damage fromHitboxareas.
Summary
You built a reusable, self-contained game component: a class_name global type with @export settings, a property setter that enforces its own rules and emits signals, and timed invincibility using await on a SceneTreeTimer. Composition with small components like this — instead of deep inheritance trees — is one of the most effective ways to organise a growing Godot project.