Best Practices for GDScript Code Architecture with Abstract Classes and Node Inheritance

Godot Version

v4.2.1 stable

Question

I have a general questions about code architecture in gdscript as it’s quite different than other languages I’ve used.

As a learning exercise, I want to write code that lets me get the amount of light at a given point. Here’s what I came up with

Base class LightProducer

class_name LightProducer
extends Node

static var group_name = "light_producer" 

func _ready():
    add_to_group(group_name)

func get_light_level_at_point(position: Vector3) -> float:
    return 0.0

Specific child for OmniLightProducer

class_name OmniLightProducer
extends LightProducer

@export var omni_light: OmniLight3D
// other fields to impact light

func get_light_level_at_point(position: Vector3) -> float:
    // Calculate the light level on the point based on the omni_range, etc

    return result

Specific child for SpotLightProducer

class_name SpotLightProducer
extends LightProducer

@export var omni_light: OmniLight3D
// other fields to impact light

func get_light_level_at_point(position: Vector3) -> float:
    // Calculate the light level on the point based on the spot_angle, etc
    return result

Which I later intend to use as:

func get_light_level():
    var total_light_amount = 0.0

    // Ideally I reference the static group name to avoid spelling issues
    for light_projector in get_tree().get_nodes_in_group(LightProjector.group_name):
        total_light_amount += light_projector.get_light_level_at_point(self.global_transform.origin)

    return total_light_amount

Performance concerns aside, this works. However:

  1. LightProducer, which should never be instantiated, appers in the node tree because it has class_name.
  2. On LightProducer, get_light_level_at_point shouldn’t really be called, but can’t be marked abstract/interface
  3. I’m unclear if making these nodes siblings of the respective lights and connecting with with @export var omni_light: OmniLight3D is the right approach

Finally I’m not sure this is the best approach overall give then above limitations and wondering how else I might approach this problem.

By “node tree” I assume you mean that dialogue you get when you go to add a new node. In that case, it should be there because it’s a class, and it extends Node.

On the declaring of abstract methods. Yeah, it’s not pretty. I do what you did; put an empty-as-possible method into the base class and then override it. It’s not ideal.

Not sure I follow your last question (3).

hth

As dbat said, I think you’re doing it the right way. There’s no way to mark a method as abstract and there’s no support for interfaces yet.

I’m also unclear on #3, but in general you use @export to avoid hard-coding settings/resources. It’s only for the editor, though. All variables and functions are always “public” in all classes… The convention is to use underscore in front of name of any member that’s not supposed to be used/called outside of the class: _my_var := 0.5 or func _my_method().

Ofc, children of a class can still call underscore methods in the parent, for example: super._ready().

PS: I’m not clear why you’re worried about performance, perhaps I lack some knowledge here. But afaik, if your game is slow b/c of inheritance then wow, you’ve done an incredible and time-consuming job of really, really, REALLY optimizing all other parts of the game!

1 Like

Thanks to both of you for your reponses, sorry lack of clarity I’m still quite new to Godot and unfamiliar with the terminology

By “node tree” I assume you mean that dialogue you get when you go to add a new node. In that case, it should be there because it’s a class, and it extends Node.

Ya that’s correct, I’m not sure what to call that dialog. I would not want the base class LightProducer to be in that dialog since it has no meaning as a node and might cause unexpected behaviour since it is effectively an abstract class

I’m also unclear on #3, but in general you use @export to avoid hard-coding settings/resources. It’s only for the editor, though. All variables and functions are always “public” in all classes… The convention is to use underscore in front of name of any member that’s not supposed to be used/called outside of the class: _my_var := 0.5 or func _my_method().

For #3, what I mean is there are multiple ways to associate associate nodes with one another so I’m curious how you might make that decision / what best practices exist.

For example, in my original post I would have the following setup & I would connect the node that I used @export on via the inspector:

- Root
  - OmniLight3D
  - OmniLightProducer (referenceing OmniLight3D)

But there are other options that I could have gone with -

Example #1: The *LightProducer as a child:

# - Root
#   - OmniLight3D
#     - OmniLightProducer

var omni_light: OmniLight3D

func _ready():
	omni_light = get_parent() # with some error checking if the type is wrong


Example #2: *LightProducer as a parent of the light (similar to CharacterBody3D and Collision shape work)

# - Root
#   - OmniLightProducer
#     - OmniLight3D

# Omnilight3D
func _ready():
   # With some error checking or maybe _get_configuration_warning
    var omni_light_child = get_omni_light_child() 		

func get_omni_light_child() -> OmniLight3D:
    for child in get_children():
        if child is OmniLight3D:
            return child
    return null


Example 3: A New script on Light to get the light producers and assign itself

extends OmniLight3D 

func _ready():
    var light_producer_child = get_light_producer_child()
   light_producer_child.omni_light = self #
   		
func get_omni_light_child() -> OmniLightProducer:
    for child in get_children():
        if child is OmniLightProducer:
            return child
    return null

I think what I went with is reasonable but I guess my concern with @export is if I set the reference explicitly, it might be easy to lose track of how nodes are associated since they could appear anywhere in the tree and the only thing keeping them easy to find would be convention of collocating them.

PS: I’m not clear why you’re worried about performance, perhaps I lack some knowledge here. But afaik, if your game is slow b/c of inheritance then wow, you’ve done an incredible and time-consuming job of really, really, REALLY optimizing all other parts of the game!

Here I just meant that i’m not worried about performance and mainly wanted to focus on code clarity / understanding of how to architecture things in Godot

Makes sense, maybe others have a solution, but I’m not aware of one. Just name the parent node (the name in the scene tree) “LightProducerAbstract - DO NOT ADD TO SCENE TREE” haha. :man_shrugging:

As to the rest… this is typical composition vs inheritance debate. Use inheritance if the two objects are of the same core type, like Enemy base class that both MeleeEnemy and RangedEnemy may inherit from, and then actual instances of enemies will inherit from those (SkeletonWarrior inherits from MeleeEnemy; SkeletonArcher - from RangedEnemy). Inheritance is valid when a child class inherits all of the functionality of the base class + adds more.

Going through Godot objects themselves to see how self-contained they are, how each child class only adds the least about needed for that child class, etc. could be helpful in determining your own structure. I think Godot gdscript api is a great example to follow.

Use composition when it’s a utility object or related to another object such as being a part of it. For example, same Enemy class could have @export var head, @export var weapon, @export var visibility_notifier, etc.

Maybe visibility_notifier (an actual Godot node) is a good example for you. It must be attached to another node to work, but it doesn’t inherit from any specific node b/c it can work with ANY node as far as notifying if it’s on screen.

You could absolutely say that Health, Attack Animations, Hitbox, etc. are all inherent properties of Enemy… but you could also abstract them (as most Godot projects do) and have HealthComponent, AttackAnimationComponent (which itself would have @export var char_skeleton or w/e), Hitbox/Hurtbox components, etc.

In the end, w/e you find easiest to maintain should generally win the battle; that’s where most of the time goes on a given project - maintenance/rewriting. Answers can be very different for smaller projects vs larger single-person projects vs projects with many team members, so it’s pretty hard to advise your specific scenario.

Possibly helpful video: https://www.youtube.com/watch?v=rCu8vQrdDDI

2 Likes
extends "res://light_producer.gd"

You can extend scripts from their file_path
Also, I wouldn’t recommend it, you could just free it as soon as it’s instantiated.

extends Node // We're in light_producer.gd

func _init() -> void:
    queue_free()

You’d have to make sure you override the _init method in all of the extending classes though. Apart from this there is nothing I’ve found to prevent classes from being instantiated.

1 Like