Good code architecture in Godot

Godot Version

4.5

Problem

I am having a hard time conceiving what good code design would look like in Godot.

Let’s say I have game with magic spells. Some spells are projectiles, some are area effects. In OOP it makes sense then to have an abstract Spell superclass with subclasses Spell ← ProjectileSpell and Spell ← AreaSpell.
Spell would have attributes like damage, cast_time, and cooldown.
ProjectileSpell could have attributes like velocity and homing_strength.
AreaSpell could have attributes such as radius and duration.

Now, in Godot it appears that the ProjectileSpell should be modelled as a CharacterBody2D and AreaSpell should be modelled as an Area2D. But, in doing this we have lost the inheritance from Spell since ProjectileSpell and AreaSpell inherit different immutable built-in types. This means that I can now no longer refer to spells as Spell or trust them to have a damage or cooldown property.

Question

My question is: what am I to do? Godot lacks interfaces, which could be a powerful option here given that I could make an interface ISpell which ProjectileSpell and AreaSpell implement, obtaining methods like get_damage() or get_cooldown(). I would also be able to do things like is ISpell instead of is ProjectileSpell or is AreaSpell. Now, with that said, you could utilize “duck-typing” still to make behavior like has_method("get_damage"), but I find this to be less concrete and more roundabout than orthodox OOP.

If anyone has any ideas of robust design solutions please let me know. Otherwise I will just continue to hack everything together as one does in Godot.

2 Likes

I’d advise not getting too hung up on object oriented programming; it’s not a great fit for most problem domains, really. There are a few where it kind of works (node hierarchies &UI libraries, arguably), but most of the time OOP just adds a lot of ritual and boilerplate.

Consider your own example; you’re trying to establish some sort of inheritance relationship between various kinds of spell. You likely won’t gain anything from it if you succeed other than unnecessary complexity. You’ve got damage listed as a core attribute, for example, but at some point you might want to make a fly spell, and now you’re either specifying that fly has a damage of zero or you’re having to branch off a DamageSpell subclass and reparent all your spells with damage…

Godot has lots of tools for interfaces without using inheritance. As you noticed, every subclass of Object has a has_method() property, which means you can build queryable interfaces into things:

func try_method(method: String) -> bool:
    if !has_method(method):
        if is_instance_valid(delegate): # Do we have a delegate handling messages?
            return delegate.call(method)
        else: # No delegate, no method, no joy.
            return false
    call(method)
    return true

Godot also has Callable and Dictionary, which means you can make a map of names to functions that you can populate either at build or runtime:

var ActionDict: Dictionary = {
    "start-cast": _start_cast,
    "explode": _explode
}

func do_action(action_name: String) :
    if !ActionDict.has(action_name): return
    ActionDict[action_name].call()

func has_action(action_name: String) -> bool:
    return ActionDict.has(action_name)

func _start_cast() -> void: # Start casting the spell...
   [...]

I’ve left out arguments for simplicity, but call() can supply arguments as well. You could also use the default argument to Dictionary.get() to supply a default message handler:

func do_action(action_name: String):
    ActionDict.get(action_name, _default_method_handler).call()

These tools give you a dynamic message interface system that’s free from the artificial constraints of inheritance-based OOP.

7 Likes

I disagree, maybe as a beginner coder it kind of makes sense, to not overload yourself with too many concepts. However eventually a bigger project will be attempted and its far easier to bolt extra functionality when code is properly organised into the principles of OOP.

OOP can absolutely tie you in knots whilst trying to work out what classes should be responsible for what, and their associated methods. Thats it biggest downfall, but if you can get past that to some degree or another its still be better approach for anything other than simple projects.

The irony is that Godot (or any other publicly available game engine for that matter) wouldnt exist without it strongly being written with OOP in mind.

Every time you add a node to a scene and add a component to it you are using OOP.

6 Likes

I disagree. While there are times OOP can be somewhat useful, I’d argue it has been a vaguely specified (I bet you’ll find that your “principles of OOP” don’t exactly match anyone else’s…), massively overhyped mistake that has set the industry back significantly. Maybe it was a mistake we had to make, but the problems were obvious even by the mid 1990s, and by 2000 I’d been a close up witness to more than one high profile C++ disaster project.

