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.