Discussing Architectural Patterns in Godot after 2 years of development

A separate thought on the networking side — because it ties into a much bigger design choice for me.

Adding multiplayer isn’t just a code layer bolted on top of an existing game. In practice it means designing entirely different levels, with different pacing, different failure states, different camera/UX assumptions. It’s close to building a second game. That’s why right now I’m focused exclusively on the single-player loop. Once that’s actually finished I’ll start thinking about co-op, but to be honest I don’t even have the shadow of an idea of how I’d structure it yet.

What I do know is that I’m done with combat-driven and competitive modes. The most toxic game I’ve ever experienced is League of Legends. I got hooked on it like a drug — I was worse than someone burning their paycheck at a Vegas casino — but the more time passed, the clearer it became that the problem wasn’t me, it was the game itself. The game is profoundly unhealthy by design: it actively damages the body, the brain, and — I genuinely believe — the future of humanity at scale, given how many hours millions of people pour into it.

You can call me naive for saying that, and I won’t take it personally. For whatever it’s worth, I’m credited in the acknowledgements of 11 published books, so I’d like to think my read on this isn’t completely off-base. My sincere conviction is that the way we behave intellectually inside a game does carry over into real life. The reflexes you train every evening — patience or contempt, cooperation or domination, generosity or score-hoarding — don’t politely stay inside the client when you close it. They become you.

So even if it sounds a bit naive, what I’m actually looking for is something where people laugh together, and that stays out of the LGBTQA++ vicious circle — which honestly, to me, looks like a passing trend more than anything else. Mixing all of that into something genuinely positive and genuinely funny is extremely hard. I try not to set limits on myself, and yet I systematically run into people who put walls in front of me — because no matter what style of humour I try, someone is always there to take offence. That’s what I find so frustrating, because to me humour is the most subtle form of intelligence there is.

On that note: if you don’t know who Joel Spolsky is, look him up. Most people instinctively reach for Steve Jobs or Bill Gates when asked who they admire in tech — I reach for Spolsky, precisely for that rare combination of sharp intelligence and real humour. And in case anyone’s tempted to dismiss him as “just a funny blogger”, he co-founded two companies that ended in major exits: Stack Overflow (with Jeff Atwood, another person I deeply admire) — sold to Prosus in 2021 for $1.8 billion — and Trello (with Michael Pryor at Fog Creek) — sold to Atlassian in 2017 for $425 million. So he’s not exactly the dreamer type either.

So whatever multiplayer mode I eventually build, it will be strictly cooperative, and it will try to make people laugh. That’s the only direction I find worth the years of work it represents.

One last thing. Old guys like me — yes, our brains slow down, we know it — also have something younger devs simply haven’t had time to accumulate yet: experience, and the scar tissue that comes with it. The suggestions I’m sharing here took me a long time to formulate; please don’t dismiss any of them lightly. I genuinely hope you get something useful out of it.

There’s an old French expression I always come back to: what matters is the engine, not the bodywork. And today, 99% of the people walking this planet would rather stare at the paint job than ever bother lifting the hood. I’ve always sat on the other side of that line — and I always will.

1 Like

A fascinating post; thanks for that. As ‘very small beer’, however, I’m afraid that a lot of it went well over my ancient head. OK, I’m old, with over half a century of IT stuff, including analyst/programming/coding in several languages over the decades, and spent the last few decades of my past career dabbling in OOP for factory production-line systems. I’m currently playing around with Godot, making games simply for myself, to keep the grey matter moving, with no intention of diffusing anything at all, but I’m intrigued by these new (to me…) expressions, concerning methods, styles, techniques etc, most of which show me that I could do much better, if only I had a handle, a grasp, an introduction to the bottom rung of the ladder. I fear the answer, but I’ll ask anyway : is there a book (or books…), or any online (series of…) videos which go patiently, step by step, through some of these concepts..? I can, of course continue in my merry path of doing stuff in my innocent way, but if there’s anything better that I could learn about, I’d like to see what, if anything, I could make of it.
Anyway, thanks for the glimpse through the glass ceiling of what can be done, and Good Luck with your present and future ventures.
Have a great day.
(Note to self : get working on that Time Machine you started, to go back a few decades and follow more closely what’s going down. Pick up a lottery ticket whilst you’re testing the thing, but be certain not to have any flies in the vicinity…)

