DEV Community

saltmire
saltmire

Posted on Originally published at saltmire.github.io

What I learned building a health component with i-frames in Godot 4

Every Godot 4 project I start ends up with var health := 100 pasted into the player, then
the enemy, then the breakable crate. Then the three copies drift apart. Here is what I
learned pulling health out into one reusable component, roughly in the order each bug
showed up.

Lesson 1: health is not the player's job

The first version always lives inside the player script:

var health := 100

func take_damage(amount: int) -> void:
    health -= amount
    if health <= 0:
        die()
Enter fullscreen mode Exit fullscreen mode

That's fine until an enemy needs the same logic, then a destructible barrel, then a boss
with a health bar. Once there are three copies, one gets clamping, one gets a signal for
the UI, and one gets neither. The fix is a small node you can drop into any scene:

class_name HealthComponent
extends Node

signal health_changed(current: int, max_health: int)
signal died

@export var max_health := 100
var current := max_health

func _ready() -> void:
    current = max_health

func damage(amount: int) -> void:
    current = clampi(current - amount, 0, max_health)
    health_changed.emit(current, max_health)
    if current == 0:
        died.emit()

func heal(amount: int) -> void:
    current = clampi(current + amount, 0, max_health)
    health_changed.emit(current, max_health)
Enter fullscreen mode Exit fullscreen mode

The owner (player, enemy, crate) connects to died and decides what death means. The
component only counts. That split turned out to matter more than any of the code.

Lesson 2: the health bar should never ask for health

My early UI code read player.health every frame in _process. That works, but it ties
the health bar to one specific node and breaks the moment the player is freed and
respawned. With the signal, the bar just listens:

func bind(health: HealthComponent) -> void:
    health.health_changed.connect(_on_health_changed)
    _on_health_changed(health.current, health.max_health)

func _on_health_changed(current: int, max_health: int) -> void:
    value = float(current) / max_health * 100.0
Enter fullscreen mode Exit fullscreen mode

Calling the handler once inside bind() is the part I kept forgetting. Without it the bar
shows 100% until the first hit, even if the scene loaded with the player at half health.

Lesson 3: the double-hit bug is what i-frames actually fix

I thought invincibility frames were a design choice, something that makes the game feel
fairer. In practice they fixed a real bug first. A spike hitbox overlapping the player
for 12 physics frames dealt damage 12 times, and a single sword swing could hit twice
when the enemy's hurtbox entered, left, and re-entered the area during the animation.

A timer inside the component fixes both at the source:

@export var iframe_duration := 0.5
var _invulnerable := false

func damage(amount: int) -> void:
    if _invulnerable or current == 0:
        return
    current = clampi(current - amount, 0, max_health)
    health_changed.emit(current, max_health)
    if current == 0:
        died.emit()
        return
    if iframe_duration > 0.0:
        _invulnerable = true
        get_tree().create_timer(iframe_duration).timeout.connect(
            func(): _invulnerable = false)
Enter fullscreen mode Exit fullscreen mode

Enemies in a horde game usually want iframe_duration = 0.0 (or something very short)
so fast weapons still feel fast. The player usually wants 0.5–1.0 seconds. Making it an
export instead of hard-coding it was what let one component work for both.

Lesson 4: "died" must fire exactly once

This bug wasted an evening. Two projectiles hit an enemy on the same physics frame. Both
called damage(), both saw current == 0 afterwards, and died fired twice. The enemy
dropped two coins, the kill counter went up by two, and the second queue_free() threw
an error on an instance that was already queued.

That's why the check at the top of damage() has or current == 0. Once health reaches
zero, the component ignores everything after it. It's one condition, and it's also the
only place that condition needs to exist. Anything listening to died can assume it runs
once per life.

If you add a respawn, give it an explicit reset instead of healing from zero:

func reset() -> void:
    current = max_health
    _invulnerable = false
    health_changed.emit(current, max_health)
Enter fullscreen mode Exit fullscreen mode

heal() on a dead entity was the other source of zombies. I made healing from 0 do
nothing, and revival only happens on purpose.

Lesson 5: flash the sprite from the owner, not the component

The component should not know there's a sprite. When I put modulate changes inside it,
it broke immediately on the crate, which used a different node structure. Now the owner
reacts to the signal it already has:

func _ready() -> void:
    $HealthComponent.health_changed.connect(func(_c, _m): _flash())

func _flash() -> void:
    var t := create_tween()
    $Sprite2D.modulate = Color(1, 0.4, 0.4)
    t.tween_property($Sprite2D, "modulate", Color.WHITE, 0.15)
Enter fullscreen mode Exit fullscreen mode

What I'd do differently starting over

Build the component first, before the second thing that can take damage exists. Put
i-frames and the "dead means dead" guard in from day one, because both bugs show up the
first time two hits land close together. And keep the component dumb: it counts, emits,
and refuses. Everything visual belongs to whoever owns it.

The part this post skips is the other half: who is allowed to hit whom. That's
collision layers, teams, making sure the player's own sword doesn't hurt the player, and
knockback direction. That's the wiring I got tired of redoing in every project.


If you'd rather drop this in than build it, Saltmire Hitbox does it as a ready-made tool: https://saltmire.itch.io/saltmire-hitbox

Originally published at https://saltmire.github.io/godot-4-health-component-iframes.html

Top comments (0)