Use Go's static typing, fast compilation, and memory safety to write Godot games and tools — with full editor integration and a clean pkg.go.dev API reference.
Key features:
- Strongly typed RIDs, Callables, and Dictionary arguments — no silent runtime errors
- Full API documented on pkg.go.dev with Go code snippets
- Pure-Go
variantpackages (Vector2, Vector3, Quaternion, etc.) reusable in any Go project - Fast incremental recompiles after the initial build — feels like a scripting language
Install Go from go.dev/dl, then make sure $GOPATH/bin is on your PATH:
# macOS / Linux
echo 'export PATH="$PATH:$GOPATH/bin"' >> ~/.zshrc && source ~/.zshrc
# Windows
setx PATH "%PATH%;%GOPATH%\bin"Install the gd CLI — a drop-in replacement for the go command that handles all Godot build flags automatically:
go install graphics.gd/cmd/gd@releaseNote:
gddownloads and manages Godot, Zig, and other dependencies automatically. SetGDTOOLCHAIN=localto manage them yourself.
Start with a single main.go. The gd command creates the graphics/ Godot project directory, a blank project.godot, the .gdextension library file, and export presets automatically on first run.
// main.go — the minimum needed to launch
package main
import "graphics.gd/startup"
func main() {
startup.Scene()
}gd # launch the Godot editor
gd run # run in debug mode
gd build # export a release build (respects GOOS / GOARCH)
gd test # run Go tests inside the Godot runtimeRecommended directory layout:
my_game/
├── graphics/ # Godot project lives here
│ ├── project.godot
│ ├── main.tscn
│ ├── library.gdextension
│ └── assets/
├── internal/ # Go packages
│ └── mypackage/
├── releases/ # exported builds
├── go.mod
├── go.sum
└── main.go
graphics.gd compiles your Go code into a shared library (.so / .dll / .dylib) that Godot loads via GDExtension. You can open the graphics/ subdirectory in the standard Godot editor — no special fork or plugin needed.
If you prefer to drive the build yourself instead of using gd run:
# Linux / macOS
CC="zig cc" go build -o graphics/libmygame.so -buildmode=c-shared
# Windows (cross-compiling with Zig)
GOOS=windows GOARCH=amd64 CC="zig cc -target x86_64-windows" \
go build -o graphics/mygame.dll -buildmode=c-sharedThe .gdextension file (created by gd the first time, or written by hand) tells Godot where to find the library:
[configuration]
entry_symbol = "gdextension_entry"
compatibility_minimum = "4.1"
[libraries]
linux.debug.x86_64 = "res://libmygame.so"
windows.debug.x86_64 = "res://mygame.dll"
macos.debug = "res://libmygame.dylib"Place this file (e.g. library.gdextension) inside graphics/ alongside project.godot. Godot loads it automatically when you open the project.
- Open the standard Godot editor (
godot -eor via the project manager). - Point it at
graphics/project.godot. - Your registered Go classes appear in the Add Node dialog and the Inspector — exactly like built-in nodes.
After the first full build, recompile with:
go build -o graphics/libmygame.so -buildmode=c-sharedThen in the Godot editor choose Project → Reload Current Project (or press the reload button). Godot re-loads the library and picks up your changes without a full editor restart.
Using
gd runinstead automates this loop: it rebuilds the library, then launches Godot in run mode.
Embed the Extension type of any Godot class to extend it. Exported fields become editor-visible properties (exposed as snake_case), and exported methods become callable from .gd scripts.
package main
import (
"fmt"
"graphics.gd/classdb"
"graphics.gd/classdb/Node2D"
"graphics.gd/startup"
)
type Player struct {
Node2D.Extension[Player]
Speed float64 // → inspector property "speed"
Health int // → inspector property "health"
}
// Implements the Node virtual callback Ready().
func (p *Player) Ready() {
fmt.Println("Player ready!")
}
// Callable from GDScript as player.take_damage(amount).
func (p *Player) TakeDamage(amount int) {
p.Health -= amount
}
func main() {
classdb.Register[Player]()
startup.Scene()
}| Tag | Effect |
|---|---|
gd:"my_name" |
Override the engine-facing name of a field or node path |
range:"0,100,1" |
Render the field as a slider in the Inspector |
group:"Combat" |
Group properties under a named header in the Inspector |
gd:"-" |
Hide the field from the engine entirely |
Embed classdb.Tool to make your methods run while the editor is open — equivalent to @tool in GDScript:
type MyTool struct {
Node.Extension[MyTool]
classdb.Tool // methods fire in the editor
}Use Engine.IsEditorHint() to branch between editor and runtime behaviour.
func main() {
classdb.Register[Player]()
startup.LoadingScene() // wait until the engine is ready
SceneTree.Add(new(Player)) // add player to the root
startup.Scene() // blocks until the engine shuts down
}Alternatively, skip Go-side scene setup entirely: register your classes, call startup.Scene(), and place the nodes in the Godot editor's scene tree manually — they will use your Go implementation automatically.
Any exported Node-derived field is auto-populated: graphics.gd searches the scene tree for a child with the matching name and fills the field, or creates a new instance if none is found.
type HUD struct {
Control.Extension[HUD]
ScoreLabel Label.Instance `gd:"ScoreLabel"` // finds child named "ScoreLabel"
HealthBar ProgressBar.Instance // finds child named "HealthBar"
MiniMap Node2D.Instance `gd:"%MiniMap"` // unique-name lookup (%MiniMap)
}
// Requires imports: "graphics.gd/classdb/Label",
// "graphics.gd/classdb/ProgressBar"
func (h *HUD) Ready() {
h.ScoreLabel.SetText("Score: 0")
h.HealthBar.SetValue(100)
}The gd tag accepts a plain name, a relative path (Panel/Label), or a unique-name reference (%NodeName).
Every Godot class has a New() function returning a convenient Instance type with all methods. Use Object.As / Object.To for safe and unsafe type conversions.
// Create a RigidBody3D
var body RigidBody3D.Instance = RigidBody3D.New()
body.SetName("Boulder")
// Safe cast — check before use
if rb, ok := Object.As[RigidBody3D.Instance](someNode); ok {
rb.ApplyCentralImpulse(Vector3.New(0, 10, 0))
}
// Check type without extracting
if Object.Is[*Player](someNode) {
fmt.Println("it's a player!")
}
// Panic-safe cast — use only when you're certain of the type
label := Object.To[Label.Instance](genericNode)All instances expose an ID() method returning an object-specific ID type suitable for long-term storage without keeping a live reference.
Implement Draw() on a Node2D.Extension for immediate-mode 2D rendering — similar to Love2D or Ebiten. Call QueueRedraw() each frame from Process() to animate.
type Game struct {
Node2D.Extension[Game]
angle float32
}
func (g *Game) Ready() {
// one-time setup
}
func (g *Game) Process(delta Float.X) {
g.angle += float32(delta)
g.AsCanvasItem().QueueRedraw()
}
func (g *Game) Draw() {
canvas := g.AsCanvasItem()
canvas.DrawCircle(Vector2.New(300, 300), 60, Color.X11.Cyan)
canvas.DrawRect(
Rect2.New(10, 10, 100, 40),
Color.X11.Orange,
)
}
func main() {
classdb.Register[Game]()
startup.LoadingScene()
SceneTree.Add(new(Game))
startup.Scene()
}You can also place a Node2D in the Godot editor scene tree and attach your Game class to it from the Inspector's script dropdown — the Draw() method will still be called.
Define signals as typed fields using Signal.Void (no arguments) or Signal.Solo[T] (one argument). Signals are safe to emit from goroutines — handlers are queued to the main thread automatically.
type Enemy struct {
Node.Extension[Enemy]
Died Signal.Void // emitted with no arguments
Damaged Signal.Solo[int] // emitted with one int argument
}
func (e *Enemy) TakeDamage(amount int) {
e.Damaged.Emit(amount)
if e.health <= 0 {
e.Died.Emit()
}
}Connect from GDScript exactly as you would with any built-in signal:
enemy.died.connect(_on_enemy_died)
enemy.damaged.connect(func(amount): print("took ", amount))graphics.gd tracks every engine reference per-frame. Keep objects alive by storing them in Extension struct fields. If you need a reference to outlive its natural frame window, call Object.Use() on it, or store only the ID.
type World struct {
Node.Extension[World]
player Node.Instance // kept alive — stored in an Extension field
}
// For infrequent lookups, store an ID instead of a live reference.
var playerID Player.ID
func (w *World) Ready() {
p := Player.New()
playerID = p.ID()
}For the rare case where you need manual memory control:
// Opt out of automatic GC — object lives until you free it.
node := Object.Leak(Node.Advanced(Node.New()))
// ... many frames later ...
Object.Free(node) // safe to call multiple timesWarning: Only store engine references in global variables after
startup.LoadingScene(),startup.Rendering(), orstartup.Scene()has been called — accessing them earlier causes a panic-on-use.
- Fire-and-forget calls are faster. Engine functions with no return value pipeline internally — avoid blocking on return values in hot paths.
- Use constant strings. Constant strings are allocated in the engine once; dynamic strings are copied on every call.
- Prefer the main thread. Most internal optimisations target engine calls made on the main thread.
- Use
Advancedtypes in hot paths. Initialise them once and reuse to avoid per-call allocations.
Exported methods use reflection under the hood, which always allocates. For methods called thousands of times per frame, register a trampoline:
classdb.Register[MyClass](
classdb.MakeTrampoline(
func(instance *MyClass,
method func(*MyClass, int64, float64),
args variant.Arguments) {
method(instance,
args.Get(0).Int64(),
args.Get(1).Float64())
}),
)You only need one trampoline per unique method signature. Ready(), Process(delta), and other well-known virtual callbacks are already optimised — no trampoline needed for those.
| Pattern | When to use |
|---|---|
startup.Scene() + Godot editor scenes |
Default. Full editor workflow, custom nodes in the inspector. |
startup.Rendering() + RenderingServer |
Porting an existing Go renderer; no scene tree needed. |
startup.MainLoop(new(MyLoop)) |
Replace the SceneTree entirely with a custom main loop. |
startup.AsExtension() |
Shipping a redistributable GDExtension for other Godot users. |
- API reference: pkg.go.dev/graphics.gd
- Sample projects (including exported web demos): github.com/quaadgras/graphics.gd — samples branch
- GDExtension background: Godot GDExtension docs
Godot's engine has its own type system — a fixed set of types it can pass across the GDExtension boundary: vectors, colors, strings, arrays, dictionaries, and so on. These are called Variants in Godot's terminology.
graphics.gd mirrors every one of these as a pure-Go package under graphics.gd/variant/. The critical word is pure-Go: these packages have zero dependency on Godot, no cgo, no engine calls. They're just Go structs and functions.
import "graphics.gd/variant/Vector2"
import "graphics.gd/variant/Color"
import "graphics.gd/variant/Rect2"
// This runs fine in any Go program, no Godot involved.
pos := Vector2.New(3, 4)
length := Vector2.Length(pos) // → 5.0So they serve two distinct purposes:
1. The FFI boundary type system. When you call an engine function that takes a Vector2 or returns a Color, these are the types that cross the boundary. They're what the engine actually understands.
2. Reusable math in pure Go. Because they have no Godot dependency, you can import graphics.gd/variant/Vector2 in a server, a CLI tool, a unit test, or any other Go project and get the same vector math Godot uses — dot products, normalization, lerp, and so on — without pulling in anything engine-related.
The "bring your own vectors" feature extends this further: the variant packages use Go generics so they can operate on any struct with the same underlying layout, not just their own types:
// Your own type, your own methods.
type Vec2 struct{ X, Y float32 }
// But you can still call graphics.gd vector functions on it
// with no conversion — the compiler accepts it because the
// underlying struct layout matches.
result := Vector2.Normalize(Vec2{3, 4})This means you're not forced into a single vector type across your whole codebase.
To understand what a trampoline solves, you need to know what happens when Godot calls one of your exported Go methods.
When GDScript calls player.take_damage(50), Godot passes the argument as a generic Variant value across the FFI boundary. graphics.gd receives it and needs to call your actual Go method func (p *Player) TakeDamage(amount int). It knows the method exists because it inspected your struct using reflection during registration.
The problem is that Go reflection allocates. Every single call to TakeDamage from a script causes a small heap allocation — for the reflected argument list, the reflected return values, the interface boxing. For a method called once or twice per frame, this is invisible. For a method called on hundreds of enemies every frame, you're generating constant GC pressure.
A trampoline is a hand-written, reflection-free bridge function you provide instead:
classdb.Register[MyClass](
classdb.MakeTrampoline(
func(
instance *MyClass,
method func(*MyClass, int64, float64),
args variant.Arguments,
) {
// You unpack the raw variant arguments manually.
// args.Get(0).Int64() is a direct memory read — no allocation.
method(instance, args.Get(0).Int64(), args.Get(1).Float64())
}),
)The name comes from the classic compiler technique: rather than jumping directly from caller to callee (which here would require reflection), you jump to a small intermediary — the trampoline — which does the argument unpacking and then jumps to the real function. In graphics.gd the trampoline is just a plain Go closure that speaks raw variant.Arguments on one side and your typed method signature on the other.
A few practical details worth knowing:
- You only write one trampoline per signature, not per method. If
TakeDamage(int64, float64)andApplyForce(int64, float64)share the same signature, one trampoline covers both. Ready(),Process(delta), and other well-known virtual callbacks are already optimised bygraphics.gdinternally — you don't need trampolines for those.- The feature is complete to write against today, but the docs note you may not observe the performance improvement until the underlying implementation is fully wired up — so it's worth writing now for correctness of intent, with the performance payoff coming later.
In short: trampolines are an opt-in escape hatch from reflection for your hottest code paths, written once per method signature.