Skip to content

Instantly share code, notes, and snippets.

@akhenakh
Last active June 1, 2026 12:57
Show Gist options
  • Select an option

  • Save akhenakh/5ff77e1a0f561fa011ad8a48cc00ae7d to your computer and use it in GitHub Desktop.

Select an option

Save akhenakh/5ff77e1a0f561fa011ad8a48cc00ae7d to your computer and use it in GitHub Desktop.
Tutorial for GD Godot + Go https://the.graphics.gd/guide/

Go + Godot Game Development with graphics.gd

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 variant packages (Vector2, Vector3, Quaternion, etc.) reusable in any Go project
  • Fast incremental recompiles after the initial build — feels like a scripting language

01 — Install Go and the gd command

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

Note: gd downloads and manages Godot, Zig, and other dependencies automatically. Set GDTOOLCHAIN=local to manage them yourself.


02 — Create a project

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 runtime

Recommended 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

03 — Use the compiled library in the regular Godot editor

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.

Building the shared library manually

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

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

Opening the project

  1. Open the standard Godot editor (godot -e or via the project manager).
  2. Point it at graphics/project.godot.
  3. Your registered Go classes appear in the Add Node dialog and the Inspector — exactly like built-in nodes.

Iterating quickly

After the first full build, recompile with:

go build -o graphics/libmygame.so -buildmode=c-shared

Then 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 run instead automates this loop: it rebuilds the library, then launches Godot in run mode.


04 — Register a class

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()
}

Struct tag options

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

Run in the editor (tool scripts)

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.


05 — Add nodes to the scene tree

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.


06 — Declarative child nodes

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


07 — Instantiate and convert engine objects

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.


08 — 2D rendering with Draw()

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.


09 — Signals

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

10 — Memory and reference lifetimes

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 times

Warning: Only store engine references in global variables after startup.LoadingScene(), startup.Rendering(), or startup.Scene() has been called — accessing them earlier causes a panic-on-use.


11 — Performance tips

  1. Fire-and-forget calls are faster. Engine functions with no return value pipeline internally — avoid blocking on return values in hot paths.
  2. Use constant strings. Constant strings are allocated in the engine once; dynamic strings are copied on every call.
  3. Prefer the main thread. Most internal optimisations target engine calls made on the main thread.
  4. Use Advanced types in hot paths. Initialise them once and reuse to avoid per-call allocations.

Trampolines (eliminating reflection in hot methods)

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.


Choosing an integration pattern

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.

Further reading

Notes

Variant packages

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

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


Trampolines

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) and ApplyForce(int64, float64) share the same signature, one trampoline covers both.
  • Ready(), Process(delta), and other well-known virtual callbacks are already optimised by graphics.gd internally — 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment