← Back to the blog
06 Sep 2026 14 min read GDScript · Compiler

GATE: Typing GDScript without forking Godot

A GDScript superset that compiles to plain GDScript. Structs that cost nothing, collections the engine cannot express, and a null checker that survives a function call.

Gate compiler pipeline, mid-lowering

GATE is a superset of GDScript that compiles to GDScript. You write .gate files, an editor plugin compiles each one to an ordinary .gd file sitting next to it, and Godot loads that .gd like any other script. The engine is not patched. No binary ships. The compiler is about 6,100 lines of GDScript running as a @tool plugin inside the editor.

It gives you value-semantic structs, typed collections shorter to write than untyped ones, interfaces and traits and generics, and a null checker that understands what your function calls did. All of it turns back into GDScript that Godot has been able to run since 4.0.

I built a first version in 2024 and abandoned it a month later. That failure decided the architecture of this one, so it is where I will start, but it is the shortest part of the story.

Where this started

GATE 0.0.3 was about nine and a half thousand lines of TypeScript, built with Bun, shipped as a standalone command-line binary. You downloaded a build for your OS off the Releases page and ran it:

gate 0.0.3, the old waysh
./gate --input ./path/to/myFile.gate --output ./output

It did two useful things. $$ turned a variable into an observable property, generating the signal, setter, getter and connection. ?. gave you optional chaining, as long as you only used it on a var line. First commit May 2024, last commit a month later, sixteen stars, and a README promising that “an engine integration will soon follow.”

Two things were wrong with it, and only one is the one I would have named at the time.

The one I knew about: it was a line-based regex transformer. Not a lexer, not a parser, a pile of pattern matches over source text. That gets you ?. on a single line. It gets you nothing else on the roadmap, because every remaining feature needs to know what a scope is. The design had a ceiling and I had reached it.

The one I did not think hard enough about: a compiler that lives outside the engine is the wrong shape for a Godot tool. Godot developers install things from the Asset Library. They do not download per-OS binaries and wire up a build step. That promised engine integration was not a nice-to-have I ran out of time for, it was the entire adoption problem, and I had written the compiler in a language that cannot run inside the editor.

rule of thumb If your tool needs the user to change how they work before it can help them, the tool is the thing that has to change.

The superset promise

Renaming an existing .gd file to .gate must never break it.

That constraint removes most of the design space. Three routes were open.

  1. A ScriptLanguageExtension Register .gate as a real language in the engine. This means a GDExtension, so compiled binaries per platform and architecture, macOS notarisation and a CI matrix, and you owe the debugger, autocompletion and editor integration yourself. 88 virtual methods against 8 for the option below.

  2. An import plugin, with .gate as its own resource type I built this and it worked. One file in the FileSystem dock, automatic reimport, no binaries. Then I dropped it: registering the class through the resource loader segfaults the editor when the cache lists a class whose loader is not active, and renaming a .gd carrying a class_name to .gate silently unregisters that class and breaks every file depending on it. It failed the promise.

  3. Compile to a plain .gd file Player.gate compiles to Player.gd next to it, and Godot loads the .gd like any other script.

The third one wins because the output is an ordinary GDScript file. class_name works, the debugger works, @export works, autocompletion works, the Create Node dialog works, and none of it took a line of code from me. Delete the addon and your project still runs.

The costs are real. The debugger points at generated code; there is a line-to-line sourcemap but nothing reads it yet. Comments attached to a line do not survive.

One consequence: the compiler has to run inside the editor, so TypeScript is out. GATE’s compiler is written in GDScript. That is the opposite of the first attempt, and it follows from how the thing gets installed rather than from taste.

Making the fast path the short path

A survey of 2,142 real .gd files found 91.3% of dictionaries and 44.7% of arrays are untyped.

Godot developers know typed collections are faster. They write untyped ones anyway. That is an ergonomics problem, not an education one:

the cost of doing the right thinggdscript
var tiles: Array[Vector2i] = []   # 34 characters, typed, fast
var tiles = []                    # 17 characters, untyped, slow

Typing costs twice as much typing, so people take the short path, and the short path is the slow one. Invert it and the problem goes away:

collections in GATEgate
vec2i[] tiles              # Array[Vector2i]
int[] scores = [1, 2, 3]   # PackedInt32Array
{str, int} counts          # Dictionary[String, int]
int[][] grid               # Array[PackedInt32Array]

If the typed form is the shortest form, people write the fast thing by accident.

That last line is my favourite thing in the language. GDScript rejects Array[Array[int]] outright; nested typed collections do not exist, and the proposal asking for them has 188 votes. GATE lowers the inner type to its Packed equivalent, so the superset expresses something the host language cannot.

What the language looks like

Almost all of this is desugaring, which is the point: none of it needs the engine to change.

res://combat/enemy.gategate
namespace Combat:

    interface Damageable:
        func take_damage(amount: int) -> void

    trait Poolable:
        int _pool_id = -1
        func reset() -> void: _pool_id = -1

    struct Damage:
        int amount
        float crit = 1.0

    class Enemy extends CharacterBody2D implements Damageable with Poolable:
        pub  int    health = 100
        priv vec2[] _patrol
        priv {str, float} _resistances

        @observable int hp_display = 100

        override func _ready() -> void:
            reset()

        func take_damage(amount: int) -> void:
            var mult = _resistances.get("phys") ?? 1.0
            health -= int(amount * mult)
            hp_display = health

        func nearest_point(from: vec2) -> vec2?:
            if _patrol.is_empty(): return null
            var best = _patrol[0]
            for p in _patrol:
                if from.distance_to(p) < from.distance_to(best): best = p
            return best

