Help with creating modular health system

Godot Version

4.3

Question

I am trying to add a modular health system to my 2D platformer and there’s a few things I’m unsure on that I was hoping I could get clarity on.

I want to create a health component that I can attach to both the player and enemies in my game. However I need the health to persist on the player between level changes, etc. Additionally when the health changes on player I want these changes to reflect in the HUD.

I was considering creating a resource class for persistent player data. In which case should my player script be responsible for connecting to signals in health component and updating the resource class accordingly?

As for the HUD, should it be the responsibility of the player to update it whenever the health component is changed? Or should the HUD automatically connect via signals contained in health component (meaning the HUD will be dependent on child health component of player) ?

All in all I’m trying to create a modular health system that doesn’t have a lot of dependencies and I’m mostly wondering the responsibility each piece should have in my health system.
If y’all could suggest a solution that would be awesome and greatly appreciated!

People always talk about creating as little dependencies as possible, but for game dev specifically, this is quite difficult.
You are better of not abstracting everything away, that said let’s look into your problem.

You want to ask yourself: what does a health system need to do?

  • Does it just hold a current value and a maximum?
  • How do you want to take damage or heal?
  • Do you need healing, or damage over time?
  • Do you need to support some upgrades to this health system, like max health increase or heal speed or heal amount increases?
  • What other components does your health system rely on?

Now go over each of these, and think about some more options your system might need.
And then look at a decent way to implement each one.

Most health systems indeed have a current and max value.
So that will be 2 variables or 1 variable and a constant.

For taking damage, the easiest way is to make a function that other nodes can call. But how do you know notify the HUD of the damage? You can make the health component look for a parent with a specific method, but just writing that sounds awfull, so instead use a signal.

Like they say for godot, call down and signal up the hierarchy.

Then there is the damage over time effect, you can create some method for that inside the health component, that takes in an damage amount, time between damage, and total time, etc. Or you could simply call entity.health.damage(amount) instead multiple times, and have the thing that is giving you the DOT keep track of how many or how long you need to do that for.
Both have their pros and cons.

I hope I gave you some tips on how to think though these types of questions.

Let me know if you need some more help, or if you want me to directly anwser your question.

2 Likes

This episode has an excellent example of component architecture.

1 Like

Thanks I’ll take a look!

Thanks very much for the reply! For now I’m planning to keep my health system simple and just have a way for taking damage and healing but that’s it. But I’d like to build it efficiently so that I can develop it further if I need to in future games.

But if I wanted to persist the health stats on my player but none of the rest of my entities, while using the same health component on all of them, what’s a good way of going about that?

I was thinking of having a resource class that I hold the health variables and corresponding signals and then my health component script uses this resource class directly. However with this way I’d need some sort of export bool variable to determine if these values should persist. If this bool is false then I’d need a way to reset my resource class. This approach should work but it seems awkward to me.

What about an autoload for the health of the player? When you take damage or heal, you send out a signal and then the player receives that signal.
When that happens you have the player update the autoload. Finnaly when you change scenes, you ask the autoload what the value is and set it in the health component.

to define any component for any entity just make a resource of class_name 'Health"
with dictionary
static var users:={}
and then add its values and sub properties to it like:

users[player]:={
	&'health_max'=100,
	&'current'=80,
	&'movement_speed_slow_threshold'=20,}

and when you need to modify since it class_name:

Health.users[player][&'current']+=10

with checks

static func kill(target):
	users.erase(target)

static func test_health(user)->void:
	if Health.users[user][&'current']<=0: Health.kill(user)

with this you need only one static class to manage all users of comp

you may even use _set() with _get_property_list() to make possible of player.health.current+=10

Yes that’s something I could do too, instead of having a resource class it’d be an autoload. Would an autoload be better in this case?

I haven’t tested this out yet but this is what the latest version of my health component looks like:

extends Node2D
class_name Health

#If entity_stats attached to Health component then the entity's health will be persisted
@export var entity_stats : EntityStats

@export var invincible: bool = false
@export var max_health: int = 100
var health: int

signal entity_damaged(attacker)
signal entity_died
signal entity_healed

func _ready():
	#TODO if my game is going to include saving/continuing, then this will need refactoring
	GlobalSignals.start_game.connect(_reset_saved_health)
	
	if entity_stats:
		#For entities (player) that have saved stats, set their health to saved health
		health = entity_stats.health
	else:
		#For all entities that don't have saved stats, then set their health to 100 onready
		health = max_health
	
func _reset_saved_health():
	if entity_stats:
		entity_stats.health = max_health

func _persist_health():
	if entity_stats:
		entity_stats.set_health(health)

func take_damage(attacker, damage):
	#If invincibility flag set, then don't take damage
	if invincible:
		return
	
	health = max(0, health - damage)
	
	if health == 0:
		entity_died.emit()
	else:
		entity_damaged.emit(attacker)

	_persist_health()	

func add_health(value):
	health = min(max_health, health + value)
	entity_healed.emit()
	_persist_health()

Thanks for the reply!
If I do this approach wouldn’t I also need a way to only persist the player’s data but not the other entities?

you get player data by using object_id of the player. So if you think about (de)serializing data you may need a “id:int” property that saves data
for users[id][&'current']
and address it from player (if needed)

users[self.id][&'current']

or use just a separate dictionary that keeps ALL data about player entity and essential characters. Nodes just have a single property that addresses related to this node player data. That can give you a way to dynamically add or remove/modify player actions/comps/items/slots/etc.
Node’s code:

var id:int=0
#it may be int or redable type String where you get id by
#get_slice() on string like "BANDIT#315" "CITIZEN#89" or simple 51
var properties:Dictionary=EntityData.list[id]

player related data in EntityData class_name:

var list:Dictionary[int, Dictionary]={ #if id defined as int type
	0:{
		&'name':"John",
		&'surname':"Doe",
		&'location':"GENERIC_CITY#0",
		&'comps':{ #may use or may not for components
			&'health':{
				&'current':100,
				&'max':100,
				},
			&'slots':{
				&'head':"HAT#0",
				&'hand_right':"MACE#31"
				},
			},
		},

Every comp reads just related to it data like for Health:

	EntityData.list[id][&'comps'][&'health'][&'current'] #to get current level

if used to player node

$player.properties[&'comps'][&'health'][&'current']

serializing all entites are easy
just by var_to_str() or by var_to_bytes()

to filter if its needed to be saved or discarded you may just add a key &'essential'=true
before serializing:

	var to_serialize:Dictionary=EntityData.list.duplicate(true)
#filter that don't have &'essential'
	for key in EntityData.list:
		if EntityData.list[key].get(&'essential', null)==null:
			to_serialize.erase(key)

and then var_to_bytes(to_serialize)

and one thing to say… empty dictionary size is 8, empty object 36, resource 100, and node 320…

1 Like

Sorry, I actually meant a separate autoload that takes care of all data that needs to persist on the player. Lets call this autoload player_stats or something, only autoload this script as it wont have a scene attached to it. Give it a variable for the current health and let the player cal player_stats.health = health_component.health. Then whenever you switch scenes, you can set the health from the autoload. Same goes for position or other attributes that you might want to carry over from other scenes.

This way you dont need the @export var entity_stats : EntityStats and the methods that use the variable in the health script.

1 Like

In the end, I pretty much did that, except I just used a Resource class. I refactored my code so my health.gd script doesn’t contain any references to the Resource class but has signals. In the player.gd script its connects to these signals and updates the Resource class accordingly. Thank you for the help :slight_smile:

This topic was automatically closed 30 days after the last reply. New replies are no longer allowed.