You do not “get past” the stage where you’re figuring out what classes should be responsible for what, unless you’ve got a perfect plan at the outset and know exactly what your program is going to do and how. Otherwise, you’ve got evolving requirements in a system that ossifies quickly; refactoring a major requirement change in an existing inheritance hierarchy is hard and time consuming. Enough so that later in large projects it’s often easier to hack around, which is what tends to happen. Particularly as deadlines close in.

Inheritance is fundamental to this problem; it ropes everything in the project together, including things that have no logical relationship. They all have to fit in that one tree somehow. All that extra unnecessary structure puts artificial constraints on everything, and when one of those artificial constraints winds up in the way, you have to go back and reconfigure the hierarchy to fix it.

There is nothing about building Godot that required OOP. I have built several game engines myself, over the decades, including one on which we shipped ten games on a mix of Windows, Wii, Nintendo DS, Sony PSP, iOS, and Android, and could have shipped on other consoles, Linux, *BSD and Mac. It was entirely in C except for platform support code that required native (C++/ObjC/Java) code. That’s not to say it didn’t have “objects” in a sense; several things including the stream code and the UI nodes had what were effectively vtables, but they were implemented as function pointers and had no inheritance hierarchy.

Modular structuring of code and clean interfaces between modules are increasingly necessary as a project scales, but you can (and should) do those in any programming paradigm.

OOP as it currently stands is a trap for large projects. It looks efficient and elegant, but it has fatal flaws. It is very, very good at enabling you to create complexity, and it is very, very bad at giving you tools to manage complexity. As the inheritance hierarchy deepens and widens, early design assumptions get baked in.

You particularly see this when the requirements change a bit and you need two subsystems that haven’t communicated before to communicate; the “Can’t get there from here.” problem is a perennial one in OOP, which is why a lot of OOP systems also embrace message busses. Unfortunately, weaving an unordered ad-hoc flow control mesh through your code turns out to scale badly as well. It’s like people read the Intercal spec and didn’t realize COME_FROM was intended to be a joke.

OOP has some limited uses for which it is actually reasonably good. Constrained, well defined systems of similar things; the node system in Godot, arguably, or UI widget libraries (though I still think the problem of making a truly good UI system is unsolved…). Other than that, OOP is at best unhelpful and usually actively harmful. Yes, you can program anything in OOP; if the language is turing-complete you can write anything you can write in any other turing-complete language. But there are better ways.

3 Likes

To a point I agree, there is no perfect way of doing OOP, doesn’t mean you shouldn’t though.

They dont all have to fit into ‘one tree’ at all. I suppose they do in that in C# for example everything is an ‘Object’. A bit like the whole of the human race starting from one ancestor. But we still separate ourselves into constructs such as ‘race’.

No but it follows it , pretty strongly, which was my point.

You do you, I have got absolutely nothing against people who don’t want to follow OOP. I did x68000 ASM at Uni 30 years ago, thats about as far away from OOP as you can get.

The argument for and against OOP have been around as long as it has existed. I’m in no way arguing for or against it in any meaningful way, only that if it works for someone , then why not use it.

Same, around the same era; that was a fun architecture to work with. So many registers, at least for the time…

That was the CPU in the Sega MegaDrive/Genesis as well, so after school I got to mess with it a bit too, and then the SH2 in the Saturn was basically IIRC the 68K instruction set…

I was struggling with the same stuff - I really wanted Godot to have interfaces and for the same reasons you do. And I still don’t buy the duck-typing approach that is mentioned in the Godot documentation. If my interface has 10 methods, should I check for each of them? Or just one and assume others are there as well? Do I need to do this for every call? It all feels kind of “hacky” to me.

I ended up embracing Godot’s philosophy, albeit in a different way, by using components. So in my code, if a thing (i.e. scene) needs health, it has a HealthComponent. If you want to interact with the thing’s health, you do something like CompUtils.get_component(thing, HealthComponent) which basically just goes through the children and returns the first one that’s of the requested type. If it’s not found, it returns null which means that the thing doesn’t have health which is equivalent to the has_method check, but for the whole component (i.e. interface) instead of just a single method. A good thing to note (that took me a bit to realize) is that, because your components will almost always inherit a Node[XD], and by doing that they get their own _process() and co. functions, they can also define behavior which you can then add to things, even at runtime.

