Godot Architecture: 7 steps for more flexible, extensible, testable code (video)

Hello!

I made a video about how I like to structure my projects, including a refactor of some of my older code.

This is probably not a good use of time on a very small project, but larger and collaborative projects tend to benefit from more structure. Most of these techniques are about increasing readability and decreasing coupling, and culminate in fairly unit-testable code (if anyone wants that?). Anyways, all of these are tools you can take or leave, but I do explain some of the benefits!

Here’s what I go through:

- (briefly) static typing

- removing autoloads and moving to a simple version of dependency injection

- restructuring our “main scene” to provide hierarchical contexts that manage their own non-global services

- blowin’ up the signal hub and localizing signals

- colocating code and other assets into feature-group folders with maximum cohesion and minimal outside dependencies

- setting up unit testing with GDUnit

- probably some other stuff too, I don’t remember, it’s been a long day

There’s lots more I’d like to do but these are, I think, useful for a lot of projects.

Anyone else have any essential architecture best practices?

Really good intro.

Around 12:20

You recommend replacing hardcoded $NodePaths. That was good. You then said you can use @export variables. That was less good. $NodePaths should be replaced with @onready variables. For example, in your code, you changed this to an @export variable.

var start_button = $layout_margin/layout_options/start
start_button.pressed.connect(on_press_start)

This is a common thing I see in people with Unreal/Unity experience. The drawback to @export var methods are it clutters up the Inspector, and more importantly if you forget to link an object the failures are a lot more cryptic. I have found that even when creating a generic node reference, it causes less headaches to use an @onready variable and search for what you need in the tree rather than rely on yourself to remember to link the variable.

You can fix this with much less typing.

  1. Click and drag the start button in the Scene Tree over the editor window.
  2. Press the Ctrl button. (It has to be pressed after clicking and before dropping.)
  3. Drop the button into your code.
  4. This line will appear:
var start: Button =  $layout_margin/layout_options/start

It will be statically typed, and have a variable you can use immediately. It will be named the same as your Node is named. So, you can either call it Start Button, or rename the variable name it created to match your code. This brings up another thing. Usually node names in the editor are not snake_case. They’re either PascalCase or they’re more often just human readable with every word capitalized. (Hence my suggested name above.)

The other thing I noticed is the convention of calling functions that are only referenced in the class is to prepend the function name with an underscore to indicate they are “private”. I put that in quotes, because as I’m sure you know GDScript doesn’t really have access modifiers. Still, it’s convention to make the code clearer. (If you address these later in the video, I just haven’t gotten there yet.)

Around 13:20

You mention that people should follow “some” convention. I’d recommend you not prevaricate on this point. Almost every day we get people posting questions, and they’re following some weird convention that makes their code much harder to understand. Marching to the beat of your own drummer is fine, as long as you never have to ask for help. But it can become a real problem, and people get very offended and frustrated when they’re told their code is confusing and/or hard to read.

Around 14:00

You talk about state machines. I’m a big fan. Something you might look into for your own enjoyment is Resource-based or Node-based state machines. I have adopted a pull-based (as opposed to push-based) Node state machine. You can look at it and play with it using my State Machine Plugin if you’re interested. I use it for game state, as well as player states.

I also think this State Chart Plugin is pretty nifty. And while I don’t like it for myself in practical application, I think it’s a neat design pattern and I had fun playing with it. I chose a pull machine based on Kanban project management and the idea that states control when they are entered. This allows me to keep the code completely atomic and add/remove states even during runtime without having tightly coupled code.

Around 15:15

You talk about contexts here. I use a state machine instead. Same idea, different name. (Unless you feel there are differences, in which case I’d be interested to hear them.) For me this was a journey of trial and error. I originally had four state machines that all did the same thing: Node, Control, Node2D and Node3D. The icons were color-coded and they could have spatial knowledge as part of the machine.

After implementing this, I realized that I did not need to hang the HitBox off the AttackState, and in fact from an encapsulation point of view, I had to view States as Components of the larger object the StateMachine was operating on. Basically they needed to be treated as if the _subject (object was taken) of the StateMachine and it’s Nodes had protected access modifiers that “allowed” the States to see and manipulate them.

Once I got to that point, I started seeing how they could all be nodes, and as I got used to using the StateMachine I realized there was never any need to extend it - only the States.

Around 18:17