1 Like

I started one here: Godot: Learn To Be A Professional Game Developer by Making a 3D RPG From Scratch — Dragonforge Development

I abandoned it because I had done it as a way to make money, and the ROI wasn’t going to work. Each lesson took me a week+ to write. Plus, a lot of things that I’ve learned about Godot would completely change how I would write that today. But, the concepts it teaches are still good and there are a TON of links to additional resources that I curated in the sidebars about software development like OOP, as well as game development resources, and definitions galore. Plus it touches on unit testing and github.

1 Like

It’s not that composition is hard per se. You (as many of us) have just been too deeply indoctrinated to be able to think any other way.

1 Like

Fair enough, would you be wiling to talk about it from an abstract higher level? I’m particularly interested in hearing more about your bullet points:

How do you run the engine? Is it triggered by something like an Area2D/3D? Or is it built into the NPC at a level where it’s just part of their behavior?

How do players interrupt? Do you have interruption points? Is it player-initiated?

Is this also a quest system?

I’m really interested in how you use it as a tutorial system. Do you have different nodes for that?

Also, how are your NPCs constructed? Do they have state machines, or are you using some sort of command pattern to communicate with them?

How is animation_player defined? Based on your checking, I’m assuming it’s an @export variable? What does the wiring up process for this node look like? Is it literally just drop it on top and it searches for the variables it needs? Or is there configuration invovled?

I get that, but I don’t see an @rpc call to the server to say, “Hey I think I collected this.” So how is the server determining that who triggered the coin collect? Are all the player inputs sent to the server and then it does the movement for everyone?

There’s a performance hit too. The physics engine is doing the check for me with a bitwise compare operation in C++. Nothing I can code in GDScript can compare speed-wise. Not necessarily an issue, but this is why I gravitated to it from doing this a type comparison.

I get that you can have multiple characters, but there are multiple cases where I have base types that are literally just something like:

class_name Character extends CharacterBody3D
class_name Player extends Character

Just for type checking. I often refactor code into them later, but I find it helpful to me. There doesn’t actually need to be any code for a Type to be useful for checks.

Not saying your version doesn’t work, but just because I’m using composition more doesn’t mean I want to abandon the usefulness of inheritance altogether.

Agreed. This game was always meant to be multiplayer. I inherited code from a previous developer and it preferred being clever over readability. It was tightly coupled throughout, and it became a nightmare to refactor. So, I ended up going greenfield on it and starting over. I already have @rpc calls in the player object, but I’m starting to think that perhaps a revamp of that is in order. If I were to implement a command pattern, I’d only be sending RPC calls of the commands to the server. Then the server outputs the physics. The upside being that it’s less hackable, and I can store all the commands and replay the game.

I agree with this. The game I’m working on is paid work. I find myself demotivated, because I prefer games with a good story, and sandbox games personally. I stopped competing in FPS games probably 15 years ago. They became something to play cooperatively with my kids, but online competition held no sway. And I really hate the treadmill that WoW popularized, and games with microtransactions and loot boxes turned into gambling and addiction machines.

I’ll look into Joel Splotsky.

I have a question about composition:
What is the appropriate way to get a reference to a component from a different node?
Let’s say you have Node A and Node B. Node A already has a reference to Node B, but now node A needs to call a function in one of Node B’s components, what is the best way for it to get a reference to the component?

That’s a pretty generic question. It really depends on your architecture. Let’s say you have a Health component and a Hunger component, like in the thread I linked above. And let’s say if your hunger drops to zero, you start taking damage.

