Godot’s Node System, Part 1: An OOP Overview

This article is part of a series.
Part 1: An OOP Overview
Part 2: Framework Concepts
Part 3: Engine Comparisons

Edit 1: Others graciously corrected my mislabeling of Component-based frameworks as a variation of the ECS paradigm. Added a Data-Oriented Design Disclaimer section to prevent misinformation.

Introduction

My last post reviewed Godot Engine. It’s a strong open source competitor to Unity and Unreal Engine 4, among others. While I covered each engine’s scripting frameworks already, I wish to do that analysis more justice.

Newcomers to Godot are often confused about how to approach Godot’s nodes and scenes. This is usually because they are so accustomed to the way objects in the other engines work.
This article is the first in a 3-part series in which we examine the frameworks of Unity, Unreal Engine 4, and Godot Engine. By examining how Unreal and Unity’s frameworks compare, readers may be able to ease into Godot better. Readers should come away understanding Godot’s feature parity with the other engines’ frameworks.

Discussing the engine comparisons requires prior understanding of basic concepts, first with programming and then with game frameworks. The first two articles each respectively cover those topics. If you feel you are already familiar with a given topic, feel free to skip to the next article. Let’s go!

main-qimg-19f14ae4b88596379962f539b2973525

What is OOP?

Game Engines tend to define a particular “framework”. They are a collection of objects with relationships to each other. Each framework empowers developers to structure their projects how they want. The more a framework enables the developers to organize things as desired, the better it is.

To do so, they use Object-Oriented Programming (OOP) whereby users define groups of related variables and functions. Each group is usually called a “class”, an abstract concept. In the context of a class, the variables are “properties” and the functions are “methods”. Creating a manifestation of a class gives you an object, a.k.a. an “instance” of the class.

OOP has several significant elements to it. This article explores each of them in later comparisons. Therefore, it is imperative that we dive into them now.

oop5b15d

The Big 3 of OOP

Devs want users to know how to use a class, but not how it operates. For example, if someone drives a car, they don’t need to know the complexity of how the car drives. Only how to instruct it to drive. Driving might involve several other parts. It might involve data and behaviors the user knows nothing about. The car abstracts away the complexity of the task (Abstraction).

Below is a simple GDScript example of Abstraction. The user is really dealing with a complex collection of data: length, width, and area. But certain rules apply. The area is not editable, and its value changes as length and width change. Also, the user needs to refer to the length and width together. The dev has made it possible for the user to organize a complex concept into a single logical unit.

# rectangle.gd
extends Reference
var length = 1
var width = 1
func get_area():
    return length * width

Users want to edit data or “state”. Devs don’t want users to access the data though. They instead opt to enable users to issue instructions on how to change a class’s data. Devs can then change the data’s structure without affecting the program’s logic. For example, the user can explain that they want to “add an Apple” to the Basket, but how the Apple is stored relative to the Basket is the devs’ concern, not the user’s. Devs should also be able to control the visibility of data to other devs (Encapsulation – Definition 2).

This type of Encapsulation guarantees flexibility of data structure. For example, what if the user attempts to set the length directly?

# main.gd
extends Node
func _ready():
    var r = preload("rectangle.gd").new()
    r.length = 5

Well then, what if we later decide to store the data as a Vector2? That is, a struct containing two floats together? Each Vector2 has an “x” and a “y”.

# rectangle.gd
extends Reference
var vec = Vector2(1, 1)
func get_area():
    return vec.x * vec.y

Well with the change, “length” is no longer a property. The user now has to search their entire code base for every reference to r.length and change it to r.vec.x. What if instead, we encapsulated the data behind instructions to change the data, as methods? Then…

# rectangle.gd
extends Reference
var _length = 1 setget set_length, get_length
var _width = 1 setget set_width, get_width
func get_length():
    return _length
func get_width():
    return _width
func set_length(p_value):
    _length = p_value
func set_width(p_value):
    _width = p_value
func get_area():
    return _length * _width