I still use interfaces for things that are truly shared between all instances of a class. I still find that practical because it allows me to talk to all their instances in the same way without the clunky if checks. For example, all my characters have movement speed at the moment so it’s a property in my base Enemy class/scene. But if that ever stopped being the case (e.g. I suddenly have a static enemy), I’d try to turn that into a component.

Now to talk a bit about your specific example, one of the issues you seemed to have struggled with was that your ProjectileSpell would need to inherit a CharacterBody2D and AreaSpell inherit Area2D which then prevents you from inheriting from the common Spell. I’d say that you can still have your Spell, ProjectileSpell and AreaSpell interfaces if you just look at CharacterBody2D and Area2D as components to have (i.e. as children) and not things to inherit from. These wouldn’t be as public facing as my HealthComponent abouve, but it’s the same principle. In my view, this gives you the best of both worlds. You can still have a common interface for things that truly fit a hierarchy and components for everything else.

So that’s my input, I hope you find it helpful. I don’t have massive projects behind me that would verify this approach scales incredibly well and all that, but it has worked well for me so far. If you find this approach helpful, search around a bit, there are other topics and people that talk about it too.

And if it helps, I myself am still struggling to make my GDScript code as neat as I can make my C++ code neat (although I don’t think this is even possible). But I’m hoping to get at least to something I’m comfortable with :slight_smile:

The 68000 was in everything at the time, an absolute beast of a processor.

We’re getting abstract classes in GDScript in 4.5 iirc, to use as possible interfaces :wink:

1 Like

For big projects in gaming, an anti-thesis of OOP has gained quite a bit of traction these last few years : ECS, which uses Entities, which mixes (composition) data only Components which Systems act upon (the code) by using a query mechanism to operate on all similar Entities at the same time.

I genuinely don’t get the beef with OOP. Containers with encapsulated private data that send messages to eachother is pretty straight forward. Those containers with data and methods being objects, being able to take many forms. It’s a terrific paradigm.

To the OP, gdscript doesn’t have explicit interfaces but you don’t need them. All an interface is anyway is an assurance that an object implements a set of methods, and can thus be considered a type of something.

To do interfaces, you do them the same way you would in python, it’s just duck-typey. You use a bit of inheritance, but in the right spots. The thing that people miss in the inheritance/composition thing is that at the end of the day, you still use inheritance when making compositions. It’s all about subtyping, it’s just a matter of shifting dependencies away.

The pattern I’ve been using is one I learned from UE5.

Have a weapon component as a base. You can attach this to your player. You can create a lot of different weapon subtypes from this base, but they don’t really do much on their own. They mostly hold data and trigger other events. When you fire, you spawn a projectile instance. The projectile is where you’ll get most of your effects. So if you want to shoot a projectile, you do your line trace logic or whatever, if you want something that’s just an AoE, you spawn a “projectile” that’s really nothing more than an object in an area with aoe properties.

This is entirely object oriented. Families of Objects (inherited classes with encapsulated, ie private, data with methods) that are composed of eachother. I don’t get this circle-jerk mentality of “oop bad” in a system that’s largely OO to begin with. Absolutely bizarre. I feel like most people just don’t actually understand what OO even is.

I’ve got a pretty good idea what OO is personally, and I have some pretty good reasons for not liking it. You might want, for instance, to have a read over Data Oriented Design. That goes into some of the reasons why OOP as it is currently practiced is a lousy match for modern hardware, particularly when it comes to CPU caches.

I’d also argue that inheritance in general has turned out to be a bad idea in most cases. Not in all cases; you can occasionally point to a place where inheritance is a reasonable fit. Most of the time, however, inheritance is the wrong tool; you can use composition to do everything inheritance could do (and more) without the forced relationships that come from inheritance.

