GDScript: Godot's Programming Language

The Sales Pitch

GDScript bills itself as a "high-level", "object-oriented", "imperative", "gradually typed", programming language with python like syntax optimized for Godot Engine's idiosyncrasies.

My Sales Pitch

GDScript is tree-oriented, unmanaged, event-oriented, concurrent programming language. Using Godot's scene tree and asynchronous programming, one can emulate advance concurrency patterns. GDScript is enmeshed with the concept of Godot's scene tree and node grouping. It's design is somewhat unique, possibly only shared with Vala

Never used Vala, just kinda skimmed the docs. Additionally, Vala is designed for GNOME's GObjects (and in general GTK). This places Vala into a somewhat similiar space as GDScript.

Tree-Oriented Language

Originally, this was gonna be "object-oriented", but I decided that was a boring detail that the docs elaborate on enough. The thing I want to focus on here is GDScript's tree-orientation. The Scene Tree is a central piller of the Godot runtime. It's the main source of events and structure.

Components

One common design pattern with Nodes in Godot is child-nodes augmenting the abilities of their parents. A FollowPlayerNode could give any Node2D it's attached to the ability to follow the player. This become very useful for making resuable components and implementing faux multiple inheritance.

Groups

The Scene Tree additionally provides the concepts of "node groups". These are named collections of nodes that can be retrieved and dispatched on. They allow for loosely structured access to nodes in disjointed parts of the tree.

Additionally, groups can be querried allowing for tagged interactions:

func interact(source: Node, target: Node) -> void: 
    if source.is_in_group(&"player") and \
            target.is_in_group(&"werewolf"):
        InteractionPlayer.play("player", "werewolf")

    elif source.is_in_group(&"werewolf") and \
            target.is_in_group(&"player"):
        InteractionPlayer.play("werewolf", "player")
    .
    .
    .

Unique Names

Godot also features the concept of "unique names". These are nodes whose names are globally accessible within their scene. Unique nodes are accessed via %NodeName. Additionally, these accessors can be chained together %Player/%LeftHand/%Weapon.

Unique Names and Node Groups are the two most powerful ways to avoid the scene tree bleeding into your logic. As useful as the scene tree is, letting it influence the structure of you code will bit you. At some point logic will flow across to many layers and be unmanagable.

An Unmanaged High-Level Language

GDScript has no garbage collector in the classical sense. It does support reference count. Many resource types (textures, audio clips, etc) inherent from this. The default type when defining a class is now RefCounted. However, Object, the top of GDScript's class heirachy, has no automatic memory managment.

Every instance of Object must be manually destroyed with free(). The Node subtree extends this with queue_free() which defers deallocation until the end of the current frame. Node additionally will queue free it's children.

Amusingly, objects can also *cancel* their deallocation via cancel_free(). I definitely feel like you are in "deep crimes" when this method gets called. I have never used it.

Destructors can be ran by listening for the NOTIFICATION_PREDELETE message.

func _notification(what: int) -> void:
    match what:
        NOTIFICATION_PREDELETE:
            free_node_pool()

I've gotten up to some shit with this method. Some nasty shit.

Luckily, invalid pointers are not as unreleable as they are in C. All references to the underlying data will become "invalidated". The validity of a reference can be checked with is_instance_valid.

This will come up later in concurrency.

Event-Oriented via Signals

Mentioned in the tree-oriented section, Godot is centered around events. Events from inputs, events from Godot's application life cycle, events from various servers such as physics events, and events from user defined code.

Signals

Signals provide a mechanism of defining custom events. GDScript 2.0 added first class support for signals. They are now values like any other type in GDScript. This means it is possible to construct high-order events-based operations.

func wait_on(event: Signal) -> void:
    await event

You can take full advantage of Godot's concurrent runtime using signals. They allow for making non-blocking operations that execute over time (such as cutscenes, dialogue scrolling, and AI behaviors). The additionally provide another means of escaping the scene tree.

Signal Breadboard instead of Signal Proxies

A foolish design to fall into in Godot is daisy chaining a message upwards one layer at time. Propogating signals upwards one scene at a time can lead to a messy trail of state. If a message needs to travel to the root object before anything happens, then that message should be in the root object.

Signals allow for a very "bread-board" shaped design. You will likely find that many important signals start to float their way to the stop of your scene tree.

Concurrency

Godot is an inheritently concurrent system. Since games require soft-realtime actions. Nothing can be allowed to block. Additionally, many pieces of logic need to be preformed over time. Godot provides many default events.

func _ready() -> void:
    # The hook for when a node has entered the tree and is fully initialized
    pass

func _process(delta: float) -> void:
    # The hook for when a node responds to update request
    pass


func _input(event: InputEvent) -> void:
    # The hook for when inputs from mouse, keyboard, etc happen
    pass
    

The core life cycle events all have virtual methods within the Node class. A game in Godot typically starts out by overriding these methods. However, these simple event-hooks eventually become cluttered and logic needs to be better split appart.

This is where Signals come into play. They allow broadcasting events beyond the basic life cycle of Godot. However, signals also implement the ability to suspend any function until they a triggered. A common signal to use in Godot is process_frame.

func long_computation() -> void:
    var done = false
    var steps = 0
    while not done:
        if steps == 10000:
            steps = 0
            await get_tree().process_frame
        done = do_task()
        steps += 1

await proces_frame suspends a function call (and all function awaiting a result) until all nodes in the tree have executed their _process method.

Concurrency in Godot is explained in more detail in Godot is a Concurrent Runtime

await allows for many concurrent operations to be in-flight simultaniously. However, when paired with Godot's manual memory management, problem start to araise.

Unreliable References

Holding references in Godot is unreliable. At any moment a reference can be swept out from under you. Typically, for Node types this isn't as big of an issue. Nodes typically exist within the tree and get queued for deletion. Generally, the lifetime of a referenced Node is the same as the referrer. The most common reference is Parent to Child.

However, more advanced projects doing Weird Tricks with nodes will have problems with use-after-free and double-frees. If nodes are being held outside the tree, and especially if nodes are being held across await boundaries.

Reference can not be safely referred to after a function is resumed. The following could could crash by the time the function restarts.

var x := $Foobar/Baz
x.do_something()

await World.some_event

x.die()

In the time it takes for some_event to happen, x could already be deleted. The correct way to handle this is the following:

var x := $Foobar/Baz
x.do_something()

await World.some_event

if is_instance_valid(x):
    x.die()

Managing lifetimes of your references can quickly turn into a mess. It's better to avoid ephemeral references as much as possible. Additionally, groups provide a means of broadcasting messages to collections of nodes. Additionally, groups can be used to tag individual objects allow you to blind fire messages to objects.

Groups also let you avoid the messy process of maintaing a list of reference.

var mines = get_tree().get_nodes_in_group(&"explosive mines")
  for mine in mines:
    if mine.position.distance_to(World.player.position) > 10:
      mine.explode()

As nodes are deallocated, they will unregister themselves from their associated groups.

Keeping Logic Flat

A point across every section here is "keep logic flat". It's better to have many individual signals at the top level that do one simple thing, than many layers of logic and signal proxying. Each layer is another file, class, node that you have to keep in working memory. The more you code and logic mirrors the tree, the harder maintaining an application will be.

Event-oriented state machiens work well as a means of structuring logic. Small reusable components that augment parent nodes additionally reduce code duplication and rat nesting. components can also ship their own events to ease commincation problems.

Godot pushes you towards a tree-shaped program with may layers of state. But GDScript, after getting more familiar with it, will pull you towards a plug-and-play oriented design. It's best to follow the grain here.