# main.gd
extends Node
func _ready():
    var r = preload("rectangle.gd").new()
    r.set_length(5) # data change is encapsulated by class's method.
    r.length = 5 # in GDScript, the setget keyword will automatically force a call to the method rather than direct access.

Now, if we want to change the data, we don’t need to modify the program. Either way, we are only calling set_length(5). Only the object, a self-contained area, must be modified.

# rectangle.gd
extends Reference
var _vec = Vector2(1, 1) setget set_vec, get_vec
func set_vec(p_value):
    pass # block people from setting it directly (if desired)
func get_vec():
    pass # block people from accessing it (if desired)
func get_length():
    return _vec.x
func get_width():
    return _vec.y
func set_length(p_value):
    _vec.x = p_value
func set_width(p_value):
    _vec.y = p_value
func get_area():
    return _vec.x * _vec.y

Users want to refer to a specialized class and a basic class with a common vocabulary. Let’s say you create a Square which is a Rectangle. You should be able to use a Square anywhere you can use a Rectangle. It has the same properties and methods as a Rectangle. But, it’s specialized behavior sets it apart (Inheritance).

The rule of a Square is that it is a Rectangle, but it’s sides are equal at all times. For this reason, the length and width are both referred to as its “extent.” Let’s change its instructions to maintain that rule.

# square.gd
extends "rectangle.gd"

# first, we setup the new rule
func set_extent(p_value):
    _vec = Vector2(p_value, p_value)

# Then, we override the original setter instructions.
# Now, only the Square's actions will execute
func set_length(p_value):
    set_extent(p_value)
func set_width(p_value):
    set_extent(p_value)

# main.gd
extends Node
func _ready():
    var s = preload("square.gd").new()
    s.set_length(5)
    print(s.get_area())

Note how Square explicitly creates a specialized version of the set_length() method. It keeps the width equal to the length. The Square can also still use get_area(), even though it doesn’t define it. That is because it is a Rectangle, and Rectangle does define it.

Now, specialized classes may or may not have read or write access to all the base properties and methods. GDScript doesn’t support this type of Encapsulation, but C++ does. C++ has what it calls “access modifiers” that determine what can only be seen by the current class (“private”), what specialized types can also see (“protected”), and what can be seen by all objects (“public”). This controlled access within a class allows for content to be encapsulated within particular layers of inheritance hierarchies (Encapsulation – Definition 1).

class Rectangle {
private:
    int _area;
    bool _area_dirty;
    void _update_area() {
	_area = _length * _width;
	_area_dirty = false;
    }
protected:
    int _length;
    int _width;
public:
    int get_area() {
        if (_area_dirty) {
            _update_area();
        }
        return _area;
    }
};

class Square : public Rectangle {
public:
    void set_extent(int p_value) {
        _length = p_value;
        _width = p_value;
    }
};

While the area content is locked to the Rectangle class (Square has no need for changing how area works), the Square still needs access to _length and _width in order to assert equivalent values for them. Square can only reference “protected” data in its set_extent() method, so area is inaccessible. This encapsulates the “area” concept safely within the Rectangle type.

In cases where you don’t want even the specialized classes to see or access data, access modifiers of this kind can be very useful in assisting the encapsulation, i.e. “hiding” of data from other parties.

growthhacker

Advanced Concerns

Developers want to minimize how much they need to change the code. How? Replace instructional changes with execution changes where possible. The instructions on how to use an object are its “interface.” The way an object performs an instruction is its “implementation.” Devs refer to a program or library’s public-facing interface as the Application Programmer Interface (API) (Interface vs Implementation).

You saw an example of the need for maintaining an interface with the first Encapsulation: if the user calls set_length() in all cases, then they don’t need to concern themselves with how it is performing its operations to change the data. An API is more generic though. If a user creates another object that also has a set_length() method, then the objects match the same API.

Some languages, like GDScript, use duck typing, and will allow the user to call the method simply because it exists when requested. Other static languages like C++ and C# will require the user to convert the object into a type that has the needed function strictly defined (if the language supports interfaces like C# or a form of it like C++).

