LearnAI ToolsCareerPractice BuildsPlayDevelopmentContact
GDScriptIntermediate~1.5 hours

Reusable Health System

Build a reusable HealthComponent class with custom signals, a property setter, invincibility frames and a health bar UI.

class_nameCustom SignalsSetters@export

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.

What You'll Build
  • A HealthComponent node registered with class_name so it appears in the Add Node dialog.
  • Four custom signals with typed parameters.
  • A health property with a setter that clamps the value and emits signals automatically.
  • take_damage() and heal() methods, plus invincibility frames using a SceneTreeTimer.
  • A test scene where keys deal damage and heal, a ProgressBar health bar, and a red damage flash.

Prerequisites

  • Functions and lifecycle callbacks — lesson 7.
  • Signals, including custom signals with parameters — lesson 8.
  • Classes, class_name and inheritance — lesson 9.
  • @export variables 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.gd

health_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.

Pasting Code into Godot

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 HealthComponent
extends 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

Step 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_health

Step 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 = false

Step 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

health_component.gd
class_name HealthComponent
extends 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 = false
var _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 = false

The 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.

test_dummy.gd
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 out

Testing the Game

Run test_dummy.tscn with F6 (Run Current Scene) and press the keys in this order while watching the Output panel:

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: int that 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 StyleBoxFlat override.
  • Add the component to the Coin Collector player and create a spike Area2D that calls take_damage().
  • Create a Hurtbox Area2D component that finds a sibling HealthComponent and applies damage from Hitbox areas.

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.