GATE is a GDScript superset for the Godot Engine that adds extra features while compiling down to plain GDScript.
Valid GDScript is valid GATE. Rename a .gd to .gate and it compiles with no errors,
loads, and behaves identically. Recompiling GATE's own output gives back the same bytes.
Questions, or want to show what you built? Discord. Bugs and feature requests go in issues.
.gate in another editorThe full syntax is in SYNTAX.md.
Copy addons/gate/ into your project, then enable GATE under
Project Settings -> Plugins. The plugin watches the filesystem and compiles every .gate
file to a .gd beside it whenever anything changes.
Rename foo.gd to foo.gate and leave foo.gd.uid where it is. Godot references scripts
by uid, so deleting it makes every ext_resource fall back to a text path with a warning.
GATE regenerates foo.gd and never touches the uid.
The first time you open a project after renaming its scripts, expect a burst of errors
in the Output panel. Godot loads the scripts your open scenes use before GATE has written
their .gd files, so each of those fails to parse once, and GATE says once for every file
it could not check yet: about 1,600 lines for Pixelorama, 200 for GodSVG. Nothing is
wrong. Close the editor and open it again; the second session is quiet.
On a large project the first open is slow, while GATE builds every file and Godot registers the classes it wrote. Pixelorama, 212 files, measured on one machine: about 105 s the first time and about 30 s the second; after that, opening it unchanged takes about 14 s. Each file is compiled once, on the first open only: a file GATE could not check before Godot had registered the classes is remembered as built-but-unchecked, so the next session checks it without building it again.
Leave script_templates/ alone. Godot keeps a .gdignore in that folder, and GATE does
not compile anything under a .gdignore, so a template renamed to .gate is never turned
back into a .gd and simply disappears from the Script Create dialog.
Write .gate, get .gd. The output is ordinary GDScript with a header naming its source,
plus a .gd.map recording which source line each output line came from.
# player.gate
extends CharacterBody2D
pub int health = 100
Node2D? target
func hurt(amount: int) -> void:
health -= amount
target?.notify_damage(amount)
# player.gd, generated
extends CharacterBody2D
var health: int = 100
var target: Node2D = null
func hurt(amount: int) -> void:
health -= amount
if target != null:
target.notify_damage(amount)
The output is indented the way the rest of your project is. In the editor, the indent type
and size come from Editor Settings -> Text Editor -> Behavior -> Indent. With no editor
running, a headless build takes the first indented line of the .gate file itself, so a
renamed .gd keeps the look it had. A tab is the last resort. Changing the setting rewrites
the outputs on the next build.
Delete the header, then the .gate file, and what remains is a normal script you can
maintain by hand: the leftover .gd.map can go with them, and the .gd.uid stays. There
is no runtime, no dependency, and nothing to ship.
What proves a file up to date is kept in .godot/gate/, so opening a project nothing has
changed in compiles nothing. Deleting that folder costs one full build and nothing else.
A file is rebuilt when something it reads changes. From another .gate it reads the
declarations, struct field defaults, signal parameter types, and what each function may set
to null. A comment, or an edit inside a function body that changes none of those, rebuilds
only the file it was made in. A hand-written .gd counts only for the .gate files that
reach it, by class_name, as a base, as an autoload or by path, and only through its
declarations. Of project.godot, only the autoloads and the debug/gdscript warning
settings count, so switching a plugin on or off builds nothing.
Project -> Tools -> Recompile all GATE files ignores all of this and looks at every
.gate again.
The type goes before the name, so the typed form is shorter than the untyped one. var
still works exactly as it does in GDScript.
int hp = 100 # var hp: int = 100
str label = "player" # var label: String = "player"
vec2i[] tiles # Array[Vector2i]
int[] scores # Array[int]
{str, int} counts # Dictionary[String, int]
int[][] grid # Array[PackedInt32Array] - GDScript rejects nested typed collections
The short names are spellings, not new types. str is String, vec2 is Vector2,
f64 is float: same value, same methods, same thing every Godot API takes and returns,
with no wrapper and no conversion. Write String instead if you prefer - both compile to
the same line.
This form replaces nothing. var hp: int = 100 means what it always did, and GATE's
own types work in that position too, so nothing here is behind the type-first spelling:
var target: Node2D? = null # tracked nullable
var scores: int[] = [1, 2] # Array[int]
var id: <int | str> = 7 # union, emitted as Variant
Use the null tracking, structs, generics and the rest without writing a single type-first
declaration if that suits you better. Type-first is there because the type is the thing
worth reading first in a block of fields, and because it is shorter. That is the whole
reason; there is no technical one. Parameters keep GDScript's own name: Type form, which
is an inconsistency on purpose: making them type-first is a spelling change that costs the
parser more lookahead than the rest of the grammar, so it sits in ROADMAP.md
rather than half-built here.
T? is a tracked nullable type. A flow-sensitive pass follows guards, early exits and
assignments, and rejects a dereference it cannot prove is safe. It only ever complains
about types you declared nullable, so plain GDScript cannot trip it.
Node2D? target
func update() -> void:
target.queue_free() # error: 'target' may be null
target?.queue_free() # fine
if target != null:
target.queue_free() # fine
?. gives null when its left side is null, so its result is itself a value that may be
null. Where a may be null, str n = a?.label is an error and str n = a?.label ?? "" is
not.
struct has value semantics, class has reference semantics. A struct of two to four
same-typed int or float fields becomes a Vector2/3/4 and costs nothing; anything else
becomes a class and GATE inserts the copies that keep it a value.
struct Damage:
int amount
str source
func doubled() -> int:
return amount * 2
var a = Damage(7, "hit")
var b = a # a copy, not an alias
Interfaces are checked at compile time and testable at runtime. Traits inline their members into the implementor. Namespaces lower to inner classes.
interface Damageable:
func take_damage(amount: int) -> void
trait Poolable:
int _pool_id = -1
func reset() -> void:
_pool_id = -1
class Enemy extends CharacterBody2D implements Damageable with Poolable:
func take_damage(amount: int) -> void:
pass
@global on a top-level class gives it its own Godot script beside the file that
declares it, carrying class_name - a real, attachable script that shows up in the
Create Node dialog, rather than an inner class of the .gate's own output. The name is
project-wide, the same as a file's own class_name.
# res://src/bullet.gate
@global class Bullet extends Node2D:
var speed: float = 400.0
# writes res://src/bullet.Bullet.gd, carrying `class_name Bullet`
Monomorphised, so Pool<Bullet> holds a real Array[Bullet] rather than an untyped one.
A generic is monomorphised once, in the file that declares it, so is Pool<Bullet> holds
across files.
class Pool<T> extends Node:
T[] _items
func acquire() -> T?:
return _items.pop_back() if _items.size() > 0 else null
var bullets = Pool<Bullet>.new()
A function takes type parameters the same way, and one copy is emitted per set of type arguments used. They are written out rather than inferred. Another file's generic function is called through its class name or a preloaded const and monomorphised where it is declared, the same as a generic class.
func first<T>(xs: T[]) -> T:
return xs[0]
print(first<int>(ns)) # __first_int(ns)
All three compile to plain GDScript types and are checked at compile time. A union holds one of its members, a tuple is an array of fixed length with a type per slot, and a typed callable says what it takes and returns.
<int | str> id = 7 # var id: Variant = 7
<int, str> entry = [7, "hp"] # var entry: Array = [7, "hp"]
func(int) -> bool keep = func(x): return x > 0
func update() -> void:
id = 1.5 # error: float is not int or String
entry[1] = 3 # error: slot 1 is a String
keep.call("7") # error: expects int
type names a type once; nothing is emitted for it. PackedScene<T> makes
instantiate() return a T.
type Hp = int
type Loot = <Item | Gold>
Hp health = 100 # var health: int = 100
PackedScene<Enemy> scene = preload("res://enemy.tscn")
Enemy e = scene.instantiate() # typed, no cast
Signals you declared with types are checked too: hit.emit("x") on signal hit(amount: int)
is a warning. Only a warning, because Godot accepts it.
After if x is T:, or an early exit that proves it, x is a T. A match arm can test a
type and bind it in one go, and it composes with when.
func area(s: Shape) -> float:
match s:
Circle c:
return PI * c.r * c.r
Rect r when r.w == r.h:
return r.w * r.w
Rect r:
return r.w * r.h
return 0.0
func hit(n: Node) -> void:
if n is not Enemy:
return
n.take_damage(1) # n is an Enemy here
Foo f = x as Foo is an error unless x is Foo was proved first, since a failed as is
null. Plain GDScript's (x as Foo).bar() is left alone.
.xy, .xz, .zyx and the rest work on any statically typed vector, reading and
writing. An object can be built and filled in one expression.
vec3 p = Vector3(1, 2, 3)
vec2 flat = p.xz # Vector2(p.x, p.z)
p.xy = Vector2(5, 6)
var e = scene.instantiate({ position: spawn, hp: 40 + wave * 10, target: player })
var l = Label.new({ text: "Ready", modulate: Color.RED })
var d = Damage({ amount: 3, source: "fire" })
@required asserts in _ready that an exported node was assigned, in debug builds.
@export_if shows a property in the inspector only while a condition holds; it needs
@tool, and GATE will not add that for you.
@tool
extends Node
@export @required Node2D target
@export bool use_timer = false
@export_if(use_timer) float delay = 1.0
@if / @elif / @else / @endif resolve when the file compiles. A dropped block is
blanked, not deleted, so every line after it keeps its original number, and it is never
parsed, so it can hold code that would not compile at all. Symbols come from Godot itself:
DEBUG, RELEASE, EDITOR, EXPORT, platform and architecture tags, and GODOT_VERSION
in a comparison. An unknown symbol is a compile error, not a silent false. At export, each
.gd is recompiled with the preset's own values, so a release build genuinely lacks a
@if DEBUG block.
@if DEBUG
print("debug build")
@elif GODOT_VERSION >= 4.7 && WINDOWS
print("windows, recent Godot")
@else
print("release")
@endif
var name = player?.profile?.name # safe navigation
var hp = saved ?? 100 # null coalescing
var [x, y] = get_pos() # destructuring
var [int x, int y] = get_pos() # ...and the names may take types
var msg = f"{player} scored {pts}" # f-string
a, b = b, a # swap
for i, item in enumerate(items): # index and value
for k, v in scores: # dictionary key and value
nums.map((x: int) => x + 1) # arrow lambda
An arrow lambda is a one-expression func lambda with the noise removed: the parentheses
are always required, and the parameters follow the same rules a func lambda's do.
Two spellings are behind a project setting, both off by default, because each is a second way to write something GATE already has:
int[,] grid = [[1, 2]] # gate/features/multidim_arrays, = int[][]
var shown = raw |> roundi() |> mini(99) # gate/experiments/pipe_operator, = mini(roundi(raw), 99)
Writing either with its flag off is an error naming the setting.
Five of GATE's spellings are switches, under Project Settings > Gate. Writing something a switch is off for is a compile error that names the setting and the spelling to write instead, so nothing fails quietly.
int hp = 100 is an error and var hp: int = 100
is the spelling to write. Nothing else goes with it: GATE's types work in a GDScript
annotation too, so null tracking, structs, generics and the rest stay reachable. The one
thing with no replacement is the shared line, int a, b, GDScript having no multi-name
declaration.str, vec2, f64 and the rest are not type
names anywhere, and String, Vector2 and float are what to write. A class, struct,
enum or const your own project declares with one of those names keeps its meaning either
way; the setting only decides whether the shorthand meaning exists.vec2.ZERO is Vector2.ZERO and vec2(1, 2) is Vector2(1, 2). Off, both are
an error. str(...) called as a function is GDScript's own builtin either way.int[,], another spelling of int[][].x |> f(), another spelling of f(x).A default is never written into project.godot, so a project that leaves these alone
carries nothing for them.
With the plugin enabled, Godot treats .gate as a language of its own.
.gate files appear in the FileSystem dock, with their own icon. Before this they
were invisible: Godot did not track the extension at all.T?, T[], unions, f-strings, struct, interface,
namespace and the rest. Every type name colours, short or canonical, and so does
the name a struct, interface, trait, namespace, type or class declares
and a generic's parameter. A name starting with _ is dimmed. The colours come from
your own editor theme, and follow it when you change it..gate, in the same warnings panel and in the
same words: unused variable, unused local constant, unused parameter, unused signal,
and a local, parameter or for variable that shadows a member of this class or of a
base - another .gate, a hand-written .gd, or an engine class. Before this they
existed only against the generated .gd, which you are told never to open.
@warning_ignore, @warning_ignore_start/_restore and the switches under
Project Settings -> Debug -> GDScript are honoured, so a warning you turned off
stays off. The warnings that need the whole project, and the ones about types, are
still the build's.class_name in another .gate or in a hand-written .gd, a member of any of
those including one it inherits, and locals, parameters and fields in the file you
are in. It goes through autoloads, so Global.current_project.frames reaches the
field's declaration: a script autoload stands for its script, and a scene autoload for
its root node's script, or the root's engine class when it has none. A base written as
a path, extends "res://base.gd", is followed like a named one. An engine class opens
its documentation instead. Where GATE cannot place a word it says so by not
underlining it..gate and the running game stops there, and
the stop is shown in the .gate with the line marked, not in the generated .gd.
Stepping follows. A breakpoint on a line the game can never stop on - a blank, a
declaration that lowers away, or a comment, which is carried through but is still
not something Godot breaks on - is taken back with a toast saying why, rather than
left sitting there never firing. Godot's own debugger panels still name the .gd..gate, a T? field, a
class_name, a hand-written .gd class, an autoload, an engine class - including
everything inherited, all the way up, through a base written as a path too. ?. is
the same. String, Array, Vector2 and the engine's other built-in types offer
their methods, properties and constants, from a table generated from Godot 4.7.2's API.
What a typed container holds carries through an index, the Array and Dictionary
methods that return an element, and a for loop's variable, so with Frame[] frames
both frames[0]. and f. inside for f in frames: offer what a Frame has. A
variable declared with :=, or a constant, takes its type from its initialiser; one
declared with a plain = is still untyped. Unqualified, it offers locals, parameters, the file's own members and
everything it inherits, the autoloads, every type in the project, GATE's type
shorthands and keywords, and the engine's classes. After @ it offers the
annotations, GATE's and Godot's. On another object a dot leaves out the methods that
start with an underscore, such as _ready, since those are there to be overridden;
on self they are offered. A name is offered only where the caret can actually see
it: a parameter inside its own function, a local below its own line, a for
variable inside its loop. Typing the start of a virtual in a class body offers the
base's overridable methods with their signature filled in, and typing a call's
opening bracket shows that function's parameters..gd
would get. A .gate answers the editor with the engine class at the root of its
extends chain, which is the one question the Scene dock asks before it allows the
drop.priv func, pub func, virtual func, an operator overload, and any of those with
static, each jumping to its line in the .gate..gate tabs come back next session. Godot's own layout never holds them;
GATE keeps them and reopens them itself. That is so a session with GATE switched off
finds nothing to reopen: without GATE, Godot would open a .gate as plain text and
rewrite its indentation on exit, badly enough that it no longer compiles. Switching
GATE off from the Plugins tab closes its tabs, saving any unsaved edit first, and a
session with GATE off never rewrites a .gate..gate that used CRLF is written back with
CRLF; a new file, or one that used LF, stays LF..gate and attaches the .gd. Choose GATE in the
Scene dock's Attach Script dialog and it writes the .gate from the template you
picked, compiles it, and puts the generated .gd on the node. The node runs the
.gd, as it has to; the .gate is what stays open in the script editor. If the
build fails, GATE says so on screen, leaves the .gate alone so you can fix it, and
attaches nothing..gate again..gate sources, their .uid files, the
.gd.map sourcemaps, the compiler and the editor integration are all left out of
the PCK; the compiled .gd is what ships. Four small scripts from
addons/gate/editor/ do ship - loader.gd, saver.gd, registry.gd and
script.gd - because Godot's class list names the first two and a game that cannot
find them prints six errors at start-up. They do nothing in a game. If you exclude
addons/gate/ in your export preset yourself, keep those four.Two things deliberately do not work.
= and no
type, an untyped container after an index, the return of an engine method GATE
cannot follow - the list is empty rather than filled from somewhere else. It follows
a chain of names, calls and indexes, not arbitrary expressions..gate in another editorGATE speaks LSP, so a .gate can be edited in Neovim, Helix, Zed or VS Code. The server
runs inside the Godot editor, the way Godot's own GDScript language server does, which is
what makes what it tells your editor and what the script editor shows you come from the
same code rather than from two implementations that drift apart.
It is off unless you ask for it. In Project -> Project Settings -> GATE:
gate/lsp/enabled - off by default. Nobody gets a listening socket they did not want.gate/lsp/port - 6100 by default. It binds 127.0.0.1 and nothing else. If that port
is already taken, GATE says so and stops instead of moving to another one: a client is
configured with a fixed port, and a server that quietly moves is a server the client
never finds.Godot has to be running with the project open for anything to answer.
While a client is attached, GATE keeps the editor's main loop at about 60 Hz. Godot sleeps 100 ms between frames when its window is not focused, and not focused is what it is the whole time you are typing somewhere else, which is exactly when this is answering: the server polls the socket once a frame, so that period is how long a reply can sit waiting. It is handed back the moment the client disconnects, and a focused editor, which is already quicker than 60 Hz, is left alone.
The server is a socket rather than a program, so a client has to connect to it rather than start it. That is the same shape as Godot's own GDScript server, and it is why an editor that can only launch a language server needs something in between.
Neovim connects to a socket on its own:
vim.lsp.start({
name = "gate",
cmd = vim.lsp.rpc.connect("127.0.0.1", 6100),
root_dir = vim.fs.dirname(vim.fs.find({ "project.godot" }, { upward = true })[1]),
})
VS Code has no built-in way to attach to a language server at all; every language
there arrives through an extension. There is one for GATE in the GateVsCode repository
beside this one, unpublished and installed by copying a folder.
Helix and Zed launch a language server as a child process and talk to it over its
standard input and output, so they need a bridge that forwards stdio to the socket. Any
of nc, socat or a three-line script will do it; none of them is installed on Windows
by default.
What it answers today. Diagnostics, completion, signature help, go-to-definition, hover and a document outline. Anything else is answered "method not there", which is what makes a client fall back to its own word list rather than conclude GATE has nothing to say.
Go-to-definition refuses rather than guesses. It answers exactly where Ctrl-click in
the script editor answers, because it asks the same code: a local, a parameter, a member,
a function, a signal, an enum, a type declared in another .gate, a class_name in a
hand-written .gd, an autoload, and anything inherited from a base, including a base
written as a path. A word it cannot place answers nothing at all rather than the nearest
name that looks similar. An engine class answers nothing too, since there is no file to
open; hover still describes it. The answer is always a location in something you wrote -
the .gate, or a .gd you wrote yourself - never in the .gd GATE generated.
An enum member takes you to its enum. GATE's parser keeps an enum's keys as names on
the declaration and gives them no line of their own, so Mood.CALM opens the line enum Mood is on. On a one-line enum that puts the caret on the key itself; on an enum whose
body runs over several lines it puts you at the top of it.
Hover shows the declaration and its declared type. The line as you wrote it, and the
type GATE has for it. A var with no type on it gets no type line, because the type of
one of those comes out of the build, against the whole project, and a hover is answered
from one file. It says nothing at all rather than showing an empty tooltip.
The outline is the file's own declarations. Classes, structs, interfaces, traits, namespaces, enums and their members, functions, signals, constants and fields, nested the way you wrote them. A file that is mid-edit still gets an outline: a half-typed declaration costs its own entry and nothing else.
Diagnostics come in two tiers, and the difference matters. As you type, you get what
Godot's script editor underlines on the same file: the lexer's errors and Godot's own
unused and shadowed-variable warnings. When you save, you also get what a compile finds -
unknown types, the null analysis, the superset checks - which is everything GATE knows
about that file. A type declared in another .gate resolves, because the editor already
holds the project's registry. Where GATE has a hint it arrives as related information, so
your editor can show the message on the line and the hint on demand, and a problem both
tiers find is reported once.
The split is not arbitrary. The lexer pass takes 0.2 ms on a small file and 12.5 ms on a 238-line one; compiling the same two takes 5.8 ms and 259 ms, and it runs on the editor's main thread, which means Godot is not doing anything else while it does. Paying that on every pause in your typing would be paying it dozens of times a minute for an answer that has usually not changed. So a keystroke buys the fast tier and a save buys both. If you want GATE's full opinion on a file, save it.
What it still does not carry. The rest of the project. You get every diagnostic for the file you saved, not for the files that depend on it, and a build still reports those to Godot's Output panel.
Completion offers nothing rather than guess, here too. The list is the one the script
editor gives, in the same order, with GATE's kinds mapped to your editor's icons. Where
GATE cannot establish the type of what is left of the dot, the answer is an empty list.
Signature help names the call you are inside and highlights the argument you are on,
including after a comma inside a nested call. A method of a Variant built-in such as
String or Array gets no signature at all: Godot's own API dump records those methods
with a name and a return type and no parameters, so there is no argument list to show, and
an empty one would say the method takes none.
Unsaved changes count for the file you are in, and only that one. While your editor holds a file open, everything GATE says about that file is computed from your buffer. Anything it has to read from another file - a base class, when it is working out whether a local shadows a member of it - is read from disk. So a member you added to a base class and have not saved is not taken into account while GATE looks at the file that extends it. Saving fixes it once Godot has noticed the file changed, which happens the next time the editor scans the filesystem.
One file, one editor. A .gate open in Godot's script editor and in an external
editor at the same time is two buffers over one file, and GATE does not arbitrate between
them. Pick one.
The generated code is not slower than the GDScript you would have written: across 110 benchmarks the median ratio is 0.98, inside a 1.6% noise floor.
Where GATE is faster, it is because of a lowering you would not write by hand. A struct
local that provably never escapes its function becomes one plain local per field, with no
allocation and no copy - 13.5x on a 300k-iteration loop. An array of structs that packs
into a Vector is 9.9x faster than the idiomatic Array[SomeClass].
@soa is the one to be careful with: it is about 2.4x slower to build and 1.9x faster to
iterate, so it pays off only if you make more than about 1.3 passes per rebuild.
## docs included, and that covers one written inside an array
literal, a dictionary literal, an enum body or a call's argument list - such a
construct takes its one-per-line form so each comment has a line of its own, and one
carrying no comment is emitted exactly as before. Two cases have nowhere to go: a
comment inside a lambda passed as an argument belongs to statements in that body rather
than to the argument list, and one inside grouping parentheses has nothing to attach to,
because GATE drops the parentheses and re-emits the expression. Measured over 3,495 real
files, 626 of the 764 comments written inside brackets are carried and these 138 are
not. A comment written on the end of a line of code is also dropped in the one case
where that line became more than one line of output, or none: @rpc func f() written
on a single line comes out on two, and there is then no single right place for it..gd. The game runs the .gd, so the
Stack Frames list and a runtime error point at it; a toast names the .gate line each
came from. Breakpoints and stepping are shown in the .gate. A stop on a generated line
with no .gate line behind it - a helper GATE wrote - is left showing the .gd,
deliberately, rather than pointing at a .gate line that is not the one running.as is only checked in GATE's own declarations. x as T is null when the cast
fails. Inside a function, Foo f = x as Foo is an error unless an if x is Foo: or an
early exit proved it, or you declare it Foo?. Everywhere else, (x as Foo).field,
var y = x as Foo, arguments and field initialisers, the cast is trusted, so plain
GDScript keeps its meaning. That is a known unsound spot in the null analysis.d.next clears what GATE knew about next
on every reference that may be the same object: the same or a related class, or an
unknown type. Three gaps remain. A call GATE cannot see into, like an engine method,
clears what is under the references it is given but not their aliases. What it returns
is taken to be a new object. And a function that writes through an array or dictionary
holding a parameter (var arr = [c] then arr[0].next = null) is not seen by its
callers to write to c.Reg.cached.next,
falls back to the aliasing rule above rather than to the field itself. And a static function
of a subclass does not track a static field it inherits from its base.is narrows locals, parameters and typed instance fields. An untyped field, a static
var or an autoload can be rebound by a call GATE cannot trace, so a check on one does not
narrow it at all. Copy it to a local and test that.arr[0].c maps
to the Vector component when GATE knows what the container holds - a declared S[], a
parameter, a loop variable or a literal of one struct. From an untyped local grown with
append, or an untyped parameter, there is no element type, and the read fails at run
time.a()[0]. Every pass after the parser
walks the tree once per level and GDScript stops at 1024 calls, so deeper than that the
passes ran out of stack and handed back answers that were quietly cut short. A file that
nests deeper is refused with one error naming where the expression starts. This is a place
where valid GDScript is refused: Godot's own parser takes it. Deep member chains also cost
time quadratic in their depth, which only shows well past a depth real code reaches.not (not (...)) past 46 levels exhausts the stack before the cap can report it, and
what GATE writes for such a file does not recompile to itself.class_name declared by another file in the same build is
written with a warning and validated once that class is registered, usually by the next
build. Godot resolves global class names from the editor's class list, which only updates
after the file is on disk.x is SomeInterface is false for a
class GATE did not compile.@global can; @global does not reach inside a namespace.@global class's own body can only name itself, a project-wide name, or something it
declares itself - not a plain sibling declared at the same file's own top level: a
class, struct, interface, trait, namespace, enum, const, var, signal or func that is not
itself @global, since that sibling is not there any more once the class is extracted
into its own script. Refused at compile time, naming the sibling; mark it @global too,
or move it inside the class..gate in the editor rewrites its indentation to whatever
Text Editor -> Behavior -> Indent says, as Godot does for a .gd. GATE reads tabs
and spaces alike, so the output is unchanged either way..gate opened before GATE 1.1.0 keeps "Plain Text" highlighting. The choice is
remembered per file in .godot/editor/script_editor_cache.cfg; pick GATE once from the
highlighter menu and it sticks.@define. A custom symbol comes from the
project setting gate/compiler/defines only. A comparison operator is defined only for
GODOT_VERSION. Directives are line-based: half an expression cannot be dropped. The
editor shows a dropped block as ordinary code; it is not greyed out.GATE is developed against a 26-stage test suite: differential execution against Godot itself, a real-world superset corpus, build-pipeline scenarios across editor restarts, live editor sessions, sourcemap fidelity, byte-exact output comparison and randomised input. That suite is not part of this repository; what ships here is the addon.
What it established at v1.1.0:
.gd files, every one in both corpora that is not a copy of GATE itself,
compiled and then recompiled from their own output, with zero outputs that fail to load and
zero fixed-point breaks. The 17 that do not compile are Godot 3 syntax or mix tabs and
spaces for indentation, and Godot 4 rejects every one of them too..gate and built
through the plugin, then again after each of three editor restarts - with zero failed files,
and every output loads.The differential harness is the one that matters. It does not ask whether GATE accepted the file, it asks whether Godot's answer equals GATE's answer. Every silent-wrong-value defect in this project was found that way and none were found any other way.
The documentation in this repository, meaning this README, SYNTAX.md, CHANGELOG.md
and ROADMAP.md, was written by AI and then checked by me before it shipped. Every
claim about behaviour is one I verified against the test suite or the compiler
itself, and every number quoted is one that came out of a run rather than an
estimate. Where it is still wrong, that is a mistake I missed, so please open an
issue and I will fix it.
MIT. See LICENSE.
108 followers · starred Sep 2026
175 followers · starred Sep 2026
75 followers · starred Sep 2026
154 followers · starred Sep 2026
GATE is a GDScript superset for the Godot Engine that adds extra features while compiling down to plain GDScript.
Valid GDScript is valid GATE. Rename a .gd to .gate and it compiles with no errors,
loads, and behaves identically. Recompiling GATE's own output gives back the same bytes.
Questions, or want to show what you built? Discord. Bugs and feature requests go in issues.
.gate in another editorThe full syntax is in SYNTAX.md.
Copy addons/gate/ into your project, then enable GATE under
Project Settings -> Plugins. The plugin watches the filesystem and compiles every .gate
file to a .gd beside it whenever anything changes.
Rename foo.gd to foo.gate and leave foo.gd.uid where it is. Godot references scripts
by uid, so deleting it makes every ext_resource fall back to a text path with a warning.
GATE regenerates foo.gd and never touches the uid.
The first time you open a project after renaming its scripts, expect a burst of errors
in the Output panel. Godot loads the scripts your open scenes use before GATE has written
their .gd files, so each of those fails to parse once, and GATE says once for every file
it could not check yet: about 1,600 lines for Pixelorama, 200 for GodSVG. Nothing is
wrong. Close the editor and open it again; the second session is quiet.
On a large project the first open is slow, while GATE builds every file and Godot registers the classes it wrote. Pixelorama, 212 files, measured on one machine: about 105 s the first time and about 30 s the second; after that, opening it unchanged takes about 14 s. Each file is compiled once, on the first open only: a file GATE could not check before Godot had registered the classes is remembered as built-but-unchecked, so the next session checks it without building it again.
Leave script_templates/ alone. Godot keeps a .gdignore in that folder, and GATE does
not compile anything under a .gdignore, so a template renamed to .gate is never turned
back into a .gd and simply disappears from the Script Create dialog.
Write .gate, get .gd. The output is ordinary GDScript with a header naming its source,
plus a .gd.map recording which source line each output line came from.
# player.gate
extends CharacterBody2D
pub int health = 100
Node2D? target
func hurt(amount: int) -> void:
health -= amount
target?.notify_damage(amount)
# player.gd, generated
extends CharacterBody2D
var health: int = 100
var target: Node2D = null
func hurt(amount: int) -> void:
health -= amount
if target != null:
target.notify_damage(amount)
The output is indented the way the rest of your project is. In the editor, the indent type
and size come from Editor Settings -> Text Editor -> Behavior -> Indent. With no editor
running, a headless build takes the first indented line of the .gate file itself, so a
renamed .gd keeps the look it had. A tab is the last resort. Changing the setting rewrites
the outputs on the next build.
Delete the header, then the .gate file, and what remains is a normal script you can
maintain by hand: the leftover .gd.map can go with them, and the .gd.uid stays. There
is no runtime, no dependency, and nothing to ship.
What proves a file up to date is kept in .godot/gate/, so opening a project nothing has
changed in compiles nothing. Deleting that folder costs one full build and nothing else.
A file is rebuilt when something it reads changes. From another .gate it reads the
declarations, struct field defaults, signal parameter types, and what each function may set
to null. A comment, or an edit inside a function body that changes none of those, rebuilds
only the file it was made in. A hand-written .gd counts only for the .gate files that
reach it, by class_name, as a base, as an autoload or by path, and only through its
declarations. Of project.godot, only the autoloads and the debug/gdscript warning
settings count, so switching a plugin on or off builds nothing.
Project -> Tools -> Recompile all GATE files ignores all of this and looks at every
.gate again.
The type goes before the name, so the typed form is shorter than the untyped one. var
still works exactly as it does in GDScript.
int hp = 100 # var hp: int = 100
str label = "player" # var label: String = "player"
vec2i[] tiles # Array[Vector2i]
int[] scores # Array[int]
{str, int} counts # Dictionary[String, int]
int[][] grid # Array[PackedInt32Array] - GDScript rejects nested typed collections
The short names are spellings, not new types. str is String, vec2 is Vector2,
f64 is float: same value, same methods, same thing every Godot API takes and returns,
with no wrapper and no conversion. Write String instead if you prefer - both compile to
the same line.
This form replaces nothing. var hp: int = 100 means what it always did, and GATE's
own types work in that position too, so nothing here is behind the type-first spelling:
var target: Node2D? = null # tracked nullable
var scores: int[] = [1, 2] # Array[int]
var id: <int | str> = 7 # union, emitted as Variant
Use the null tracking, structs, generics and the rest without writing a single type-first
declaration if that suits you better. Type-first is there because the type is the thing
worth reading first in a block of fields, and because it is shorter. That is the whole
reason; there is no technical one. Parameters keep GDScript's own name: Type form, which
is an inconsistency on purpose: making them type-first is a spelling change that costs the
parser more lookahead than the rest of the grammar, so it sits in ROADMAP.md
rather than half-built here.
T? is a tracked nullable type. A flow-sensitive pass follows guards, early exits and
assignments, and rejects a dereference it cannot prove is safe. It only ever complains
about types you declared nullable, so plain GDScript cannot trip it.
Node2D? target
func update() -> void:
target.queue_free() # error: 'target' may be null
target?.queue_free() # fine
if target != null:
target.queue_free() # fine
?. gives null when its left side is null, so its result is itself a value that may be
null. Where a may be null, str n = a?.label is an error and str n = a?.label ?? "" is
not.
struct has value semantics, class has reference semantics. A struct of two to four
same-typed int or float fields becomes a Vector2/3/4 and costs nothing; anything else
becomes a class and GATE inserts the copies that keep it a value.
struct Damage:
int amount
str source
func doubled() -> int:
return amount * 2
var a = Damage(7, "hit")
var b = a # a copy, not an alias
Interfaces are checked at compile time and testable at runtime. Traits inline their members into the implementor. Namespaces lower to inner classes.
interface Damageable:
func take_damage(amount: int) -> void
trait Poolable:
int _pool_id = -1
func reset() -> void:
_pool_id = -1
class Enemy extends CharacterBody2D implements Damageable with Poolable:
func take_damage(amount: int) -> void:
pass
@global on a top-level class gives it its own Godot script beside the file that
declares it, carrying class_name - a real, attachable script that shows up in the
Create Node dialog, rather than an inner class of the .gate's own output. The name is
project-wide, the same as a file's own class_name.
# res://src/bullet.gate
@global class Bullet extends Node2D:
var speed: float = 400.0
# writes res://src/bullet.Bullet.gd, carrying `class_name Bullet`
Monomorphised, so Pool<Bullet> holds a real Array[Bullet] rather than an untyped one.
A generic is monomorphised once, in the file that declares it, so is Pool<Bullet> holds
across files.
class Pool<T> extends Node:
T[] _items
func acquire() -> T?:
return _items.pop_back() if _items.size() > 0 else null
var bullets = Pool<Bullet>.new()
A function takes type parameters the same way, and one copy is emitted per set of type arguments used. They are written out rather than inferred. Another file's generic function is called through its class name or a preloaded const and monomorphised where it is declared, the same as a generic class.
func first<T>(xs: T[]) -> T:
return xs[0]
print(first<int>(ns)) # __first_int(ns)
All three compile to plain GDScript types and are checked at compile time. A union holds one of its members, a tuple is an array of fixed length with a type per slot, and a typed callable says what it takes and returns.
<int | str> id = 7 # var id: Variant = 7
<int, str> entry = [7, "hp"] # var entry: Array = [7, "hp"]
func(int) -> bool keep = func(x): return x > 0
func update() -> void:
id = 1.5 # error: float is not int or String
entry[1] = 3 # error: slot 1 is a String
keep.call("7") # error: expects int
type names a type once; nothing is emitted for it. PackedScene<T> makes
instantiate() return a T.
type Hp = int
type Loot = <Item | Gold>
Hp health = 100 # var health: int = 100
PackedScene<Enemy> scene = preload("res://enemy.tscn")
Enemy e = scene.instantiate() # typed, no cast
Signals you declared with types are checked too: hit.emit("x") on signal hit(amount: int)
is a warning. Only a warning, because Godot accepts it.
After if x is T:, or an early exit that proves it, x is a T. A match arm can test a
type and bind it in one go, and it composes with when.
func area(s: Shape) -> float:
match s:
Circle c:
return PI * c.r * c.r
Rect r when r.w == r.h:
return r.w * r.w
Rect r:
return r.w * r.h
return 0.0
func hit(n: Node) -> void:
if n is not Enemy:
return
n.take_damage(1) # n is an Enemy here
Foo f = x as Foo is an error unless x is Foo was proved first, since a failed as is
null. Plain GDScript's (x as Foo).bar() is left alone.
.xy, .xz, .zyx and the rest work on any statically typed vector, reading and
writing. An object can be built and filled in one expression.
vec3 p = Vector3(1, 2, 3)
vec2 flat = p.xz # Vector2(p.x, p.z)
p.xy = Vector2(5, 6)
var e = scene.instantiate({ position: spawn, hp: 40 + wave * 10, target: player })
var l = Label.new({ text: "Ready", modulate: Color.RED })
var d = Damage({ amount: 3, source: "fire" })
@required asserts in _ready that an exported node was assigned, in debug builds.
@export_if shows a property in the inspector only while a condition holds; it needs
@tool, and GATE will not add that for you.
@tool
extends Node
@export @required Node2D target
@export bool use_timer = false
@export_if(use_timer) float delay = 1.0
@if / @elif / @else / @endif resolve when the file compiles. A dropped block is
blanked, not deleted, so every line after it keeps its original number, and it is never
parsed, so it can hold code that would not compile at all. Symbols come from Godot itself:
DEBUG, RELEASE, EDITOR, EXPORT, platform and architecture tags, and GODOT_VERSION
in a comparison. An unknown symbol is a compile error, not a silent false. At export, each
.gd is recompiled with the preset's own values, so a release build genuinely lacks a
@if DEBUG block.
@if DEBUG
print("debug build")
@elif GODOT_VERSION >= 4.7 && WINDOWS
print("windows, recent Godot")
@else
print("release")
@endif
var name = player?.profile?.name # safe navigation
var hp = saved ?? 100 # null coalescing
var [x, y] = get_pos() # destructuring
var [int x, int y] = get_pos() # ...and the names may take types
var msg = f"{player} scored {pts}" # f-string
a, b = b, a # swap
for i, item in enumerate(items): # index and value
for k, v in scores: # dictionary key and value
nums.map((x: int) => x + 1) # arrow lambda
An arrow lambda is a one-expression func lambda with the noise removed: the parentheses
are always required, and the parameters follow the same rules a func lambda's do.
Two spellings are behind a project setting, both off by default, because each is a second way to write something GATE already has:
int[,] grid = [[1, 2]] # gate/features/multidim_arrays, = int[][]
var shown = raw |> roundi() |> mini(99) # gate/experiments/pipe_operator, = mini(roundi(raw), 99)
Writing either with its flag off is an error naming the setting.
Five of GATE's spellings are switches, under Project Settings > Gate. Writing something a switch is off for is a compile error that names the setting and the spelling to write instead, so nothing fails quietly.
int hp = 100 is an error and var hp: int = 100
is the spelling to write. Nothing else goes with it: GATE's types work in a GDScript
annotation too, so null tracking, structs, generics and the rest stay reachable. The one
thing with no replacement is the shared line, int a, b, GDScript having no multi-name
declaration.str, vec2, f64 and the rest are not type
names anywhere, and String, Vector2 and float are what to write. A class, struct,
enum or const your own project declares with one of those names keeps its meaning either
way; the setting only decides whether the shorthand meaning exists.vec2.ZERO is Vector2.ZERO and vec2(1, 2) is Vector2(1, 2). Off, both are
an error. str(...) called as a function is GDScript's own builtin either way.int[,], another spelling of int[][].x |> f(), another spelling of f(x).A default is never written into project.godot, so a project that leaves these alone
carries nothing for them.
With the plugin enabled, Godot treats .gate as a language of its own.
.gate files appear in the FileSystem dock, with their own icon. Before this they
were invisible: Godot did not track the extension at all.T?, T[], unions, f-strings, struct, interface,
namespace and the rest. Every type name colours, short or canonical, and so does
the name a struct, interface, trait, namespace, type or class declares
and a generic's parameter. A name starting with _ is dimmed. The colours come from
your own editor theme, and follow it when you change it..gate, in the same warnings panel and in the
same words: unused variable, unused local constant, unused parameter, unused signal,
and a local, parameter or for variable that shadows a member of this class or of a
base - another .gate, a hand-written .gd, or an engine class. Before this they
existed only against the generated .gd, which you are told never to open.
@warning_ignore, @warning_ignore_start/_restore and the switches under
Project Settings -> Debug -> GDScript are honoured, so a warning you turned off
stays off. The warnings that need the whole project, and the ones about types, are
still the build's.class_name in another .gate or in a hand-written .gd, a member of any of
those including one it inherits, and locals, parameters and fields in the file you
are in. It goes through autoloads, so Global.current_project.frames reaches the
field's declaration: a script autoload stands for its script, and a scene autoload for
its root node's script, or the root's engine class when it has none. A base written as
a path, extends "res://base.gd", is followed like a named one. An engine class opens
its documentation instead. Where GATE cannot place a word it says so by not
underlining it..gate and the running game stops there, and
the stop is shown in the .gate with the line marked, not in the generated .gd.
Stepping follows. A breakpoint on a line the game can never stop on - a blank, a
declaration that lowers away, or a comment, which is carried through but is still
not something Godot breaks on - is taken back with a toast saying why, rather than
left sitting there never firing. Godot's own debugger panels still name the .gd..gate, a T? field, a
class_name, a hand-written .gd class, an autoload, an engine class - including
everything inherited, all the way up, through a base written as a path too. ?. is
the same. String, Array, Vector2 and the engine's other built-in types offer
their methods, properties and constants, from a table generated from Godot 4.7.2's API.
What a typed container holds carries through an index, the Array and Dictionary
methods that return an element, and a for loop's variable, so with Frame[] frames
both frames[0]. and f. inside for f in frames: offer what a Frame has. A
variable declared with :=, or a constant, takes its type from its initialiser; one
declared with a plain = is still untyped. Unqualified, it offers locals, parameters, the file's own members and
everything it inherits, the autoloads, every type in the project, GATE's type
shorthands and keywords, and the engine's classes. After @ it offers the
annotations, GATE's and Godot's. On another object a dot leaves out the methods that
start with an underscore, such as _ready, since those are there to be overridden;
on self they are offered. A name is offered only where the caret can actually see
it: a parameter inside its own function, a local below its own line, a for
variable inside its loop. Typing the start of a virtual in a class body offers the
base's overridable methods with their signature filled in, and typing a call's
opening bracket shows that function's parameters..gd
would get. A .gate answers the editor with the engine class at the root of its
extends chain, which is the one question the Scene dock asks before it allows the
drop.priv func, pub func, virtual func, an operator overload, and any of those with
static, each jumping to its line in the .gate..gate tabs come back next session. Godot's own layout never holds them;
GATE keeps them and reopens them itself. That is so a session with GATE switched off
finds nothing to reopen: without GATE, Godot would open a .gate as plain text and
rewrite its indentation on exit, badly enough that it no longer compiles. Switching
GATE off from the Plugins tab closes its tabs, saving any unsaved edit first, and a
session with GATE off never rewrites a .gate..gate that used CRLF is written back with
CRLF; a new file, or one that used LF, stays LF..gate and attaches the .gd. Choose GATE in the
Scene dock's Attach Script dialog and it writes the .gate from the template you
picked, compiles it, and puts the generated .gd on the node. The node runs the
.gd, as it has to; the .gate is what stays open in the script editor. If the
build fails, GATE says so on screen, leaves the .gate alone so you can fix it, and
attaches nothing..gate again..gate sources, their .uid files, the
.gd.map sourcemaps, the compiler and the editor integration are all left out of
the PCK; the compiled .gd is what ships. Four small scripts from
addons/gate/editor/ do ship - loader.gd, saver.gd, registry.gd and
script.gd - because Godot's class list names the first two and a game that cannot
find them prints six errors at start-up. They do nothing in a game. If you exclude
addons/gate/ in your export preset yourself, keep those four.Two things deliberately do not work.
= and no
type, an untyped container after an index, the return of an engine method GATE
cannot follow - the list is empty rather than filled from somewhere else. It follows
a chain of names, calls and indexes, not arbitrary expressions..gate in another editorGATE speaks LSP, so a .gate can be edited in Neovim, Helix, Zed or VS Code. The server
runs inside the Godot editor, the way Godot's own GDScript language server does, which is
what makes what it tells your editor and what the script editor shows you come from the
same code rather than from two implementations that drift apart.
It is off unless you ask for it. In Project -> Project Settings -> GATE:
gate/lsp/enabled - off by default. Nobody gets a listening socket they did not want.gate/lsp/port - 6100 by default. It binds 127.0.0.1 and nothing else. If that port
is already taken, GATE says so and stops instead of moving to another one: a client is
configured with a fixed port, and a server that quietly moves is a server the client
never finds.Godot has to be running with the project open for anything to answer.
While a client is attached, GATE keeps the editor's main loop at about 60 Hz. Godot sleeps 100 ms between frames when its window is not focused, and not focused is what it is the whole time you are typing somewhere else, which is exactly when this is answering: the server polls the socket once a frame, so that period is how long a reply can sit waiting. It is handed back the moment the client disconnects, and a focused editor, which is already quicker than 60 Hz, is left alone.
The server is a socket rather than a program, so a client has to connect to it rather than start it. That is the same shape as Godot's own GDScript server, and it is why an editor that can only launch a language server needs something in between.
Neovim connects to a socket on its own:
vim.lsp.start({
name = "gate",
cmd = vim.lsp.rpc.connect("127.0.0.1", 6100),
root_dir = vim.fs.dirname(vim.fs.find({ "project.godot" }, { upward = true })[1]),
})
VS Code has no built-in way to attach to a language server at all; every language
there arrives through an extension. There is one for GATE in the GateVsCode repository
beside this one, unpublished and installed by copying a folder.
Helix and Zed launch a language server as a child process and talk to it over its
standard input and output, so they need a bridge that forwards stdio to the socket. Any
of nc, socat or a three-line script will do it; none of them is installed on Windows
by default.
What it answers today. Diagnostics, completion, signature help, go-to-definition, hover and a document outline. Anything else is answered "method not there", which is what makes a client fall back to its own word list rather than conclude GATE has nothing to say.
Go-to-definition refuses rather than guesses. It answers exactly where Ctrl-click in
the script editor answers, because it asks the same code: a local, a parameter, a member,
a function, a signal, an enum, a type declared in another .gate, a class_name in a
hand-written .gd, an autoload, and anything inherited from a base, including a base
written as a path. A word it cannot place answers nothing at all rather than the nearest
name that looks similar. An engine class answers nothing too, since there is no file to
open; hover still describes it. The answer is always a location in something you wrote -
the .gate, or a .gd you wrote yourself - never in the .gd GATE generated.
An enum member takes you to its enum. GATE's parser keeps an enum's keys as names on
the declaration and gives them no line of their own, so Mood.CALM opens the line enum Mood is on. On a one-line enum that puts the caret on the key itself; on an enum whose
body runs over several lines it puts you at the top of it.
Hover shows the declaration and its declared type. The line as you wrote it, and the
type GATE has for it. A var with no type on it gets no type line, because the type of
one of those comes out of the build, against the whole project, and a hover is answered
from one file. It says nothing at all rather than showing an empty tooltip.
The outline is the file's own declarations. Classes, structs, interfaces, traits, namespaces, enums and their members, functions, signals, constants and fields, nested the way you wrote them. A file that is mid-edit still gets an outline: a half-typed declaration costs its own entry and nothing else.
Diagnostics come in two tiers, and the difference matters. As you type, you get what
Godot's script editor underlines on the same file: the lexer's errors and Godot's own
unused and shadowed-variable warnings. When you save, you also get what a compile finds -
unknown types, the null analysis, the superset checks - which is everything GATE knows
about that file. A type declared in another .gate resolves, because the editor already
holds the project's registry. Where GATE has a hint it arrives as related information, so
your editor can show the message on the line and the hint on demand, and a problem both
tiers find is reported once.
The split is not arbitrary. The lexer pass takes 0.2 ms on a small file and 12.5 ms on a 238-line one; compiling the same two takes 5.8 ms and 259 ms, and it runs on the editor's main thread, which means Godot is not doing anything else while it does. Paying that on every pause in your typing would be paying it dozens of times a minute for an answer that has usually not changed. So a keystroke buys the fast tier and a save buys both. If you want GATE's full opinion on a file, save it.
What it still does not carry. The rest of the project. You get every diagnostic for the file you saved, not for the files that depend on it, and a build still reports those to Godot's Output panel.
Completion offers nothing rather than guess, here too. The list is the one the script
editor gives, in the same order, with GATE's kinds mapped to your editor's icons. Where
GATE cannot establish the type of what is left of the dot, the answer is an empty list.
Signature help names the call you are inside and highlights the argument you are on,
including after a comma inside a nested call. A method of a Variant built-in such as
String or Array gets no signature at all: Godot's own API dump records those methods
with a name and a return type and no parameters, so there is no argument list to show, and
an empty one would say the method takes none.
Unsaved changes count for the file you are in, and only that one. While your editor holds a file open, everything GATE says about that file is computed from your buffer. Anything it has to read from another file - a base class, when it is working out whether a local shadows a member of it - is read from disk. So a member you added to a base class and have not saved is not taken into account while GATE looks at the file that extends it. Saving fixes it once Godot has noticed the file changed, which happens the next time the editor scans the filesystem.
One file, one editor. A .gate open in Godot's script editor and in an external
editor at the same time is two buffers over one file, and GATE does not arbitrate between
them. Pick one.
The generated code is not slower than the GDScript you would have written: across 110 benchmarks the median ratio is 0.98, inside a 1.6% noise floor.
Where GATE is faster, it is because of a lowering you would not write by hand. A struct
local that provably never escapes its function becomes one plain local per field, with no
allocation and no copy - 13.5x on a 300k-iteration loop. An array of structs that packs
into a Vector is 9.9x faster than the idiomatic Array[SomeClass].
@soa is the one to be careful with: it is about 2.4x slower to build and 1.9x faster to
iterate, so it pays off only if you make more than about 1.3 passes per rebuild.
## docs included, and that covers one written inside an array
literal, a dictionary literal, an enum body or a call's argument list - such a
construct takes its one-per-line form so each comment has a line of its own, and one
carrying no comment is emitted exactly as before. Two cases have nowhere to go: a
comment inside a lambda passed as an argument belongs to statements in that body rather
than to the argument list, and one inside grouping parentheses has nothing to attach to,
because GATE drops the parentheses and re-emits the expression. Measured over 3,495 real
files, 626 of the 764 comments written inside brackets are carried and these 138 are
not. A comment written on the end of a line of code is also dropped in the one case
where that line became more than one line of output, or none: @rpc func f() written
on a single line comes out on two, and there is then no single right place for it..gd. The game runs the .gd, so the
Stack Frames list and a runtime error point at it; a toast names the .gate line each
came from. Breakpoints and stepping are shown in the .gate. A stop on a generated line
with no .gate line behind it - a helper GATE wrote - is left showing the .gd,
deliberately, rather than pointing at a .gate line that is not the one running.as is only checked in GATE's own declarations. x as T is null when the cast
fails. Inside a function, Foo f = x as Foo is an error unless an if x is Foo: or an
early exit proved it, or you declare it Foo?. Everywhere else, (x as Foo).field,
var y = x as Foo, arguments and field initialisers, the cast is trusted, so plain
GDScript keeps its meaning. That is a known unsound spot in the null analysis.d.next clears what GATE knew about next
on every reference that may be the same object: the same or a related class, or an
unknown type. Three gaps remain. A call GATE cannot see into, like an engine method,
clears what is under the references it is given but not their aliases. What it returns
is taken to be a new object. And a function that writes through an array or dictionary
holding a parameter (var arr = [c] then arr[0].next = null) is not seen by its
callers to write to c.Reg.cached.next,
falls back to the aliasing rule above rather than to the field itself. And a static function
of a subclass does not track a static field it inherits from its base.is narrows locals, parameters and typed instance fields. An untyped field, a static
var or an autoload can be rebound by a call GATE cannot trace, so a check on one does not
narrow it at all. Copy it to a local and test that.arr[0].c maps
to the Vector component when GATE knows what the container holds - a declared S[], a
parameter, a loop variable or a literal of one struct. From an untyped local grown with
append, or an untyped parameter, there is no element type, and the read fails at run
time.a()[0]. Every pass after the parser
walks the tree once per level and GDScript stops at 1024 calls, so deeper than that the
passes ran out of stack and handed back answers that were quietly cut short. A file that
nests deeper is refused with one error naming where the expression starts. This is a place
where valid GDScript is refused: Godot's own parser takes it. Deep member chains also cost
time quadratic in their depth, which only shows well past a depth real code reaches.not (not (...)) past 46 levels exhausts the stack before the cap can report it, and
what GATE writes for such a file does not recompile to itself.class_name declared by another file in the same build is
written with a warning and validated once that class is registered, usually by the next
build. Godot resolves global class names from the editor's class list, which only updates
after the file is on disk.x is SomeInterface is false for a
class GATE did not compile.@global can; @global does not reach inside a namespace.@global class's own body can only name itself, a project-wide name, or something it
declares itself - not a plain sibling declared at the same file's own top level: a
class, struct, interface, trait, namespace, enum, const, var, signal or func that is not
itself @global, since that sibling is not there any more once the class is extracted
into its own script. Refused at compile time, naming the sibling; mark it @global too,
or move it inside the class..gate in the editor rewrites its indentation to whatever
Text Editor -> Behavior -> Indent says, as Godot does for a .gd. GATE reads tabs
and spaces alike, so the output is unchanged either way..gate opened before GATE 1.1.0 keeps "Plain Text" highlighting. The choice is
remembered per file in .godot/editor/script_editor_cache.cfg; pick GATE once from the
highlighter menu and it sticks.@define. A custom symbol comes from the
project setting gate/compiler/defines only. A comparison operator is defined only for
GODOT_VERSION. Directives are line-based: half an expression cannot be dropped. The
editor shows a dropped block as ordinary code; it is not greyed out.GATE is developed against a 26-stage test suite: differential execution against Godot itself, a real-world superset corpus, build-pipeline scenarios across editor restarts, live editor sessions, sourcemap fidelity, byte-exact output comparison and randomised input. That suite is not part of this repository; what ships here is the addon.
What it established at v1.1.0:
.gd files, every one in both corpora that is not a copy of GATE itself,
compiled and then recompiled from their own output, with zero outputs that fail to load and
zero fixed-point breaks. The 17 that do not compile are Godot 3 syntax or mix tabs and
spaces for indentation, and Godot 4 rejects every one of them too..gate and built
through the plugin, then again after each of three editor restarts - with zero failed files,
and every output loads.The differential harness is the one that matters. It does not ask whether GATE accepted the file, it asks whether Godot's answer equals GATE's answer. Every silent-wrong-value defect in this project was found that way and none were found any other way.
The documentation in this repository, meaning this README, SYNTAX.md, CHANGELOG.md
and ROADMAP.md, was written by AI and then checked by me before it shipped. Every
claim about behaviour is one I verified against the test suite or the compiler
itself, and every number quoted is one that came out of a run rather than an
estimate. Where it is still wrong, that is a mistake I missed, so please open an
issue and I will fix it.
MIT. See LICENSE.
108 followers · starred Sep 2026
175 followers · starred Sep 2026
75 followers · starred Sep 2026
154 followers · starred Sep 2026