I get where you’re coming from about Autoloads I think they get overused and used incorrectly a lot. I’m typically not a fan of a SignalBus style Autoload. Having said that, as I have modularized my code, I’ve ended up with a bunch of little Autoloads.

Each one handles atomic things that I want handled at a high level. They all have as few functions in them as possible, and they are specifically not load dependent. This list is generated from my Game Template Plugin, which brings a number of my commonly used plugins under one roof with a basic UI and scaffolding for 2D and 3D games. It also has the Localization Plugin, which simply provides a dynamically created language option button based on the languages you localize in the game.


(From my game Katamari Mech Spacey)

That node tree also has my great shame, the Camera2DSignalBus which comes from the Camera2D Plugin. That plugin provides a number of components that can be added to a Camera2D to extend its functionality.

About 19:25

I do agree that changing things in Autoloads is can be a big problem. Which is why I typically only use them in plugins, and those are all under version control and use semantic versioning. Even that Game autoload is from a plugin, and it carries a very few things that multiple contexts need to have access to that I haven’t yet figured how to pass in other ways. It’s also my central clearinghouse for when inheritance when I need a place to put some messy variables or signals for prototyping in a larger game until I can factor them out. It’s a convenient way for me to know all my trash is in one script.

I found your discussion of dependency injection very interesting, but I wish it was longer, because even though I’ve used it in other contexts (web development and automated testing), I couldn’t decide what I thought on the pros/cons of using it here, and the implementation details.

I also saw your use of a variable name gsh. I find the abbreviation of variables in modern languages obfuscation for obfuscation’s sake. You know what it is, but I had to look for a reference to what it meant, and it seemed unnecessary to shorten that name. Especially in the context of teaching - though I get that this was old code and we end up with short hand that often means things to us as developers. I’ve just found no reason not to have descriptive variables names as we are no longer in the 80s.

About 20:30

I saw a code smell, which is a bunch of classes named “Controller”. This seems like the Manager Anti-Pattern with a different name. I watched a talk years ago on C++ naming conventions, and the speaker talked about how our brains do things to our objects when we call them “Manager”. And by changing the name to what they actually do, we tend to delegate a lot of the responsibility they are holding onto to the objects that really should be doing them. If we take a look at my Autoloads again:

You’ll see that in essence they could all be named Manager or Controller. Funnily enough Controller is in fact the synonym I landed on for Input because that name is already taken by Godot. Gamepad Take Keyboard for example. This is the entire script:

@icon("res://addons/dragonforge_controller/assets/textures/icons/keyboard.svg")
## Keyboard Autoload
extends Node

const icon_path = "res://addons/dragonforge_controller/assets/key_icons/"


## Returns the Texture2D representation of the keyboard key event passed
func get_key_icon(event: InputEventKey) -> Texture2D:
	var keyname = event.as_text().trim_prefix("Kp ").trim_suffix(" (Physical)").trim_suffix(" - Physical").to_lower()
	var filename = icon_path + "keyboard_" + keyname + "_outline.png"
	return load(filename)

All it does is integrate in with Controller and when called with an InputEventKey, pull up a graphical representation in the form of a Texture2D. It’s only use is for the GUI to display the correct key to press.

If I called it KeyboardManager or KeyboardController, I would be tempted to give it more responsibility. Maybe all keyboard input should go through it - or special keyboard input. I decided not.

Likewise, if we look at the Mouse script (sans some comments):

@icon("res://addons/dragonforge_controller/assets/textures/icons/mouse.svg")
## Mouse Autoload
extends Node

## Look sensitivity modifier for 3D camera controls
@export var sensitivity: float = 0.0075:
	set(value):
		sensitivity = value
		if get_tree().root.has_node("Disk"):
			var disk: Variant = get_tree().root.get_node("Disk")
			disk.save_setting(sensitivity, "mouse_sensitivity")
@export var mouse_button_images: Array[Texture2D]


func _ready() -> void:
	if get_tree().root.has_node("Disk"):
		var disk: Variant = get_tree().root.get_node("Disk")
		var returned_value = disk.load_setting("mouse_sensitivity")
		if returned_value:
			sensitivity = returned_value


func _input(event: InputEvent) -> void:
	if Controller.enable_3d_look and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED and event is InputEventMouseMotion:
		Controller.look = -event.relative * sensitivity