Interfaces are flattened and checked at compile time, and they keep runtime support, so is Damageable still works on the emitted class. That check is a marker lookup rather than a native type test: about 63 ns on release, roughly 1.25x a native is, and quicker than has_method(). Traits are inlined, which is how reset() and _pool_id arrive on Enemy without inheritance. Namespaces become nested classes. Generics are monomorphised into one concrete class per instantiation.

@observable is the one thing carried over from 2024, renamed. $$ had to go because it is already claimed: #5064 and #996 both want it for scene-unique node access, which is node-path semantics. Colliding with what people already expect a sigil to mean is a bad trade for four saved characters. The new name follows #4867, which is the proposal asking for the feature in the first place.

The smaller things are the ones I miss when I go back to plain GDScript: ??, ?. as a real expression instead of something restricted to assignments, destructuring, f-strings, chained comparison, enumerate(), swapping two variables, pub and priv, override and virtual, and arity-based overloading. All of them are on the community wishlist with votes behind them, and all of them are pure desugaring.

Structs, and letting the compiler choose

struct means value semantics, class means reference semantics, and you do not annotate the representation. The compiler picks it:

a struct that costs nothinggate
struct Particle:
	float x, y, vx, vy

Four floats become a Vector4. A real value type, no allocation, and nothing in your code had to know. On release that is about 3x faster than the class-by-reference version you would write today, which also has the wrong aliasing semantics.

When you want a lot of them, one annotation changes the memory layout:

struct-of-arrays behind one wordgate
@soa Particle[] swarm

for p in swarm:
	p.x += p.vx

That emits four parallel PackedFloat32Arrays and rewrites the loop to index into them. p is a view rather than an element, so storing it past the iteration is a compile error that explains itself.

Here are four ways to hold the same 200,000 particles, all compiled by GATE from the same source, measured against release templates:

Holding 200k particlesFrame time
@soa Particle[], four parallel PackedFloat32Array14.3 - 15.1 ms
Particle[], GATE’s default, no annotation28.4 - 28.9 ms
Array[Particle], a typed array of a class81.6 - 83.1 ms
Array, an untyped array of a class90.1 - 91.7 ms

So @soa is worth about 2x, which is less than I expected. GATE never emits an array of objects for a four-float struct in the first place: Particle[] with no annotation already lowers to a PackedVector4Array. The honest answer to “should I add @soa?” is roughly 2x over what you get for free. I could quote 5x against Array[Particle], but most of that is the default lowering rather than the annotation, so it would be the wrong headline.

Null safety

T? marks a nullable type, and the checker is flow-sensitive:

the check that has to failgate
if c.next != null:
	c.wipe()             # this method assigns next = null
	return c.next.name   # error: 'c.next' may be null

The guard is real when it is written. It stops being valid because of what wipe() did, and that is the whole difficulty.

The checker narrows access paths rather than variables, so t.next.name, items[0] and by_name["boss"] can each be tracked. Narrowing on a subscript is dropped when the slot is written, when the container is mutated, or when a variable index changes. Loops run to a fixpoint, so a value cleared at the bottom of a body is already suspect at the top.

What makes it usable is what happens at a call. If every call invalidated everything, the checker would fire on print() and you would switch it off. So each function carries a summary of the access paths it writes, relative to its receiver and to each parameter, computed to a fixpoint through the call graph. A call that cannot reach your guard leaves it standing.

How it is built

The pipeline is ordinary:

  1. an indentation-aware lexer, emitting INDENT and DEDENT
  2. a recursive-descent parser with precedence climbing
  3. a checker: traits inlined, interfaces flattened and verified, struct lowerings chosen, overloads mangled by arity
  4. the null analysis
  5. an emitter, which also writes the sourcemap

It runs in two passes over the project, so an interface declared in one file can be implemented in another.

GDScript is a stranger host language for a compiler than it sounds. Classes, Dictionary, Array and match are enough for an AST and a recursive-descent parser, and @tool scripts give you the filesystem hooks. What you give up is sum types, so AST nodes become classes or tagged dictionaries, and generics, so there is more casting than I would like. It is slower than native, but compilation is a build step rather than a hot path.

There is an upside I did not plan: writing the compiler in GDScript means the compiler is itself a large real-world GDScript codebase to test the superset promise against.

Current state

Working, with tests behind it: type shorthands, type-first declarations, T[] and {K,V} collections, nested collections via Packed, structs lowered to Vectors or classes, interfaces with runtime is, traits, namespaces, generic monomorphisation, cross-file declarations, ??, ?., destructuring, f-strings, chained comparison, swap, enumerate, pub and priv, override, virtual, @observable, arity overloading, @soa, and the null analysis. It also compiles 33 real .gd files, about 7,400 lines, without touching them.

Not built, though SYNTAX.md promises both:

  • Scalar replacement. A struct that never escapes should be exploded into plain locals. It is not.
  • Escape analysis. An escape should be an error pointing at the line where it escapes. Instead GATE warns at the declaration, which is the wrong place to be told. @soa being something you ask for rather than something inferred is the same hole.

Also missing: generic argument checking, anything that reads the sourcemap, and a structural fallback for is SomeInterface against classes GATE did not compile.

If you were going to start on escape analysis, one number first. Scalar replacement is 2.27x in a debug build and 1.43x on release. It is the only lowering that gets worse on the build that ships, because release already removes most of what it would have removed. Vector packing needs no analysis at all and is 3.08x. So the biggest job left is worth roughly half of what the spec claims for it.

The first GATE failed because a regex transformer could not grow and a CLI binary could not reach anyone. This one might still fail, but not for those two reasons. It is not production-ready and I would not put it in a game I intended to ship. It compiles my own code without breaking it, which was the bar it had to clear first.

GDScriptCompiler

Keep reading

Archive →
© 2026 Blade67 · Say hi anytime Built with SvelteKit