GDScriptNotes
So I got bitten by The Gamedev Bug again, and in lieu of figuring out how to make raylib work with F# or Gleam I decided to do the intelligent thing for once and just learn to use Godot. Godot can be briefly summarized as “Unity except open source and hopefully better designed”, and for all its flaws, I’ve used Unity seriously before and once you climb the learning curve it solves a lot of problems for you. Annoyingly however, while Unity is just scripted in C# or other .NET languages, using Godot mostly requires learning to use GDScript, Godot’s own scripting language. After being burned by how uninspiring Haxe, UnrealScript, and just about every other engine’s homebrew scripting language turns out to be, I’m not terribly excited by the prospect. But I want to make a game about befriending cryptids, so it’s time to swallow my pride and learn GDScript for real.
Note that this is my opinion, and I’m not an expert on Godot or GDScript. It’s my thoughts on going through and learning this stuff, not a detailed scientific analysis. So, enjoy my random notes and rambling so I can learn without my inner monologue getting in the way. Last updated in Feb 2025, looking at Godot version 4.3.
Background
The current version of Godot is 4.3. It turns out that GDScript in some form or another has existed since Godot 2.1, which is the earliest version that documentation exists for on the website. They justify GDScript’s existence by saying “in the early days, the engine used the Lua scripting language. Lua is fast, but creating bindings to an object oriented system (by using fallbacks) was complex and slow and took an enormous amount of code. After some experiments with Python, it also proved difficult to embed.” They’ve also tried out other languages such as Squirrel, and in general have been dissatisfied with issues around memory model/GC, data representation, vector programming and embedding/integration. So they ended up making their own thing.
I am rather existentially miffed by this because, as I said, I have seldom found some random framework’s homegrown DSL to be an improvement on just using Lua or Scheme (or both at once). There’s a fair selection of decent scripting languages these days, and lots more if you’re willing to get kinda weird, so making Yet Another Scripting Language seems counterproductive. There’s also some really bad scripting languages out there, and plenty of useful tooling already in existence for the good ones, so I feel like the bar is pretty high for a new language to be worthwhile.
But, hey, Godot’s been around for a while, I’ve seldom heard anyone really rage at GDScript, and while I think they could hack up Lua to solve all their listed problems, those problems do seem real and valid. Maybe the Godot creators actually know a few things about making languages? Their FAQ certainly suggests they’ve gotten a fair amount of criticism for it, and they’ve kept with it through a few major version changes where they could have gotten rid of it. So let’s suck it up and start playing around to learn for real.
As a bit of an aside, the story around scripting in Godot is actually kinda quirky, in a neat way. Godot itself is written in C++, but it really has three different interfaces for extension: GDScript which we will be looking at, GDExtension which is a way for it to load native-code DLL’s, and then of all things they have a C# API binding – maybe for all the people fleeing Unity’s corporate overlords? Through these last two you can in principle use Godot in any language that compiles to .NET or native code pretty easily, which is most of the languages I’d want to use. However as usual when going off the beaten path like that, your mileage may vary. The Godot IDE-thingy doesn’t support non-C# .NET languages like F# but you can just build them separately and load them with a bit of a C# shim. Just using F# is tempting, but I have learned the hard way that it’s best to actually understand how a system is supposed to be used before you start abusing it. So, here we are.
Words
So, GDScript is a dynamically-typed scripting language with an indentation-based syntax that looks a lot like Python. It is made for tight integration with Godot’s API and structure, and so it has a similarly OO-ish feel. Not exactly my favorite, but it’s workable. Apparently it’s interpreted; no idea yet whether it’s a tree-walking interpreter or it compiles to bytecode, but it doesn’t really matter for real.
Variables are introduced with var
and assigned to with
=
. There’s also const
which introduces
compile-time constants, and comments start with #
.
Functions are declared like func whatever(args): body
. On
the whole it’s obviously Python-inspired, which can sometimes
be a good thing; we’ll see if it’s a good thing when we get to how it
represents data. The indentation-based syntax is not my favorite but is
generally inoffensive to write and easy to read. You have your normal
loops, if statements and function calls, as well as match
statements which actually are proper
pattern matching that can bind and destructure their inputs rather
than being just switch
statements. Pleasantly
surprising.
Loops exist. For loops are Python-style, they iterate over a
collection. If you give them a dictionary, they iterate over the keys of
the dictionary. There’s a range()
function to generate an
iterator-y thing, again like Python– wait, no, the example
docs say it does not allocate an array, but everything else I can
find indicates it does, including the typeof()
function.
I’m curious enough to read the source, but apparently github makes
searching hard enough now that I don’t want to bother. Lesson learned:
don’t use Github. Oh well, doesn’t really matter right now, but
apparently you can create custom iterators if you feel the need to.
There’s also a number of annotations on statements, like
@export
, which mostly don’t change how the code behaves but
rather makes the Godot editor do stuff like show a scroll bar to let you
tweak a value. These things aren’t universally passive though:
@onready
lets you initialize values at a particular (very
common) time in the script object lifecycle and @rpc
lets
you mark a function as callable via remote clients. Unlike Python
decorators, they’re all language builtins, you can’t define your own
without altering the engine. I expect that most people won’t need to use
many annotations, and some people will take great joy in littering them
everywhere to create magical things.
There are also special comments #region
and
#endregion
that you can use to mark a chunk of code as
foldable; the language itself doesn’t care about it but Godot’s built-in
text editor does. My feelings are mixed.
There’s also static
variables, aka class variables. meh,
whatever.
Unlike most scripting languages, GDScript uses reference counting for memory management rather than tracing garbage collecting. So we can expect it to not necessarily be the swiftest memory management possible, but rather have deterministic performance and not consume as much memory. GDScript in general is kinda not designed to be super fast and high-performance, since all the heavy-duty number-crunching is done by the Godot engine’s C++ code. Sensible.
Types
There are lists, dictionaries, integers, floats, all the basics, and
then along with that there are enums and classes. Enums are just C-like
piles of integers, not proper tagged unions, while classes are
traditional OO live-on-the-heap structs-with-vtables. There’s also
built-in Vector2
and Vector3
types, and a
whole zoo of related vector-math things, which are presumably non-boxed
so they are faster to access and update than structs– er, objects
are.
Each file defines a class, kinda like Java. Unlike Java, you don’t
need 30 words of boilerplate and 2 levels of indentation around each
class; you can just write extends BaseClass
at the file’s
top-level if you want to inherit from something, and writing
class_name MyClass
is optional. If omitted the class name
is just the file name. Since Godot by nature includes an IDE and build
system for GDScript then this honestly kinda makes sense. All game
assets including scripts are managed in their own mini-filesystem, so
they can be zipped up and distributed as package files; there’s not much
module system for the code specifically, because the module system
consists of all the assets in the game. Though apparently you
have to give a class an explicit name to have it show up in the editor?
Idk. You can also nest classes with the
class Whatever: body
block, and constructors are just
instance methods called _init()
, it seems.
A few interesting types: &"foo"
is a
StringName
, aka a symbol or interned string. Always a good
thing. ^"Node/Label"
is a NodePath
, which
refers to an object in the scene graph by name. Don’t know enough about
Godot’s scene graph to say more about it yet; I suspect it basically
acts like a lazy reference into the scene-graph’s global state. There’s
also $NodePath
and %NodePath
operators which
look up objects by node path, they’re shortcuts for
get_node(...)
with slightly different properties. Whatever
the details are they all return an object inheriting from
Node
.
Types seem generally fairly strict, or at least stricter than
average: 3/2
will return the integer 1
,
1 == 1.0
is permitted but ==
may refuse to
compare values of weirder types if it feels like it, stuff like that.
The as
operator will convert between different object types
if they are compatible, or attempt to to turn non-object types like
int
and float
into each other if possible.
Integers are 64-bit signed ints, floats are 64-bit floating point
numbers, while Vector2
and such are composed of 32-bit
floats – makes sense for graphics code, where the difference between a
64 and 32 bit float is usually invisible and GPU’s really like
smaller numbers, but sounds like it could be a little annoying for
physics code. There’s also Vector2i
and
Vector3i
which have integer coordinates useful for showing
things on a grid, and a few other misc gamedev-y types like
Color
and Quaternion
.
There is a null
value and a type Nil
that
contains it, but most primitive types cannot be null
. It
appears that instead of the class hierarchy starting with a
nullable Object
type, it starts with a non-nullable Variant
type which is a tagged union of all value types (Nil
included). Array
and Dictionary
similarly are
Variant
’s, and thus cannot be null
. Then
Object
is one subclass of Variant
and all
nullable reference types inherit from that. This is… more savvy than
most OO systems out there, I have to say. However it does come with the
disadvantage that value types are closed; you can’t add your own types
to Variant
without diving into the engine’s C++ code and
making your custom fork.
GDScript is apparently actually gradually-typed? Yes it is, in fact!
If you write var foo = 3
then foo
has type
Variant
, but if you write var foo: int = 3
then foo
has type int
and assigning a
different type to it is a compile-time error. And, like Jai and Odin and
whatever preceeded them, doing var foo := 3
will infer the
type of foo
, though I have no idea yet how powerful the
type inference is. Looks like it’s squarely Good Enough; if I’m reading
the
docs correctly it can only infer built-in types or types declared in
the file you’re writing.
Back to as
, apparently it just returns null
without an error if the conversion is impossible. The type checker will
find trivial mistakes with this, so if you just write
3 as String
it is a compilation error. It’s easy to subvert
though, you can do
var foo: int = 3; var bar = foo as String
and it isn’t
caught by the type checker. …though when I do that it is a runtime
error, not null
. Perhaps it only works if you cast it to a
nullable type? If I try I get an error saying “can’t convert a
non-object type into an object type”. So our type system is at the mercy
of our runtime representation here, it seems; invalid object-to-object
conversions return null
and invalid conversions involving
value types raise a runtime error. Tsk, tsk. Regardless, doing
value is Type
returns a bool and lets you check whether
your assumptions are true.
Arrays are typed, neat! Assigning a float
into
Array[int]
is a runtime error. Also one of the potential
types you can put in an array is just Variant
, so you can
have a dynamic heterogeneous array if you want. That’s what happens if
you don’t specify an array type; var foo: Array = ...
is
just shorthand for var foo: Array[Variant] = ...
. There’s
also some specialized array types for packed integer and float arrays,
which presumably gain some efficiency by leaving out the tag and
unnecessary type-checks. Dictionaries cannot be given types in this
fashion, which seems like a weird omission but not a huge one.
Apparently that will change in Godot 4.4, so it’s an area of active
work.
Oh, apparently Array[Array[int]]
isn’t possible for some
reason; it just says “nested typed collections are not supported”. You
can write
var foo: Array = [1, 2, ["three", 4], {"five": 6.0}]
and it
works just fine so it’s not a limit of the underlying data model, just
the language’s type checker. There’s probably limits to how much they
feel like complicating the type checker and inference, which I
can sympathize with. I’d be miffed if this were intended to be a
Real Programming Language with that limitation, but for something
intended for game scripting I don’t expect strongly-typed nested arrays
to be a deal-breaker feature very often. Maybe I should pitch in and
help improve the type checker? They’d probably welcome it.
…Why the hell do I want to add another type checker project to my life? I hate writing type checkers.
There’s first-class functions and function-like objects called
Callable
. More on those later.
There’s also Signal
’s which appear to be callbacks that
other objects can subscribe to. Or maybe it’s better to think about them
like pubsub messages? Either way, they let an object say “here are
things that may happen” and another object can look at it and say “call
this function when X happens”. They can have signatures that carry data
just like functions, so they can be on_button_press()
or
on_move_to(x, y)
or whatever. Either way, you can use the
Godot editor to see what signals a script or object provides and wire
them together, or define them in code. I tend to think better in an
Erlang-y message-sending model, but it’s still neat having first-class
support for them. Useful for gamedev and UI where things need to be able
to talk to each other indirectly.
Functions
Functions are more properly methods on a class. They can have keyword
args and optional type annotations. Functions that don’t return
void
must return a value through all control flow paths.
You can create anonymous lambda functions with func
, which
unlike Python is not limited to a single expression, huzzah! They
are proper lambdas that can capture the environment around
them, though they capture it by value not by reference so each lambda
you create gets its own copy of the function’s environment. Using
pass-by-reference data types like Array
bypasses this.
Like Java/C#, a class’s instance variables are always in scope for
its methods; there’s no explicit self
like in Python or
Rust. That’s gonna trip me up something fierce, I bet. Not actually a
problem though.
There are static
functions which apparently are just,
like, normal functions. Or you might call them class functions. Either
way, they have no access to self
or instance variables.
I don’t have a better place to put this, so GDScript also has
coroutines. You can have a function await
on a
Signal
, which will block until the signal is triggered. So
this gets you actual coroutines. See that’s why I kinda don’t believe it
when the Godot authors say Lua wasn’t good enough at threading, it has
all that stuff built in already. But honestly if it were me I would
rapidly have started extending Lua to make the things I wanted more
convenient, and it soon would not look much like Lua at all. So, swings
and roundabouts. Anyway, you can either await
on a
coroutine to get the result it returns, or call it and not use its
return value which just runs it asynchronously without stopping the
caller. I have a few concerns about the number of mutexes you’d need to
ensure that’s safe, but I don’t want to get that deep into things.
Other than that, functions seem pretty unsurprising and I don’t have much to complain about.
Classes and stuff
No multiple inheritance or mixins or anything like that, apparently.
Classes have constructors that initialize their variables; there is
always a default constructor _init()
that takes no values
and initializes instance variables to the default for their type;
0
, false
, whatever. IMO default values are a
footgun more often than not, but oh well. You can add your own
_init()
constructors with different args, I think? (TODO:
Can you overload methods in general?)
Constructors kiiiiinda aren’t inherited; if I understand correctly if
you write class A
, then B
inheriting from
A
, then define a new constructor on A
that
takes some args then B
has to define a constructor that
takes the same args and call super(whatever)
to call
A
’s constructor explicitly. Interesting. It’s the sort of
ugly wrinkle of object-oriented systems that Rust just shows is entirely
irrelevant if you just make people initialize their damn data with the
values they want, but still interesting.
There’s also a static constructor _static_init()
that
cannot have a different signature and is called once when the class is
loaded, to initialize class variables. More game-lifecycle stuff like
@onready
. I don’t really like how many options there are
there ’cause while I know first-hand they’re useful af, I also know
first-hand that it gets messy af. This is one of the reasons I tend to
distrust editor-based scene-graph-y game engines like Godot or Unity,
they tend to pretend that a lot of implicit global state just magically
always exists instead of giving you control of how it’s set up and torn
down. I like being able to read some code and follow the initialization
chain of everything, and tinker/reorder it as necessary. It’s not an
unsolvable problem, just a part of the design I tend to find messy and
irksome.
You can define getters and setters on variables. Nothing too exciting about them.
There’s a class tree of stuff for builtin classes that is
kinda complicated and I don’t feel like getting deeply into right now.
The root of it seems to be
Variant -> Object -> RefCounted
and there’s many
children of both Object
and RefCounted
, and
Object
’s children are relatively-rare things that have to
be manually managed or that it only makes sense to have one of, like
sound managers. The interaction of inheritance between value types,
reference types, refcounted types etc all seems like a textbook case of
“why object-orientation isn’t very good”, but is also a nontrivial
problem in general. GDScript’s implementation of it seems more savvy
than most, and it probably works just fine for actual game scripting, I
just don’t envy the engine writers. Or maybe it all makes more sense
than I can infer right now, idk.
Editor
Godot comes with its own text editor! This is both kinda convenient and kinda hateful, for basically the same reasons as having a project editor and custom scripting language are kinda convenient and kinda hateful. But you can just use your own text editor which isn’t hard, though I haven’t messed with it enough to know how well it works. Five minutes of screwing around with Helix seems to indicate it works… about as well as external editor integration in any IDE, which is to say “usable but not exactly joyous”. Apparently Godot also offers a LSP server too, but Helix doesn’t know how to talk to it yet? Odd. I found a couple bugs with Helix LSP integration in the issue tracker, so maybe LSP support is a bit WIP.
ANYway. The built-in text editor offers the nice language-server-y things we expect these days, such as autocomplete. It doesn’t seem to do much type inference, but rather takes advantage of GDScript’s gradual typing, so the more type info you add to your program the more it can offer you autocomplete suggestions and function signatures and stuff.
Really, the editor looks and feels like VS Code. Is it just an embedded copy of VS Code? Signs point to no, to my surprise. If it is actually all homebrew, it’s a quite impressive endeavor so far. Haven’t used it enough to be able to tell for sure.
The editor also has an interesting feature called “safe lines”, which changes the color of the line numbers if the type checker can validate them. Oooooh, that’s a neat idea. So you can tell at a glance whether a line of code might involve a fallible dynamic cast or not. It’s not flawless, but it’s certainly interesting.
You can extend the text editor or the game scene editor with new panes and stuff by writing them in GDScript, though I haven’t dug into it.
Conclusions
You know… I’m pleasantly surprised. To a functional-language-nerd with low expectations and high standards, GDScript is really quite nice. Sure there’s flaws and things that I would consider sub-par designs, but very little I would criticize too harshly for the domain it’s built for. It’s all in all a nice-feeling OO language rather like Crystal is, and like Crystal it’s learned from a lot of the mistakes of the past. Despite the Python-y syntax it has a more functional-language/C# vibe than Crystal’s Ruby-ish vibe, which I like; it’s always been more comfy to me. Compared to something like Python or Lua, GDScript has stronger types, fewer nulls and more sensible handling of value vs reference types. Not perfect; if I were writing it then the result would look a lot more like OCaml and/or Lisp, but it’s very nice to find a random DSL hacked together for a single tool, and discover it is made by someone who actually knows pattern matching exists.
The gradual typing is both pleasant and pleasantly pervasive, and is used well by the language implementation. It’s the only language with gradual typing I’ve seen besides Elixir that appears to dodge the problem that Python and some other attempts have had, where the types end up being a second-class citizen that can’t really express the language’s type system. Some parts of it are still obviously a bit WIP, but that’s okay.
You know, maybe we’re slowly getting through the dark era of shitty hacked-together scripting languages made by people who don’t know that C and Bash aren’t the peak of language design? The era that (with all due respect) gave us PHP, ZZT, S-Lang, MUF and SourcePawn? I dunno, I’ve written like a hundred actual lines of GDScript so far. Maybe as I use it more I’ll find an ocean of footguns and obvious mistakes, baked so deeply into the language’s ideas that they can’t be fixed. But so far I’m a lot more optimistic about it than I was.