Composition is not inheritance, and doesn’t need to be built on it. You can potentially use both; an object could inherit composed components. But they are different things. Composition is embedding discrete components in a larger object or structure, while inheritance is essentially a templating system with overrides. The dependency graph of a composition system tends to be much sparser than the dependency graph of an inherited system, because the composition system is explicit and direct (you have to specify what you want to include, from where) while the inheritance system is implicit and cascading (you get everything unless you specify otherwise, and depend on everything above you). That implicit nature of inheritance means every time you make a new kind of object, you’re adding new edges to the dependency graph.

Those dependency relationships come with design ossification and maintenance costs. They make structural refactoring significantly harder, which means in a real project (particularly one that’s already shipped a version and needs maintaining) there tends to be increasing pressure to hack around problems rather than address them directly if they would affect the layout of the object hierarchy. Yes, the right thing to do is to fix the structural problem, but that’s going to take way more time and effort than hacking, and time may be of the essence if your customers are waiting for a fix.

I would also argue that encapsulation has major tradeoffs involved with it. The main reason for the recent proliferation of signals and message busses in OOP systems is to end-run around encapsulation, which means we’ve reinvented GOTO and added ad-hoc multiple-target unordered cross-module flow control to it. Really, we’ve reinvented Intercal’s COME_FROM, which is even worse. This does not feel like a win to me, and I have plenty of empirical evidence to back me up on that.

You have not seen true spaghetti code until you’ve seen a threaded OOP system that makes heavy use of async messaging. Usually the first clue you’re in for a ride is finding random sleep calls in signal handling code where someone’s tried to hack around a signal delivery ordering problem.

As for “data with methods”, see the data oriented design book above.

I have been on more than a few large projects at this point. I have worked on OOP commercial products built in C++, ObjectiveC, Swift, Java, Kotlin, and a variety of other languages. Everything I have seen so far has led me to believe that OOP was a massively overhyped fad that has set computer science back decades. It was already clearly not a great idea in the 1990s, and it has not aged well. It’s telling that a lot of the newest crop of languages (Zig, Odin, Jai…) are leaning away from OOP or making it optional.

My experience has been that OOP as a design philosophy is a great way of getting too far out over your skis. It makes it extremely easy to build complex systems, but gives you terrible tools for maintaining or understanding complex systems. It is very easy to get to a place where nobody knows how the whole system works any more, which means it’s very easy to get to a place where bugs arise from internal interactions that nobody understands.

This is entirely leaving aside problems with specific OOP implementations you can actually use, which are a rich field in which many rants could be grown.

1 Like

Hear, hear ! Very well said :wink:
Forgot to link the DoD book when linking ECS and flecs :+1:
Cheers !

I understand object orientation is actually a pretty good idea and if written properly will help your coding a lot.
If you want maintainable, neat and modular code (meaning that a small change somewhere does not have an affect somewhere else) then OOP is one way to go.

One of the most interesting uses for object orientation is to represent your game objects with a Platonic philosophy, to generate a taxonomic tree of things with properties that are useful in the game. This can be achieved with inheritance and composition, and doesn’t necessarily become unreadable or unmaintainable.

For example, you might want to check if a flame has touched something flammable, or if a chemical reacts with another.

The system is already object oriented but imagine if Godot had already designed a world object philosophy but they had got it all wrong or perhaps the implementation didn’t work for your code because they had made it clunky or inaccessible. (I.e. if there was a ‘Dog’ type called ‘canis familiaris’ of order ‘carnivora’ and you just want to create a type that ‘barks’ and fetches ‘sticks’)

That is why scripting is so useful, you can define all your non system objects for your unique game world in the script - you can also make modifications and fix them.

But if you wanted to use external data or text files or arrays of numbers, for your object types, you could also put that into script.

Edit: For question posed by the OP : i would expect ProjectileSpell could contain a Character2D object and AreaSpell could contain an Area2D. That would be a composition pattern. The other point is that they are all objects anyway, (recounted or node) and can fit into a dictionary or array, so a list of spells can still have the polymorphic attributes so long as they implement the necessary functions. As noted above, Entity Component systems are popular but they also have their drawbacks.

1 Like

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