Here Comes Godot

2025-10-28, Tue

What if we consider the Godot engine1 as a script interpreter first? – specifically, as an interpreter for GDScript. This post intends to record hiccups I've encountered while taking this path.

1. Environment Setup

Of course, the first step is to compile Godot in local environment and add godot to user path. Follow the official documentation for instructions on compiling for macOS2 and Linux3.

The following step is to config text editor. Take Emacs as example, we need to:

  • Install gdsript-mode from MELPA4
  • Add eglot-ensure to gdscript-mode-hook5
  • Start Godot editor as LSP Server

A sample config looks like this:

(add-hook 'gdscript-mode-hook 'eglot-ensure)
(add-hook 'gdscript-mode-hook 'company-mode)

2. Sample Scripts

Now it's time to draft some "Hello World" script, e.g. for hello-world.gd:

#ref: hello-world.gd  
extends SceneTree

run the script with godot:

godot -s hello-world.gd

And we would have a dialog window showing up, meaning that the script has been executed successfully.

In order to display static text in the dialog, we could use a label. e.g.

#ref: hello-world-v2.gd
extends SceneTree

func _initialize():
        var label = Label.new()
        label.text = "Hello, World!"
        label.add_theme_font_size_override("font_size", 48)
        root.add_child(label)

Run the script again, and we should be able to see text now.

hello-world-gdscript_400x300.jpg

Figure 1: Hello World in GDScript

If you are into CLI instead of GUI, we could make things easier by extending MainLoop:

#ref: hello-mainloop.gd
extends MainLoop

func _initialize():
        print("Hello, World!")

func _process(_delta: float):
        return true # return true to terminate the program

Now run godot --headless -s hello-mainloop.gd to see result in only terminal.

3. Import Class From Other Scripts

We could use @GlobalScope.preload() to import class defined in other scripts. e.g. Person.gd defines a class called, well, Person:

class_name Person

static var max_id = 0

var id
var name

func _init(p_name):
        max_id += 1
        id = max_id
        name = p_name

In some other script, we could import the class like this:

#ref: Test_Person.gd
extends MainLoop

const Person = preload("Person.gd")

func _initialize():
        var person1 = Person.new("Jone Doe")
        var person2 = Person.new("Jane Doe")

        print(person1.id)
        print(person2.id)

func _process(_delta):
        return true

4. Create, Save, Load, and Instantiate Scene

These steps could usually be achieved through Editor. However, what if we want to get them done only in script intead?

In order to create and save a new scene, we could use the ResourceSave.save() method, e.g.

extends MainLoop

var label: Label

# godot --headless -s create-and-save-scene.gd
func _initialize():
        label = Label.new()
        label.text = "Hello, World! (from scene)"

        var scene := PackedScene.new()
        scene.pack(label)

        var error := ResourceSaver.save(scene, "out/hello-world.tscn")
        if error != OK:
                print("Save scene failed")

func _process(_delta): return true


func _finalize(): label.free()

Note that the reason why we introduce _finalize() to free label is to get rid of the following warning messages on memory leak:

WARNING: 1 RID of type "CanvasItem" was leaked.
     at: _free_rids (servers/rendering/renderer_canvas_cull.cpp:2690)
WARNING: ObjectDB instances leaked at exit (run with --verbose for details).
     at: cleanup (core/object/object.cpp:2570)

Try to comment out the _finalize method to trigger this message.

Also note that the file name should end with suffix .tscn, otherwise ResourceSaver.save() will fail.

Now let's load and instantiate the scene from file.

extends SceneTree

var scene := preload("out/hello-world.tscn")

# godot -s load-and-instantiate-scene.gd
func _initialize():
        var instance := scene.instantiate()
        root.add_child(instance)

Footnotes: