Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

godot-grpc brings native gRPC to Godot 4.6+ as a Rust GDExtension, built on godot-rust (gdext), tonic, and tokio.

Godot has no built-in gRPC support. Existing community options cover protobuf messages in GDScript but not gRPC services or streaming. godot-grpc provides:

  • Unary and streaming RPCs — server-, client-, and bidirectional-streaming.
  • TCP and Unix Domain Socketshttp://host:port or unix:///path/to.sock.
  • Three ways to call, none requiring a Godot-side build step:
    • Typed codegen — generated GDScript classes for the most ergonomic API.
    • Tier 2 (runtime descriptors) — load a descriptor file, call with Dictionary/GrpcMessage. Zero codegen.
    • Tier 1 (raw bytes) — the lowest-level building block.
  • Sound threading — tokio runs on a background thread; results are delivered on the Godot main thread, so Gd<T> is never shared across threads.

Who this is for

  • GDScript developers who want to talk to a gRPC backend from a Godot game, tool, or kiosk app — use tier 2 or the typed codegen.
  • Rust extension authors who depend on godot-grpc as a crate and want the channel/runtime/bridge while using their own generated tonic clients (tier 1).

Status

The functional core is complete and tested (unary + all four streaming modes, runtime + generated APIs). It is pre-1.0 — APIs may change before 1.0. Linux and macOS are supported; Windows is future work. MIT-licensed.

Installation

godot-grpc is a GDExtension: you build a native library and point a .gdextension manifest at it.

Requirements

  • Rust ≥ 1.94 (Edition 2024)
  • Godot 4.6+
  • Linux or macOS (Windows is future work)
  • protoc (only to produce descriptor sets / run codegen)

Build the extension

git clone https://github.com/quobox/godot-grpc
cd godot-grpc
# tier 1 only (smallest binary):
cargo build -p godot-grpc --release
# or with the tier-2 runtime descriptor API:
cargo build -p godot-grpc --features tier2 --release

The default feature set is tier-1 only, to keep the binary small. Enable tier2 if you want the Dictionary/GrpcMessage runtime API or use generated code.

Add it to your Godot project

