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 Sockets —
http://host:portorunix:///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-grpcas 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
- Prefer typed
req.name = "world"over dicts? See Typed codegen. - Streaming RPCs: see Streaming.
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 1 | Tier 2 | |
|---|---|---|
| Request/response | PackedByteArray (encoded) | Dictionary / GrpcMessage |
| Schema needed at runtime | no | yes (a FileDescriptorSet) |
| Build feature | always | --features tier2 |
| Best for | Rust authors with their own generated clients; embedded use | GDScript 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.gd—class_name GreeterMessageswith one typed inner class per message.greeter_greeter.gd—class_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 eachstream_itemwith the generatedReply.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 type | Godot type |
|---|---|
double, float | float |
int32, sint32, sfixed32 | int |
int64, sint64, sfixed64 | int |
uint32, fixed32 | int |
uint64, fixed64 | int |
bool | bool |
string | String |
bytes | PackedByteArray |
enum | int (the enum value) |
| message | GrpcMessage |
repeated T | Array |
map<K, V> | Dictionary |
Notes
- 64-bit unsigned integers: values above
2^63 - 1do not fit in Godot’s signed 64-bitintand will wrap. Usestringfields for very large numbers if exact representation matters. - Nested messages are
GrpcMessageinstances. When setting a message field you may pass either aGrpcMessageor a plainDictionary. GrpcMessage.to_dict()converts a whole message (recursively) to aDictionary;get_field/set_fieldwork 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 aGd<T>. - Once per frame, on the main thread,
godot-grpcdrains that channel and emits thecompleted/stream_item/failedsignals on the rightGrpcCall.
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).