## Returns the Texture2D representation of the mouse button event passed.
## Returns null if the InputEvent is not a MouseButtonEvent.
func get_mouse_icon(action: InputEvent) -> Texture2D:
	if action is InputEventMouseButton:
		return mouse_button_images[action.button_index]
	return

It stores images for mouse buttons and will return them just like Keyboard. It also handles variables for mouse look in 3D. It also decouples itself from the Disk Plugin. If the Autoload exists, it uses it to save and load the sensitivity settings for the mouse. If not, it doesn’t. It also can track looking for using keyboard and mouse for 1st and 3rd person camera control. The Gamepad Autoload does the same thing for the gamepage, and the controller takes that information and puts it together so that both inputs always work and you can tell which one is being used.

But the Mouse isn’t a Manager or Controller. It only deals with things that need to be centralized, and lets everything else be handled by the scripts that need to handle mouse input.

About 21:13

I’d question the naming convention of Holder as well, but not seeing the code it’s hard to say. If it’s basically a synonym of List that could make sense, I’m just used to the FSM context of State Machine as the holder of game states.

About 22:00

Really good stuff. Call down, signal up. (Ha! You said it!)

About 20:26

Good stuff.

I put all assets in an assets folder, and then group scripts and scenes together under features. I have found that I often need to re-use assets in more than one context more often, and I sometimes need to do bulk import changes or something else. So in general, I have found it most useful to keep assets together. It also helps on game #10 when quick load can’t find a texture for example, and I want to find it, I always know that I am browsing to res://assets/textures/ and then looking for the folder name that makes sense. Over time my brain has decided on what works for me.

About 25:05

Really good point about the reduction of merge conflicts. It’s also really helpful when working with an artist or sound designer. All their stuff is in one place and they don’t need to muck about in your scenes and code. And while a lot of people seeing your video won’t be working on regular development teams, they may at some point be working on game jam teams, and this structure will really help if they want to pull in some assets designers in.

About 25:25

Preach!

I love that you’re talking about this.

I started using GDUnit a couple years ago. I created this lesson on implementing GDUnit intended for people who know nothing.

However, in practice I have found, as you touched on, that my games change so quickly that unit tests are not as useful for me as I hoped they would be. I also have made a fair number of game jam games, and the frenetic pace of game jams does not lend it self to unit testing.

Closing Thoughts

You had a lot of good things to say overall. Very good production quality. Engaging and interesting video.

1 Like

Like you said, everyone has their own idea about coding and I have mine :slight_smile: I will guess this is for small one or two person teams. After all we are a gaming community.

What I tell people about this topic is the best laid architecture is the first thing that falls apart once you step into the mud. Architecture is good for overall top level designs. Beyond that is analysis followed by paralysis.

The best way to future proof workable code is to be consistent with your coding style and document. At the top of each file and function have a summary what this code does. Self documenting variables/function names is good but can only go so far. Create meaningful naming and folder structures. Error checks with meaningful messages. Error checks not only validate the functionality but can serve as documentation for what your code is supposed to do. Unit testing is good but only goes so far. That’s a topic in itself.

I’ve gone back to messy code many times and as long as it’s documented I and anyone else can work with it. Of course these days you can use AI to generate requirements, code and document. But that’s another story.

First of all… what a gift! Thank you SO much for taking the time to provide me with your detailed thoughts. Seriously.

In general a lot of things are… me being a bit sloppy! I couldn’t cover everything, but also I don’t know everything yet. I kinda just wanted to give some people a high level overview of some techniques I like to use.

In particular:

1) $ syntax: guilty as charged! I switched from Unity a couple years ago. I still don’t like $ syntax, because I find that I’m often tinkering with scene structure, moving stuff between layout nodes etc. I think if anyone likes this, it’s fine. I’m going to have to check out onready, i haven’t used it much.

2) Codestyle conventions: Yeah, but I think that it’s ok to deviate somewhat from codestyle within your organization if you’re being consistent, clear and intentional. I’ve worked at a few big companies in my life and they all have slightly different (but usually readable) conventions. The GDScript style guide is great though and I wouldn’t deviate unless I’ve got a good reason.

3) State machines: Yeah, it’s all trial and error. A lot of error. I’ll check out those state machine resources.