Copy the built library into your project (e.g. res://addons/godot-grpc/lib/) and add a .gdextension manifest:

[configuration]
entry_symbol = "gdext_rust_init"
compatibility_minimum = 4.6
reloadable = true

[libraries]
linux.debug.x86_64   = "res://addons/godot-grpc/lib/libgodot_grpc.so"
linux.release.x86_64 = "res://addons/godot-grpc/lib/libgodot_grpc.so"
macos.debug          = "res://addons/godot-grpc/lib/libgodot_grpc.dylib"
macos.release        = "res://addons/godot-grpc/lib/libgodot_grpc.dylib"

When developing against a checkout, you can instead point the library paths directly at target/debug/ with a relative res://../../target/debug/... path.

Headless note: the first time a project loads a new .gdextension, run the editor once (godot --headless --editor --path <project> --quit) so Godot records it in .godot/extension_list.cfg. After changing registered classes, rebuild the library (cargo build) before launching Godot, or it loads a stale copy.

Verify

func _ready() -> void:
    print(ClassDB.class_exists("GrpcChannel"))  # true once loaded

Quick start

This walks through calling a Greeter service with a unary SayHello RPC, using the tier-2 runtime API (no codegen). Assume this proto:

syntax = "proto3";
package greeter;

service Greeter {
  rpc SayHello (HelloRequest) returns (HelloReply);
}
message HelloRequest { string name = 1; }
message HelloReply   { string message = 1; }

1. Export a descriptor set

godot-grpc reads your schema at runtime from a serialized FileDescriptorSet:

protoc --descriptor_set_out=greeter.descriptor.bin -I proto proto/greeter.proto

Place greeter.descriptor.bin in your project, e.g. res://greeter.descriptor.bin.

2. Call it from GDScript

extends Node

func _ready() -> void:
    var pool := GrpcDescriptorPool.new()
    pool.load_file("res://greeter.descriptor.bin")

    var channel := GrpcChannel.tcp("http://127.0.0.1:50051")
    var stub := pool.service("greeter.Greeter").client(channel)

    var call := stub.unary("SayHello", { "name": "world" })
    call.completed.connect(func(reply): print(reply.get_field("message")))
    call.failed.connect(func(status): push_error(status.message()))

That’s the whole flow: load schema → open channel → bind a service stub → call with a Dictionary, handle the completed/failed signals.

Unix Domain Sockets

Swap the transport — everything else is identical:

var channel := GrpcChannel.uds("/tmp/greeter.sock")

Connections are lazy

GrpcChannel.tcp(...) / .uds(...) return immediately; the connection is established on the first RPC. A bad address or unreachable server surfaces as a failed(status) signal on the call, not at channel-construction time.

Next steps

The two tiers

godot-grpc exposes the same transport through two layers. Both run over the identical channel/runtime/bridge; they differ only in how messages are represented on the GDScript side.

Tier 1Tier 2
Request/responsePackedByteArray (encoded)Dictionary / GrpcMessage
Schema needed at runtimenoyes (a FileDescriptorSet)
Build featurealways--features tier2
Best forRust authors with their own generated clients; embedded useGDScript users; the typed codegen builds on this

Tier 1 — raw bytes

The lowest level. You hand GrpcChannel an already-encoded request and receive encoded response bytes; you decode them yourself (e.g. with a generated prost type on the Rust side, or a GDScript protobuf library).

var channel := GrpcChannel.tcp("http://127.0.0.1:50051")
var call := channel.unary_call("/greeter.Greeter/SayHello", request_bytes)
call.completed.connect(func(bytes: PackedByteArray): ...)  # decode yourself

The method path is /<package>.<Service>/<Method>. Streaming uses server_stream_call, client_stream_call, and bidi_call.

Tier 2 — runtime descriptors

Load a FileDescriptorSet into a GrpcDescriptorPool and call services by name with Dictionary requests; responses come back as GrpcMessage objects with get_field / set_field / to_dict.

var pool := GrpcDescriptorPool.new()
pool.load_file("res://greeter.descriptor.bin")
var channel := GrpcChannel.tcp("http://127.0.0.1:50051")
var stub := pool.service("greeter.Greeter").client(channel)
var call := stub.unary("SayHello", { "name": "world" })
call.completed.connect(func(reply): print(reply.get_field("message")))

GrpcServiceStub methods: unary, server_stream, client_stream, bidi (named unary — not call — because Object.call is a Godot built-in).

The result of either is a GrpcCall

Every call returns a GrpcCall with these signals:

  • completed(response) — success (unary) or end-of-stream.
  • stream_item(message) — one server-/bidi-stream message.
  • failed(status: GrpcStatus) — a gRPC or transport error.
  • cancelled().

In tier 1 the payloads are PackedByteArray; in tier 2 they are GrpcMessage.

Typed codegen

For the most ergonomic API, protoc-gen-godot-grpc generates typed GDScript classes from your .proto files: typed message properties and await-able service methods.

Generate

Build the plugin, then run protoc with it:

cargo build -p protoc-gen-godot-grpc --release

protoc --plugin=protoc-gen-godot-grpc=target/release/protoc-gen-godot-grpc \
       --godot-grpc_out=res/generated \
       -I proto proto/greeter.proto

(The binary must be named protoc-gen-godot-grpc; protoc finds it via the --plugin= flag or on your PATH.)

For greeter.proto this emits:

  • greeter_messages.gdclass_name GreeterMessages with one typed inner class per message.
  • greeter_greeter.gdclass_name GreeterGreeter, a typed service stub.

The generated message file embeds the schema (as base64) and builds its own descriptor pool lazily, so the generated code is self-contained — no separate .descriptor.bin to ship.

Use

var greeter := GreeterGreeter.new(GrpcChannel.tcp("http://127.0.0.1:50051"))

var req := GreeterMessages.HelloRequest.new()
req.name = "world"                       # typed setter

var reply := await greeter.say_hello(req)  # typed coroutine
print(reply.message)                      # typed getter
  • Message fields become typed properties backed by get_field/set_field.
  • Unary methods are coroutines: await greeter.say_hello(req) returns the typed reply.
  • Streaming methods return a GrpcCall; wrap each stream_item with the generated Reply.wrap(message) for typed access.

Error handling caveat

The generated unary coroutine awaits the completed signal. On an RPC failure, failed fires instead and the generated method logs it via push_error — but the awaiting coroutine will not resume. If you need to react to failures in the await path today, use the tier-2 stub directly and connect both completed and failed. More ergonomic error handling on the await path is planned.

Requirements

Generated code uses the tier-2 runtime API, so build the extension with --features tier2.

Streaming

All four gRPC modes are supported. Responses always arrive on the Godot main thread via signals on the returned GrpcCall.

The examples below use the tier-2 stub; tier 1 has the equivalent server_stream_call / client_stream_call / bidi_call on GrpcChannel.

Server streaming

One request, many responses. Each message is a stream_item; completed marks the end of the stream.

var call := stub.server_stream("ListItems", { "page": 1 })
call.stream_item.connect(func(item): print(item.get_field("name")))
call.completed.connect(func(_end): print("done"))
call.failed.connect(func(status): push_error(status.message()))

Client streaming

Many requests, one response. Send with send_dict (tier 2) or send (tier 1, raw bytes), then close_send to finish; the reply arrives as completed.

# `chunks` is your own data to upload, e.g. an Array of PackedByteArray.
var call := stub.client_stream("UploadChunks")
call.completed.connect(func(reply): print(reply.get_field("status")))
for chunk in chunks:
    call.send_dict({ "data": chunk })
call.close_send()

Bidirectional streaming

Send and receive independently.

var call := stub.bidi("Chat")
call.stream_item.connect(func(msg): print(msg.get_field("text")))
call.completed.connect(func(_end): print("closed"))
call.send_dict({ "text": "hello" })
# ... later ...
call.close_send()

Concurrency

A single GrpcChannel multiplexes concurrent RPCs — you can have many calls in flight on one channel. Each call is independent and delivers to its own signals.

Type mapping

In tier 2 (and generated code), protobuf types convert to and from Godot Variant types as follows.

Proto typeGodot type
double, floatfloat
int32, sint32, sfixed32int
int64, sint64, sfixed64int
uint32, fixed32int
uint64, fixed64int
boolbool
stringString
bytesPackedByteArray
enumint (the enum value)
messageGrpcMessage
repeated TArray
map<K, V>Dictionary

Notes

  • 64-bit unsigned integers: values above 2^63 - 1 do not fit in Godot’s signed 64-bit int and will wrap. Use string fields for very large numbers if exact representation matters.
  • Nested messages are GrpcMessage instances. When setting a message field you may pass either a GrpcMessage or a plain Dictionary.
  • GrpcMessage.to_dict() converts a whole message (recursively) to a Dictionary; get_field / set_field work field-by-field.
  • Unset fields read back as their protobuf defaults (0, "", empty array, etc.).

Threading model

This is the most important thing to understand about how godot-grpc works internally — and why it’s safe.

The rule

Godot APIs are only ever touched on the main thread. tonic needs a full tokio runtime, which runs on its own background threads. godot-grpc keeps these worlds strictly separated:

  • A multi-threaded tokio runtime is owned by the extension and started when Godot finishes initializing, shut down on exit.
  • RPCs run as tokio tasks on that runtime. When a task produces a result, it sends plain Rust data (encoded bytes + status) over a lock-free crossbeam-channel — never a Gd<T>.
  • Once per frame, on the main thread, godot-grpc drains that channel and emits the completed / stream_item / failed signals on the right GrpcCall.

Because Gd<T> (Godot object handles) never cross to the tokio thread, the extension needs no experimental-threads feature and contains no unsound cross-thread sharing.

What this means for you

  • Signals fire on the main thread, so your handlers can freely touch nodes, the scene tree, the UI — no marshalling needed on your side.
  • There is up to one frame of latency between a response arriving on the network and the signal firing (it’s delivered on the next frame’s drain). For the vast majority of game/tool/kiosk use this is irrelevant.
  • The runtime is created/destroyed with the extension lifecycle, including surviving editor hot-reload.

Why not godot::task / coroutines?

gdext’s async integration is great for engine-driven async, but it is not a tokio runtime and cannot drive tonic’s tower-based stack. The two coexist deliberately: tokio does the networking; the per-frame drain bridges results back into Godot’s signal/await world (which is what makes await call.completed work from GDScript).