In that case, it makes sense for Hunger to know about Health. So you can just call the damage() function periodically. You can either search for the Health component and if it exists call it, or create an @export variable and link it.

Let’s say you have a Posion effect, and when poisoned, you want the Health and Hunger to go down. You could add a poison() function to the Health and then tell it about the Hunger component. Then you’ve got something that’s tightly coupled.

Or, you could create a Poison effect as a node. Give it a lifetime, and have it search for Health and Hunger. It could call the damage() function, and alter the hunger amount (or you could create a starve() function) periodically. This way, it affects the things it needs to affect, and doesn’t couple things further.


So, the better question to ask yourself is, "Is my desire to tell Node B about Node A tightly couple my code, and is there another solution to this problem?

One of the things that @surferix got me thinking about in the initial post was about the Collectible component and the Door. My approach was always to make a Collectible base class, and then extend from it. I have a similar architecture with my doors in Eternal Echoes.

I have a base class that is an Area2D. It shows the button to be pressed and an icon representing the action based on the input being used by the player.

class_name ActionDisplayTrigger extends Area2D

## The name of the action to look up for the appropriate icon based on the
## current input type. Note, the action must match an action exactly from the
## input map.
@export var action_name: String
## The text to display after the icon shown. (E.G. "Jump", "To Skip Cutscene",
## "To Enter Mine")
@export var action_text: String


func _ready() -> void:
	body_entered.connect(_on_player_entered)
	body_exited.connect(_on_player_exited)


func _on_player_entered(_body: Node2D) -> void:
	Controller.show_action_display.emit(action_name, action_text)


func _on_player_exited(_body: Node2D) -> void:
	Controller.hide_action_display.emit()

Then I extend that for a TransitionArea.

class_name TransitionArea extends ActionDisplayTrigger

@export_file("*.tscn") var linked_level: String
@export var target_transition_area_name: String

var player: Player

func _ready() -> void:
	super()
	set_process_input(false)


func _input(event: InputEvent) -> void:
	if event.is_action_pressed("enter_new_area") and player.is_on_floor() and player.direction == 0.0:
		Disk.save_game()
		Game.load_level.emit(linked_level, player, target_transition_area_name)
		#get_parent().hide()
		_on_player_exited(player)


func _on_player_entered(body: Node2D) -> void:
	super(body)
	player = body
	set_process_input(true)


func _on_player_exited(_body: Node2D) -> void:
	super(_body)
	set_process_input(false)

Then I extend that for a LockedDoor.

class_name LockedDoor extends TransitionArea

@export var unlock_animation_duration: float = 1.0 #TODO: Remove this
@export var locked_message: String = "It's Locked!"

var is_locked: bool = true

@onready var lock: Sprite2D = %Lock
@onready var lock_collision_shape_2d: CollisionShape2D = %LockCollisionShape2D
@onready var lock_static_body_2d: StaticBody2D = %LockStaticBody2D
@onready var audio_stream_player_2d: AudioStreamPlayer2D = $AudioStreamPlayer2D


func _input(event: InputEvent) -> void:
	if is_locked and event.is_action_pressed("enter_new_area") and player.is_on_floor() and player.direction == 0.0:
		Controller.show_action_display.emit(action_name, locked_message)
		await get_tree().create_timer(0.5).timeout
		Controller.show_action_display.emit(action_name, action_text)
		return
	super(event)


func unlock() -> void:
	audio_stream_player_2d.play()
	lock_collision_shape_2d.set_deferred("disabled", true)
	var tween: Tween = create_tween()
	tween.tween_property(lock, "modulate:a", 0.0, unlock_animation_duration)
	for dagger in lock_static_body_2d.get_children():
		if dagger is ThrownWeapon:
			tween.tween_property(dagger, "modulate:a", 0.0, unlock_animation_duration)
	is_locked = false
	await get_tree().create_timer(unlock_animation_duration).timeout
	lock.queue_free()
	for dagger in lock_static_body_2d.get_children():
		if dagger is ThrownWeapon:
			dagger.queue_free()