4) I think I come down too hard on autoloads; a lot of people are asking me if they are forbidden, which is not really what I mean. Rather, I think they are usually unnecessary and often problematic. There ARE a few things that are totally appropriate to keep up at that level. I can’t remember how well they play with GDUnit though? I assume there’s a mocking function for them. Love the example.

5) I agree about descriptive variables. I only crush em down into a dumb abbreviation if 1) it’s something that’s used pretty much in the next and line and has an obvious purpose (e.g. i/j/x/y in a loop over a range, or in this case I’m just passing in the only game-state holder service as an argument). Also you might adopt a common abbreviation for a type of thing across your game/org as a general shorthand. Counterpoint: the company I work for at my day job has so many damn abbreviations that it’s impossible to remember what anything is, so… yeah. Readability!

6) Manager classes.. You’ve found my great shame, and this is something I’m going to work on, and I’ll spread the word.

7) GameStateHolder: Eh, the holder is just the thing that holds the GameState for access elsewhere. GameStateProvider? I don’t know, it just holds it. I wanted a service that can hold the GameState and hold a different one if you start a new game without having to update refs everywhere.

One thing I’m curious if you or anyone else has thoughts on is this:

When I was mucking around with building Entity Controller Systems, I got really into the idea of treating game data as simple data classes with no functionality, and having outside services act on them; obviously, I’m not doing an ECS here (no components, and there’s likely no performance gain to do this in GDScript) but I did rather like this as it abstracts dependencies away from the game data classes themselves. I’ve heard it called “an anemic domain model” but I sort of like to think of the “controllers” I have as the main interfaces of the objects themselves, and are able to orchestrate behaviour between domains. As with lots of OO principles, it works either way and the “downsides” are a bit abstract; anyone have any concrete thoughts on this?

Anyone who likes $NodePaths all over their code is new to Godot. I don’t say this as a shaming thing. If you look at my early code, it’s there, because it is magic. But, as soon as you start moving nodes around, you now have tons of references to fix.

To coin a term here, $NodePath notation links are Magic Node Paths. They are fancy Magic Strings.

@onready variables are the solution to that. The only time they don’t work is when you need to reference them inside _ready(), in which case you can do:

var timer: Timer


func ready() -> void:
	timer = $Timer
	timer.start()

Potentially, yes. But my job for yeras has been to go into companies and train development teams, including architects and senior devs how to improve their SDLC. Which means if they’re not linting for that stuff in the CI/CD pipeline, we are gonna have a talk.

But my point is that 95% (SWAG number) of Godot developers are hobbyists with no team. So they often adopt style guides that are: “This is what I did in my last language.” The only people they collaborate with are other Godot hobbyists - either when looking for help, or when doing a game jam.

The style guide is also used by the dev team, so if you follows it, your code looks like theirs and is easy to read. It’s like teaching someone grammar. They will use it without fully appreciating how much better it makes little things. Having bad grammar won’t necessarily affect their communication with others, but it can make them hard to understand.

So yes, in a corporate structure it’s different. But when you’re using a FOSS tool, it’s best in the long run to use the tool like everyone else.

@normalized would say you didn’t. We have different opinions on them. I do think they need to be used carefully and can lead to bad design. But they are not inherently evil - and as long as they are kept lightweight, they are very useful.

Which is why I have over a dozen plugins and some have dependencies. To let people choose what they want to use without bogging them down with unnecessary weight.

I do not remember having a problem with GDUnit and autoloads. They’re easy enough to reference in a test I would think.

Iterators and math operands get a pass. Just yesterdays I gave a pass to stats in a reply to someone. But I suggest they pick that or statistics and not mix both in the same file.

Heh. We all have things in our past.

GameStateMachine In games, that’s the naming standard. It says “This holds my game states”.

I’m sure @normalized has some thoughts. Mainly cause he feels different about OO than I do in many areas.

I’ve never used ECS. So I can have feelings about the architecture, but I haven’t mucked around inside code. My feeling is based on its history, is it didn’t gain much popularity until Apple implemented it in a toolkit in 2015, and then really took off when Unity implemented it in 2018. And like most complex architectures, I think a lot of hobbyists apply it to problems when it is not the hammer for that screw.

As for using ECS in Godot, here’s an article about why Godot doesn’t implement ECS. Here’s an ECS Solution by GDQuest. There was also a 3.x implementation called Godex that looks like it was never ported to 4.x and was abandoned 3 years ago. Emi addressed ECS in Godot in one of the recent Godot Tomorrow streams. He basically said, “We’re not doing that, but you can do it in Godot if you want.”

