GATE README

The project's README from github.com/Blade67/GATE. As one markdown file: README.md.

GATE

GATE is a GDScript superset for the Godot Engine that adds extra features while compiling down to plain GDScript.

Requires Godot 4.7+. MIT licensed.

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.

Table of Contents

The full syntax is in SYNTAX.md.

Install

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.

Converting an existing script

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.

Usage

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 gets rebuilt

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.

Features

Type-first declarations

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.

Example
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.

Null safety

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.

Example
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.

Structs

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.

Example
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, traits and namespaces

Interfaces are checked at compile time and testable at runtime. Traits inline their members into the implementor. Namespaces lower to inner classes.

Example
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 classes

@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.

Example
# res://src/bullet.gate
@global class Bullet extends Node2D:
	var speed: float = 400.0
# writes res://src/bullet.Bullet.gd, carrying `class_name Bullet`

Generics

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.

Example
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)

Unions, tuples and typed callables

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.

Example
<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 aliases and typed scenes

type names a type once; nothing is emitted for it. PackedScene<T> makes instantiate() return a T.

Example
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.

Narrowing and match patterns

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.

Example
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.

Swizzling and object initialisers

.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.

Example
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" })

Editor annotations

@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.

Example
@tool
extends Node

@export @required Node2D target
@export bool use_timer = false
@export_if(use_timer) float delay = 1.0

Conditional compilation

@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.

Example
@if DEBUG
print("debug build")
@elif GODOT_VERSION >= 4.7 && WINDOWS
print("windows, recent Godot")
@else
print("release")
@endif

Expressions

Example
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.

Feature settings

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.

A default is never written into project.godot, so a project that leaves these alone carries nothing for them.

In the editor

With the plugin enabled, Godot treats .gate as a language of its own.

Two things deliberately do not work.

Editing a .gate in another editor

GATE 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:

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.

Pointing an editor at it

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.

Performance

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.

Known limitations

Verification

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:

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.

Documentation

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.

License

MIT. See LICENSE.

GATE (Blade67/GATE)

Searching Database…

283 ·GitHub
这是什么?GATE 是面向 Godot 引擎的 GDScript 超集,添加了空安全、结构体、泛型和接口等额外语言特性,同时直接向下编译为普通 GDScript。
这是一种新语言吗,还是我现有的 GDScript 仍然可用?任何有效的 GDScript 文件都已经是有效的 GATE:把 .gd 改名为 .gate 后即可无错误编译且行为完全一致,所以它是扩展而不是替代品。
怎么把 GATE 添加到我的 Godot 项目?把 addons/gate/ 文件夹复制到项目中,并在 Project Settings -> Plugins 下启用 GATE;之后它会监视文件系统,一旦有变化就把每个 .gate 文件编译成旁边的 .gd 文件。
我需要哪个版本的 Godot?需要 Godot 4.7 或更新版本;编译完成后无需额外发布任何东西,因为输出就是普通 GDScript,没有运行时或依赖。
把现有项目转换成 GATE 会带来问题吗?把脚本改名为 .gate 后,第一次重新打开编辑器时会触发一次性的大量解析错误(在 Pixelorama 这样的项目上约有 1,600 行),因为 Godot 会在 GATE 完成编译之前加载场景;关闭再重新打开编辑器即可消除,之后的打开也会恢复快速。
GATE 在 Windows、macOS 和 Linux 上都能用吗?可以,它作为编辑器插件在 Godot 内部运行,所以 Godot 4.7+ 支持的任何平台都能使用。
GATE 中小的数值结构体会怎样?由两到四个同类型 int 或 float 字段组成的结构体会直接编译为 Godot 的 Vector2/3/4,没有任何额外开销;更大的则会变成一个类,并通过必要的复制来保持值语义。
泛型在底层是怎么实现的?泛型采用单态化:泛型类或泛型函数会为每个用到的具体类型编译一次,所以 Pool 里保存的是真正的类型化 Array[Bullet],而不是无类型数组。
条件编译用来做什么?@if/@elif/@else 块用于检测 DEBUG、RELEASE、EDITOR、EXPORT 等 Godot 构建符号和平台标签,并按导出预设分别重新编译,因此发布构建会真正剔除仅调试用的代码。
使用 GATE 会给发布的游戏增加运行时开销吗?不会,输出就是普通 GDScript,没有运行时或依赖,GATE 自身的编译器只在编辑器内运行,绝不会出现在发布的游戏中。
Creator
Blade67
Size
1 MB
Stars
283
Age
2y
Commits · 90 Days