But really, those are just three functions I could create as components. I could turn this:

Into something like this:

It’s the same number of Nodes, they’re just now hanging off a generic Node2D whose only purpose is to not have any transforms on it. But now I can add and remove nodes to get different functionality, and with a bit of tweaking of the code, everything becomes mor atomic.

So again, I think it comes down to using them differently.

1 Like

Thank you for the detailed answer.

I was thinking of a situation like that, where Poison node has to search for the other components. Would you use GetChildren() and then loop through them to check if it is of type Health/Hunger until you find it? Or is there a better way to search for it?

@amarc Ahahaha… I can answer that… use .filter() on get children with the is keyword to check by type.

@surferix The problem is everyone is using A.I to express their thoughts these days which tends to be verbose when dumped, sometimes more than a human, wondering how you still have access to 4o. I know ChatGPT’s style anywhere but some good points were made on scene composition and inheritance :wink:

Scene composition vs inheritance

Sometimes both are still acceptable as long as it is a clean way

Regarding scene composition and swapping nodes out for other nodes for the same type with different behaviours, that is one of the flexibilities of godot.

I should give an example, thinking faster than I can type so initial post skipped straight to solution. Buffer overflow exception happening.

So u mentioned the effects technique, let me just use apply to movement patterns. Let’s say u can swap out a walk movement and a jump movement per enemy.

How I would solve the problem,would be to just inherit the scene and make a new gd script that extended the original by class_name and override move method that I call in _physics
_process, due to my coming from an object oriented background.

In this case, both methods should be acceptable, kinda like using a for loop vs using the filter function to collect items. It’s kinda like old currency and new currency. Both are legal tender :wink:

Why people get confused

The reason why people are getting confused is because we come from an application dev background. There is no such thing as us dragging in a button, and it giving us extra code we can call whether it is JSF or html. All code logic is usually wired in from backend scripts in .java or .js scripts.

Having said that, I do appreciate the flexibility that is given by godot, but to new people, because both ways are correct, it can be abit daunting. Why did person X say do it this way and person Y say do it that way?

I don’t blame godot for this, Game development is objectively more concurrent with many things happening in the background like bullets checking for players, players running from bullets while they are moving. It needs that flexibility. The screen doesn’t just stay still all the tiime like a web app or like a dead fish if u get what I mean. :face_with_hand_over_mouth:

My example for clean code

@normalized I was making a cuphead like rpg for abit, to be flashy rather than use numbers, I wanted to draw hearts to display a health bar like this

:heart: :heart: :heart:

Solution 1

Three hearts that were hardcoded or in godots case “hard-added” to a health bar node.

Everytime a player lost health. I just switched the icons all to empty hearts and looped that if the heart index was less than the health, I painted it red back.

Then I decided I wanted characters to have more health up to 7 points

Solution 2

:heart: :heart: :heart: :heart: :heart: :heart: :heart:

I didn’t know about auto center aligning controls then so there was a problem. If a player had three hearts it was aligned left and looked bad.
My solution was to change the start index and end index based on max health so if he had three max health, the start index would be 2, and the end index would be 5.

But this solution abit confusing and not extensible. U kinda had different indexes based on max health so u had to look that up, and everytime decided u want to update the max health you needed to change the component.

I jest abit that this is the preferred solution for most people though it has many flaws, simply because it provides job security.

Solution 3

Use a center aligned control… and basically just add hearts into component up to max health.

Then whenever u lose health u just disable all the hearts and do what solution one did.

So out of the three Solutions, Solution 3 is objectively the most flexible and the has the easiest code to understand.

The point is to find solution 3 which is the most extensive and if it is not too complicated to write and in this case cleaner, to just write that.