A specialized class should execute its behavior in place of the base class’s where applicable. For example, let’s say Animal has specialized classes Dog, Monkey, and Human. If Animal has an “isHumanoid” method, each of them should have a specialized response to it (false, true, true). Accessing the “isHumanoid” method should always call the specialized response if possible (Polymorphism).

Below is an example of Polymorphism in GDScript. It outlines how the user can request the same information from different objects, but each one, depending on its type, provides a specialized response. There is no risk of the created WindowsOS somehow printing an empty line. Because Mac and Linux inherit from Unix, they inherit a polymorphic response, i.e. a response unique from the Windows response.

# main.gd
extends Node

class OpSystem:
func get_filepath_delimeter():
    return ""
class WindowsOS:
    extends OpSystem
    func get_filepath_delimeter():
        return "\\" # the first backslash is "escaping" the second
class UnixOS:
    extends OpSystem
    func get_filepath_delimeter():
        return "/"
class MacOS:
    extends UnixOS
class LinuxOS:
    extends UnixOS

func _ready():
    print(WindowsOS.new().get_filepath_delimeter())
    print(MacOS.new().get_filepath_delimeter())
    print(LinuxOS.new().get_filepath_delimeter())

To avoid over-complicating specializations, it can be useful to establish ownership between classes. If one class owns another class, then an instance of the second may only belong to a single instance of the first. Using ownership in place of specialization provides many advantages. (Aggregation).

  • It is easy to change a class’s ownership from a second class to a third class. It is difficult to change a single class’s specialization to another specialization (Loose Coupling).
  • Favoring Aggregation guarantees protection of the owned class’s data. This supports Encapsulation by ensuring that the class only has to worry about its own data and implementation (Separation of Concerns).
  • Aggregation abstracts away the complexity of using several classes at once. Inheritance abstracts away only the usage details of a single class. Inheritance has a deeper effectiveness for abstracting a single class’s complexity. But the tight coupling inheritance generates is often not beneficial in the long run when Aggregation is an option (stronger Abstraction).

In some cases, developers may want a class to rely on another class for its very existence. One class owns the other, but it is stronger than Aggregation. The owner creates the owned object directly, and if the owner dies, so does the owned object (Composition).

For example, computers and software have this relationship. Turning on the computer starts the software. Shutting it off kills the software. The software has a strong dependency on the computer. It “composes” the computer.

Below is a GDScript example of these concepts.

# main.gd
# Assuming a scene structure like so...
# - Node2D "main"
# - - Node2D "child1"
# - - - Sprite
# - - Node2D "child2"
# - - - Sprite
extends Node2D
func _ready():
    var texture = preload("icon.png")
    $child1/Sprite.texture = texture
    $child2/Sprite.texture = texture

Now, a key feature here is that each “child” isn’t a single object that has a texture property with logic to manipulate images. Instead, all of that is self-contained within a Sprite. The “child” nodes are free to have a variety of other features, but the Sprite tidbit is narrowly confined to that one node. This illustrates separation of concerns.

Because a node can be added and removed easily, it also shows loose coupling. Technically speaking, we could place another node of a different type where the sprites are, give them the name “Sprite”, and make sure they have a property called “texture”. If that were the case, the program would execute the same way. It isn’t tied strictly to the use of the Sprite node exactly.

In this example, the texture asset is aggregated while each Sprite node that uses it composes its parent “child” node. If the user deletes child1, its child Sprite will die with it. This is because of the Sprite’s compositional relationship to the parent node. But that doesn’t mean the texture is unloaded from memory, i.e. the image is not deleted. It is still in use by child2’s Sprite.

In this way, each Sprite “owns” a reference to the texture, but the texture doesn’t depend on any particular Sprite to exist. The Sprites do depend on a particular node (their parent) to continue existing though. That is composition.

8df8xts

The Birth of Component-Based Systems

In game development, relying on inheritance alone is messy. Combining inheritance with aggregation and composition gives developers great flexibility.

