Health System
A health and damage system for Godot 4.7. You add a HealthComponent to your actor. Everything else is set up as resources in the inspector: armor, resistances, loot drops, invincibility frames.
A goblin with armor, a loot drop and a despawn on death needs no GDScript at all.
HealthComponent extends Node, not Node2D or Node3D. The same profiles work in a platformer and in a first-person shooter. Only the two hurtbox wrappers contain 2D or 3D specific code.
Installation
- Copy
addons/health-system/into your project - Go to Project → Project Settings → Plugins and enable
health-system
An enemy in 60 seconds
Build the scene. A CharacterBody2D as the root, with a HealthComponent underneath:
Goblin (CharacterBody2D)
|-- Sprite2D
|-- CollisionShape2D
|-- HealthComponent
|-- Hurtbox2D
|-- CollisionShape2D
The Hurtbox2D finds the HealthComponent on its own, as long as the two are siblings.
Create a profile. Right-click in the FileSystem, pick New Resource, choose HealthProfile and save it as goblin.tres:
| Field | Value |
|---|---|
max_health |
30 |
default_modifiers |
one ArmorModifier with armor = 3 |
actions |
one SpawnSceneAction (scene = coin.tscn, spawn_count = 2) and one QueueFreeAction |
Assign it. Drag goblin.tres into the health_profile field of the HealthComponent.
That is the whole setup. The goblin has 30 HP, takes 3 less damage per hit, drops two coins when it dies and removes itself from the tree. Every other enemy is another .tres file, not another script.
To deal damage from game code:
var health := HealthComponent.find(goblin)
var result := health.damage(10.0, self, &"fire")
print(result.final_amount) # 7.0, the armor took 3 off
You can also do it without any code, through the hurtbox. See Modules.
Concepts
HealthComponent
This is the only node you always need. It stores the health value, runs the modifier pipeline and sends the signals.
@export var health_profile: HealthProfile
@export var max_health: float = 100.0
var current_health: float
var is_alive: bool
The methods you will use most:
health.damage(25.0, attacker, &"fire") # -> DamageResult
health.heal(10.0, potion, &"potion") # -> DamageResult
health.kill()
health.revive(50.0) # percent of max_health
health.get_health_percent() # 0.0 .. 100.0
damage() and heal() are shortcuts. If you need to pass more data along, like a hit position for particles or your own metadata, build the info object yourself:
var info := DamageInfo.new(25.0, attacker, &"fire")
info.position = hit_position
info.metadata["critical"] = true
var result := health.apply_damage(info)
Both calls return a DamageResult:
| Field | Meaning |
|---|---|
final_amount |
what was actually subtracted after all modifiers |
blocked_amount |
how much less that is than the incoming value, so armor, resistance and shield together |
overkill |
damage dealt past 0 HP |
was_lethal |
this hit killed |
was_blocked |
nothing got through |
HealthComponent.find(node) searches a node's children for the component. That is the usual way to get from a bullet to its target.
Profiles
A HealthProfile is a .tres file that describes one type of actor. It has five fields:
@export var max_health: float = 100.0
@export var start_health_percent: float = 100.0
@export var thresholds: Array[float] = []
@export var default_modifiers: Array[HealthModifier] = []
@export var actions: Array[HealthAction] = []
thresholds are percentage marks. When health crosses one of them in either direction, the component emits threshold_crossed. So a boss with [25.0] tells you itself when its enrage phase starts.
Modifiers
A HealthModifier sits between the incoming damage and the health value and can change the number. All modifiers on an actor run one after another, sorted by priority, lowest first.
These come with the addon:
| Class | Priority | Effect |
|---|---|---|
ArmorModifier |
20 | flat reduction, never below min_damage |
ResistanceModifier |
50 | multiplier per damage type, 0.0 for immunity, 2.0 for a weakness |
DamageCapModifier |
80 | limit per hit, either flat or as a percentage of max health |
ShieldAbsorbModifier |
85 | takes from the shield pool, managed by ShieldComponent |
BlockAllDamageModifier |
90 | sets the hit to 0 |
You can attach one at runtime, with or without a duration:
var shout := ArmorModifier.new()
shout.armor = 10.0
health.add_modifier(shout, 5.0) # armor buff for five seconds
health.remove_modifier(shout) # or take it off yourself
Without a duration the modifier stays until someone removes it. Adding the same instance twice does nothing. Two separate instances do stack.
The priority convention
The numbers are a convention. Nothing in the code enforces them. They exist so that modifiers from different sources end up in a sensible order:
| Range | For | Reason |
|---|---|---|
| 20 | flat reductions | they should run before anything multiplies |
| 50 | multipliers | resistances, weaknesses, vulnerability windows |
| 80 | limits | they work on the finished number |
| 85 | shield | absorbs what would otherwise hit the health |
| 90 | hard blocks | runs last |
The order changes the result. (40 - 5) * 0.5 = 17.5, but 40 * 0.5 - 5 = 15. Which number the player sees depends on the priority.
Actions
Modifiers change numbers. Actions react to events. You put them in the profile or add them at runtime, and they replace the signal wiring you would otherwise write in an enemy script.
There are five triggers, shown as a dropdown in the inspector:
| Trigger | Context |
|---|---|
ON_DAMAGED |
{"info": DamageInfo, "result": DamageResult} |
ON_HEALED |
{"info": HealInfo, "result": DamageResult} |
ON_DEATH |
{"info": DamageInfo} |
ON_THRESHOLD |
{"threshold": float, "going_down": bool} |
ON_REVIVE |
{} |
These come with the addon:
QueueFreeActionremoves the actor on death. It has an optionaldelayso a death animation can finish first.SpawnSceneActioncreatessceneas a sibling of the actor,spawn_counttimes. If the actor is a Node2D or Node3D, matching instances get its position.ApplyInvincibilityActiongives i-frames after a hit, using the taginvincibleand a block that runs out on its own.
Tags
Tags are how the health system talks to the rest of your game. They are counted, so if two sources set stunned, only removing the second one emits tag_removed.
health.add_tag(&"burning")
health.has_tag(&"burning") # true
health.get_tags() # Array[StringName]
health.remove_tag(&"burning")
That means the blinking during i-frames needs to know nothing about modifiers:
func _ready() -> void:
health.tag_added.connect(func(tag): if tag == &"invincible": _start_blink())
health.tag_removed.connect(func(tag): if tag == &"invincible": _stop_blink())
Modules
These nodes are optional. Whatever you do not put in the tree does not exist at runtime.
ShieldComponent is a separate pool that soaks up damage before it reaches the health value. Put it next to the HealthComponent as a sibling. It hooks into the pipeline through a modifier at priority 85, so the core knows nothing about it.
signal shield_changed(old_value: float, new_value: float)
signal shield_broken
signal shield_restored
@export var max_shield: float = 50.0
shield.restore_shield(20.0)
shield.break_shield()
RegenModule heals over time:
@export var regen_per_second: float = 5.0
@export var delay_after_damage: float = 0.0
@export var tick_interval: float = 0.5
It heals through the normal heal() API, so regeneration goes through the modifier pipeline like everything else. A modifier that halves healing also halves regeneration. The heal type is &"regen" if you want to treat it separately.
Hurtbox2D and Hurtbox3D turn area collisions into damage. The attacking side stays your code. All that is standardised is how a hit arrives.
You can do it without writing anything. Give the lava node an entry under Node → Metadata called damage with the value 100, and if you want, damage_type with "fire". When the player walks in, the hurtbox reads the metadata and calls damage().
Or from code:
hurtbox.receive_damage(100.0, self, &"fire")
receive_damage returns the DamageResult. If it cannot find a HealthComponent, it returns null and prints a warning.
Writing your own modifier
Anything that changes a damage or heal number is a modifier. Four steps.
1. Create the file and extend HealthModifier.
class_name GlassCannonModifier
extends HealthModifier
## Below a health threshold, every hit lands harder.
2. Add the settings as exports. Whatever you put here is what a designer fills in inside the .tres file.
@export var threshold_percent: float = 30.0
@export var multiplier: float = 2.0
3. Set the priority. Multipliers belong around 50, see the convention above.
func _init() -> void:
priority = 50
4. Override modify_damage(). The second parameter is the HealthComponent. That is what it is for, when your modifier needs to know the state of the actor.
func modify_damage(info: DamageInfo, health: HealthComponent) -> void:
if health.get_health_percent() > threshold_percent:
return
info.amount *= multiplier
For healing there is modify_heal(info: HealInfo, health: HealthComponent). Both are optional, so override only what you need.
When you are done, list it under default_modifiers in a .tres file, or attach it at runtime.
Writing your own action
Anything that reacts to an event instead of changing a number.
1. Create the file and extend HealthAction.
class_name GrantExperienceAction
extends HealthAction
## Awards experience to whoever landed the killing blow.
2. Add the settings as exports.
@export var experience: int = 10
3. Set a default trigger. It stays editable in the inspector.
func _init() -> void:
trigger = Trigger.ON_DEATH
4. Override execute(). The context is a Dictionary. Which keys it holds depends on the trigger, see the table above.
func execute(_health: HealthComponent, context: Dictionary) -> void:
var info: DamageInfo = context["info"]
if info.source and info.source.has_method("grant_experience"):
info.source.grant_experience(experience)
When you are done, list it under actions in a .tres file, or call health.add_action(action) at runtime.
Signals
All of these are on the HealthComponent:
| Signal | Arguments | When |
|---|---|---|
health_changed |
old_value: float, new_value: float |
on any change to the health value, including at startup |
damaged |
info: DamageInfo, result: DamageResult |
after every hit, even one that was blocked completely |
healed |
info: HealInfo, result: DamageResult |
after every heal |
died |
info: DamageInfo |
once per death |
revived |
none | after revive() |
threshold_crossed |
threshold: float, going_down: bool |
when health crosses a profile mark in either direction |
tag_added |
tag: StringName |
when a tag is set for the first time |
tag_removed |
tag: StringName |
when the last source gives it up |
modifier_added |
modifier: HealthModifier |
on add_modifier() |
modifier_removed |
modifier: HealthModifier |
on remove_modifier() and when a duration runs out |
action_added |
action: HealthAction |
on add_action() |
action_removed |
action: HealthAction |
on remove_action() |
On the ShieldComponent:
| Signal | Arguments | When |
|---|---|---|
shield_changed |
old_value: float, new_value: float |
on any change, including at startup |
shield_broken |
none | when the shield drops to 0 |
shield_restored |
none | when it is full again |
Tests
The suite runs on gdUnit4 and covers every public method and signal.
godot --headless --path . -s -d --remote-debug tcp://127.0.0.1:0 \
res://addons/gdUnit4/bin/GdUnitCmdTool.gd --ignoreHeadlessMode -c -a res://tests/
License
MIT, Copyright (c) 2026 John Söllner. The full text is in LICENSE.
The editor icons in addons/health-system/icons/ come from the @icons set by Valentin Fossati (Voxybuns), which is MIT as well. Its license sits unchanged in the same folder and covers those SVG files. The icons are copied into the addon instead of referenced, so you do not need the icon plugin installed.
How AI was used here
I build this addon to learn Godot and to get better at architecture. So I write the source code by hand. The component layout, the modifier pipeline, the priority model, the action system and the modules are mine, both the design and the typing. An assistant that hands me a finished file would defeat the point.
I did use AI in three places.
Tests: The gdUnit4 suites are written by AI, based on the acceptance criteria in my own tickets. The loop was the same every time. I hand over the ticket, I get tests back, I run them, I look at what turned red. It found a good number of real bugs that I would otherwise have run into in the game.
Comments and documentation: The doc comments and this README. Writing is not my strong side, and a reference implementation that nobody can follow is not much of a reference.
Commit messages: it is just easier and helps others understand my changes :3
CI/CD.
Changelog for version v1.0.0
No changelog provided for this version.