In practice however, under stress we tend to default to solution 2 instead. I think this more or less sums up what I have to say on the value of clean code.

Here I am, discussing this in the morning, when I am yet to have breakfast :laughing:

There’s also Solution 4 - learn the engine proper and use a sprite with repeated texture. Handles as many hearths as you want with a single node and a single line of update code. Clean AF.

2 Likes

That is why I told u I can’t evaluate the C code :wink:

I always think about performance and ease-of-use. If I don’t have to hook up a variable, that’s a good thing. My first option would be an @onready variable.

@onready var health: Health = $Health

Secondwould be an @onready variable with a Unique Name (Right-Click on them and select as Access as Unique Name.) But this requires you to right-click. Still the error is very clear when it can’t find the node.

@onready var health: Health = %Health

Slightly faster than an @onready variable (because it doesn’t have to be found - the reference already exists), but requires you to remember to set it, is an @export variable. But since you have to remember to set it and the error when you don’t is cryptic - this is my least favorite options.

You can do what @StJava suggests:

var health: Health
var hunger: Hunger

func _ready() -> void:
	for node in get_children().filter(func(node) -> bool: return node is Health):
		health = node
	for node in get_children().filter(func(node) -> bool: return node is Hunger):
		hunger = node

But there’s something in programming called Big O Notaction, which is the amount of time it takes to operate a loop or branch. This solution requires you to loop through all the nodes twice. Every time you need to search for something else, the time increases by that amount. This is exponential: O(2^n)

However if we do this:

func _fill_hearts(value: float) -> void:
	for node in get_children():
		if node is Health:
			health = node
		if node is Hunger:
			hunger = node

Then we are only looping once and our time is linear: O(n)

So that’s the better solution because it’s faster and more readable.

You could also technically have a more complex filter function, but then you’d still need to sort on the type twice:

var health: Health
var hunger: Hunger

func _ready() -> void:
	for node in get_children().filter(func(node) -> bool: return node is Health or node is Hunger):
		if node is Health:
			health = node
		if node is Hunger:
			hunger = node

Which I think would be O(log n)

You could also loop through and check for the name of the node instead of type, but string matching can becoming a processor-intensive process, so type checking is usually the fastest option - which is also why I give almost EVERYTHING a class_name. (The main exception is one-off Control nodes.)

So I would do type checking if you’re going to loop and @onready isn’t an option.

I currently do this:

@tool
@icon("uid://nvfdcccy33w1")
class_name HeartProgressBar extends TextureProgressBar

const HEART_EMPTY = preload("uid://bff2ldo6a7pej")
const HEART_FULL = preload("uid://nvfdcccy33w1")


func _ready() -> void:
	fill_mode = FILL_COUNTER_CLOCKWISE
	nine_patch_stretch = true
	texture_under = HEART_EMPTY
	texture_progress = HEART_FULL
	custom_minimum_size = Vector2(44.0, 39.0)


func fill_heart(health: float) -> void:
	value = health * 100.0

I created this node in the editor, and then made it all code. Then I did this:

@icon("uid://c34ybhksjav6d")
class_name HeartFlowContainer extends HFlowContainer

## The Health object we are tracking.
@export var health: Health


var heart_container_count: int: set = _set_heart_containers


func _ready() -> void:
	health.maximum_increased.connect(_on_maximum_health_changed)
	health.maximum_decreased.connect(_on_maximum_health_changed)
	health.damaged.connect(_fill_hearts)
	health.healed.connect(_fill_hearts)
	health.zero.connect(_fill_hearts.bind(0))
	_on_maximum_health_changed(health.maximum)


func _on_maximum_health_changed(new_maximum_health: float) -> void:
	var _heart_container_count: int = int(new_maximum_health)
	if new_maximum_health > float(_heart_container_count):
		_heart_container_count += 1
	heart_container_count = _heart_container_count


