Zero vendor lock-in
Compile libgodot straight from official Godot source (pinned 4.7-stable). Your C++ modules, your engine flags, your upgrade schedule.
JS THREAD · 120 Hz GODOT THREAD · 60 Hz
A Nitro Module that embeds libgodot as a native rendering surface. Pure C++ over JSI — no Swift, no Kotlin, no bridge. Two threads, two clocks, and not a single lock between them.
THREAD SAFETY MODEL
The JS thread never calls a Godot API. The Godot thread never touches the JS runtime. Every byte that crosses over flows through lock-free SPSC queues or atomic double-buffers — drained once per Godot frame, after _process().
WHY THIS EXISTS
Compile libgodot straight from official Godot source (pinned 4.7-stable). Your C++ modules, your engine flags, your upgrade schedule.
Hand a 10 MB ArrayBuffer — AI tensor output, mesh data — from JS to Godot's rendering device as a pointer. No serialization, no GC churn.
Godot runs on its own std::thread. Under heavy 3D GPU load your React Native UI stays smooth at 120 FPS — the threads can't stall each other.
No blind polling. A rAF drain loop wakes only when Godot enqueues — setOnWakeUp() replaces the busy-wait tax entirely.
Pin a React Native view over a 3D target with unprojectPosition() — cached camera matrices, pure math, safe inside Reanimated worklets.
Godot is the authoritative server; Legend-State observables drive HUD updates at 60 Hz+ with zero React re-renders. Send intents down, get STATE_SYNC back.
Touches enqueue lock-free and dispatch on the Godot thread through Input.parse_input_event() — Godot UI, physics, and drag all just work.
Automatic suspend/resume on background/foreground with ghost-touch mitigation. No GPU-timeout crashes when the app leaves the screen.
MEASURE WHAT SHIPS
In-engine counters measure how often the loop runs — not how many frames CoreAnimation actually presents. We've watched a counter read ~40 fps while only ~9 reached the screen. So the engine reports both:
Read the deep-dive →presentedFps is CADisplayLink-based and iOS-only today; it reads 0 on Android.
QUICKSTART
useGodotEngine manages start, polling, touch forwarding, suspend/resume, and teardown. Drop a <GodotView /> in and you're rendering.
# core
npm install react-native-nitro-godot react-native-nitro-modules
# optional: zero-render CQRS state sync
npm install @legendapp/state@beta
# iOS
cd ios && pod install
import { StyleSheet, View } from "react-native";
import { useGodotEngine, GodotView } from "react-native-nitro-godot";
function GameScreen() {
const { engineState, lastError, surfaceCallbacks,
handleTouchEvent, sendMessage } =
useGodotEngine(pckPath, (msg) => console.log("Godot:", msg));
return (
<View style={StyleSheet.absoluteFill}>
<GodotView
style={StyleSheet.absoluteFill}
{...surfaceCallbacks}
onTouchEvent={handleTouchEvent}
/>
</View>
);
}
API REFERENCE
Every method below is a direct JSI call into C++ — no bridge serialization, no async tax unless the operation is genuinely async.
initialize(pckPath)Load a .pck from an absolute file-system path.
start()Spawn the render thread; the Godot main loop begins at ~60 Hz.
pause()Pause the main loop without destroying engine state.
suspendOS() / resumeOS(ptr)Background/foreground handling — prevents GPU-timeout crashes.
destroy()Stop and join the render thread and release the engine reference. The Godot instance is intentionally kept alive — it is a process-wide singleton that cannot be cleanly restarted.
attachSurface(ptr: bigint)Bind ANativeWindow* (Android) or CAMetalLayer* (iOS).
resizeSurface(w, h)Sync Godot viewport + swapchain to the native surface.
updateSharedBuffer(buf)Zero-copy ArrayBuffer handoff — pointer only.
getFrameStats()Produced vs presented FPS + worst frame time.
sendMessage(msg)Enqueue into the inbound SPSC; dispatched to GDScript on the Godot thread.
pollMessage()Pop the outbound SPSC. Returns "" when empty — safe at 120 Hz.
setOnWakeUp(cb)Fires when Godot enqueues while JS isn't polling — the event-driven core.
loadSceneAsync(pckPath)Async scene loading with progress events over the queue.
sendTouchEvent(x, y, pressed, i)Forward press/release as InputEventScreenTouch.
sendDragEvent(x, y, …)Forward drags as InputEventScreenDrag with velocity.
unprojectPosition(x, y, z)3D world → 2D screen. Pure math, atomic reads, worklet-safe.
getLastError()Last critical engine error — first stop when the view is blank.
THE GDSCRIPT CONTRACT
Add an AutoLoad singleton named RNBridge to your Godot project. The C++ frame callback calls into it each frame — inbound messages in, outgoing queue drained out.
extends Node
var _outgoing_queue: Array[String] = []
signal message_received(data: String)
# ─── INCOMING (JS → Godot) — called by C++ each frame ───
func on_react_native_message(payload: String) -> void:
message_received.emit(payload)
# ─── OUTGOING (Godot → JS) — drained into the SPSC ──────
func send_to_react_native(msg: String) -> void:
_outgoing_queue.append(msg)
# Required: C++ calls this each frame to drain the queue.
# Without it, Godot → JS messages never arrive.
func poll_message() -> String:
if _outgoing_queue.is_empty():
return ""
return _outgoing_queue.pop_front()
# ─── State sync (CQRS) ──────────────────────────────────
func sync_state_to_rn(data: Dictionary) -> void:
send_to_react_native(JSON.stringify({ "type": "STATE_SYNC", "data": data }))
OWN YOUR ENGINE
No pre-built binary blobs. build_godot.sh downloads the pinned 4.7-stable source, applies version-pinned patches, compiles libgodot for Android + iOS, and verifies the C-API symbols.
cd engine_build && ./build_godot.sh
# → libgodot.xcframework (iOS) + libgodot.so (Android)