Developers often want to describe game concepts as a set of attributes and behaviors. In the early days, devs took a naive approach to defining these qualities. They might define a basic object and then ever more specialized objects for all purposes of game development.

That quickly became too difficult to manage though. If two specialized classes need common behavior, devs had to give it to a basic class. The “basic” classes soon became too complex to maintain.

# main.gd
extends Node
class Bird:
    func fly():
        # REALLY complex flying logic here. Expensive to reproduce and maintain
class Camera:
    extends Bird
    func record():
        # REALLY complex recording logic here. Same story.

Let’s say there is functionality in a class somewhere that could be really useful somewhere else. The Camera needs the movement logic to fly smoothly through the environment. Well, if that logic has already been written in the Bird class, then it can make the job a lot easier to just have the camera extend the Bird class and get the flying logic for free. Then the user can just add the camera logic.

But that poses a significant problem. If the camera is a bird, it suddenly also has a lot of unrelated bird functionality too: feathers, a bone structure, animations, chirping logic, wings, etc. And what if the user creates different types of cameras? What if the user makes a TrackCamera that moves on a track that doesn’t even require free movement like the bird provides? In order to get the Camera logic, the TrackCamera now has to carry all of this unnecessary Bird logic!

To solve this, developers created a new technique: create an empty container of attributes and behaviors. Then add only what you need! Devs call these empty containers “entities”. The attributes and behaviors are then grouped into “components” and added to an entity. Destroying the entity also destroys the components.

Through composition, devs could finally design their objects with only what they need. Nothing more. If the demands of an entity change, you add and/or remove a component. If the nature of a particular component needs to change, no problem! Swap out the component with another one that meets the same interface, but provides a new implementation.

Data-Oriented Design Disclaimer

This Component-based architecture is not to be confused with the often-used-interchangeably Entity-Component-System (ECS) pattern that is a staple of Data-Oriented Design (DOD). DOD is useful when one is primarily concerned with the hyper-performance of a game engine. It stresses techniques that organize programs around data and transformations rather than abstractions of real-world concepts. ECS is the latest illustration of this practice whereby…

  • Entities are merely an ID number.
  • Components are bundles of data.
  • Systems are stateless transformations of Components’ data.
  • Components are associated with Entities by their ID.

The lighter version of this design (which we cover here) foregoes the System aspect and merely abstracts its Objects into entity or component arrangements. The subdivision of objects into their components is still practiced. Users can simplify their Object into containers for those components. However, often the entity and/or components still execute behaviors rather than having systems handle it.

Note that a component-based design solves many of the issues with Object-Oriented Programming’s love for inheritance, but it does not meet DOD goals if one wishes to program that way. There is a general trend of moving towards more ECS-like paradigms. The major engines aren’t quite there yet, but it appears as though Unity has hired some experts to help them focus more on that style down the road.

documents-1024x576

Conclusion

Hopefully that wasn’t too much for your brain! By now you should have an introductory understanding of Object-Oriented Programming and its applications in developing software. There are many more, far more detailed explanations for each of the reviewed concepts, and the links provided are simply a way to get you started.

The keywords mentioned should give you the means to find more information if you need a better understanding. Once you feel comfortable with the concepts of inheritance, ownership, and other programming principles, dive into the next topic: today’s gaming framework designs.

As always, please comment with any feedback you have, especially if something is in error or confusing. I frequently update my work over time to try to maintain its integrity.

4 thoughts on “Godot’s Node System, Part 1: An OOP Overview

  1. This is a really awesome post! I greatly enjoy the way you explained the motivation behind aggregation vs composition and many other concepts! Your posts are sooo much better then books on the topic that tend to be too abstract with too “artificial” examples!

    Liked by 1 person

    1. Thanks for your feedback! I actually just read over this article again and realized I have an error in the aggregation/composition section. The fact that Godot’s nodes can be made not a child of another node means that they aggregate, not compose, each other. Composition would happen if detaching a node we’re not possible and it was exclusively the parent node’s method abstractions that controlled the adding and removing of child nodes (where they can’t exist separately).

      Will have to amend the post. XD

      Like

Leave a comment