func _set_heart_containers(value: int) -> void:
	heart_container_count = value

	var current_count: int = get_child_count()
	if current_count < heart_container_count:
		for i in heart_container_count - current_count:
			var heart: HeartProgressBar = HeartProgressBar.new()
			add_child(heart)
		_fill_hearts(health.current)
	elif current_count > heart_container_count:
		for i in range(get_child_count() -1, -1, -1):
			if i >= heart_container_count:
				get_child(i).queue_free()
				get_children().remove_at(i)
		_fill_hearts(health.current)


func _fill_hearts(value: float) -> void:
	for heart in get_children():
		if heart is HeartProgressBar:
			heart.fill_heart(value)
			value -= 1.0

It extends a FlowContainer, which handles all that pesky “Where do the hearts go?” Problem. You set it’s width, and when the top row fills up, it makes a second row. Sure, it’s a little more code, but I can add or subtract hearts, and if the player starts with 3 hearts, that’s something I set on the player’s Health component. The display is completely divorced from caring about health at all.

Actually what u did is still n or linear time. It is just worse case of 2n instead of n. 2n being that we repeat the for loop twice

What I was suggesting is just

get_children().filter(func(node): return node is Poison).first()

This has the same effect as what u did also.

Basically after the filter u will always get 1 node anyway, so still constant linear time. In practice, unless we a very large number of nodes like 20000 nested nodes, then n2 is bad(for loop within for loop) and then it matters but for our case we probably are checking 20 nodes and it will always return 1 node from the first pass anyway.

But do agree that if u need to pull out multiple things from the list and check more than one thing, for loop is the way to go

P.S code was abit hard to follow but I would just have popped all the hearts and added back new ones from the new max HP amount if there was a change. Not so optimized but easier to read. Again I assume a max HP change won’t happen that often,so I sacrifice performance for readability

Thanks. That’s what I was thinking. Specifically in cases where the node that is searching for the component isn’t part of the same scene, so cases in which export and onready aren’t viable options.

1 Like

If you must do this, use Node::find_children()

3 Likes

I have to ask why u add the Node:: in front

Because find_children() is a method of Node class.

1 Like

I’m not following. But you could be right.

Except the question was how does the Poison node find the other two. So that wouldn’t work. So it doesn’t have the same effect.

Yeah that was my point.

Yes, and it wouldn’t work.

I did that first, and it didn’t work due to race condition issues. Basically what happens is the old containers are in the free() queue. Then the new ones get added. Then the fill function gets run, and fills the containers getting freed, and you end up with empty containers. And using call_deferred() on the fill doesn’t work.

The method I’m using is the only one that works. If you max goes up, this is all you need.

func _set_heart_containers(value: int) -> void:
	heart_container_count = value

	var current_count: int = get_child_count()
	if current_count < heart_container_count:
		for i in heart_container_count - current_count:
			var heart: HeartProgressBar = HeartProgressBar.new()
			add_child(heart)
		_fill_hearts(health.current)

But otherwise you have to iterate over the Array backwards if you want to delete things. Which is probably why the code looks confusing.

Yeah, I wouldn’t do that. Also, I think @surferix had the best solution here:

var health: Health
var hunger: Hunger

func find_nodes(value: float) -> void:
	for node in get_children():
		if node is Health:
			assert(not health_found, "Multiple Health nodes found in %s" % name)
			health = node
		if node is Hunger:
			assert(not hunger_found, "Multiple Hunger nodes found in %s" % name)
			hunger = node
	assert(health, "No Health node in %s" % name)
	assert(hunger, "No Hunger node in %s" % name)
2 Likes

How is iterating in script better than letting native code do it? find_children() can automatically filter by name and/or by type and you can choose to go recursive or not.

1 Like

Because of this note in the documentation about find_children():

Note: This method can be very slow. Consider storing references to the found nodes in a variable.

I’ve just always avoided it. I inferred that using get_children() and doing my own filtering is faster.

I could be wrong.