My personal feeling is that it’s not a good fit. The more you use Godot, the more you’ll see the benefits of using Inheritance AND Composition. I’m a huge fan of inheritance. But Godot really made me re-evaluate the usefulness of components as nodes. I discussed it at length in this post: A discussion about composition and code complexity - #14 by dragonforge-dev Including at the end, where I discussed my approach to my Camera3D and Camera2D plugins, and why I went from a primarily inheritance approach to a component-based approach.

The simple answer is Nodes. It’s why my StateMachine is Node-based. I used to think I needed to do everything purely code-focused to keep things optimized. I learned two things. First, this post talks about the size of Nodes in memory. Second, I learned that Godot only has performance issues if you are pushing 10s of thousands of 3D objects to the screen, or you’re using it crunch numbers for random world generation.

While yes, Node objects do take up a little more space - that space is not going to affect the performance of your game. But what you get is something that’s MUCH easier to use in building games. I’ve been using Finite State Machines since the 80s. When I started using Godot, I followed the examples of using simple Enum-based state machines.

Then I followed this excellent class from GameDev.tv: Godot 4 C# Action Adventure: Build your own 2.5D RPG IO used it to make my game Dash and His Ghost Girlfriend for a game jam. (The reason I don’t use C# with is after 5 minutes, Mono Godot is still loading. and luckily I don’t have to compile it) Here’s what the State Machine looks like in that project:

StateMachine.cs

using Godot;
using System;
using System.Linq;

public partial class StateMachine : Node
{
    [Export] private Node currentState;
    [Export] private CharacterState[] states;

    public override void _Ready()
    {
        currentState.Notification(GameConstants.NOTIFICATION_ENTER_STATE);
    }

    public void SwitchState<T>()
    {
        CharacterState newState = states.Where((state) => state is T).FirstOrDefault();

        if (newState == null) { return; }

        if (currentState is T) { return; }

        if (!newState.CanTransition()) { return; }

        currentState.Notification(GameConstants.NOTIFICATION_EXIT_STATE);
        currentState = newState;
        currentState.Notification(GameConstants.NOTIFICATION_ENTER_STATE);

    }
}

IdleState.cs

using Godot;
using System;

public partial class PlayerIdleState : PlayerState
{
    public override void _PhysicsProcess(double delta)
    {
        if (characterNode.direction != Vector2.Zero)
        {
            characterNode.StateMachineNode.SwitchState<PlayerMoveState>();
        }
    }

    public override void _Input(InputEvent @event)
    {
        CheckForAttackInput();

        CheckForBombInput();

        CheckForDashInput();
    }

    protected override void EnterState()
    {
        characterNode.AnimPlayerNode.Play(GameConstants.ANIM_IDLE);
    }
}

I liked it, but there were parts I didn’t like. For example, you had an @export Array for all the states in the machine. Just use the nodes under it and be done. There was also a Resource-based Stat system.

So when I ported it back to GDScript, I tried a Resource-based state machine to “save memory”. I also thought I was being clever, because they would be available in the Inspector, but not clutter up my scene tree. I discuss it more here.

I ended up just finding that the Node-based approach makes a lot of sense for a State Machine. It’s so much easier to test and modify. It’s so much easier to see all the states very clearly in your editor.

I found the same thing with the idea of a health component. When I first saw these being used, I hated them. At some point, someone asked how you would make one, so I replied: Am I doing Components/composition right? - #3 by dragonforge-dev As you can guess, I ended up making a long post and providing code. More recently, I started using it. I updated the code a bit.

health.gd
@tool
@icon("res://assets/textures/icons/heart.svg")
## A Health Component to add to players and enemies.
class_name Health extends Node

signal damaged
signal healed
signal zeroed

@export var max_health: float = 1.0:
	set(value):
		max_health = value
		health = max_health
@export var health: float = 1.0:
	set(value):
		if value <= 0:
			zeroed.emit()
			value = 0
		elif health > value:
			damaged.emit()
		elif health < value:
			healed.emit()
		health = value


func damage(amount: float) -> void:
	health -= amount


func heal(amount: float) -> void:
	health += amount

I’ve found that the more I use components and Node-based architecture, the easier making games in Godot becomes.

TLDR: I think ECS is overkill unless you are making a game with a company.