react-native-nitro-godot
v0.1.10
Published
React Native Nitro Module for Godot Engine integration via libgodot
Maintainers
Readme
👾 react-native-nitro-godot
A high-performance React Native module that embeds the Godot Engine as a native rendering surface. Built on Nitro Modules for zero-overhead JSI bridging — no Swift/Kotlin bridge layer, all communication flows directly through pure C++ and JSI.
✨ Features & Philosophy
- 🔓 Zero Vendor Lock-in: Compile
libgodotstraight from the official Godot source. You keep 100% control over C++ modules, engine features, and updates. - ⚡️ True Zero-Copy Memory: Pass massive
ArrayBufferpayloads (e.g., Mobile AI tensor data) directly from JS to Godot's C++ rendering device without serialization. - 🛡️ Deterministic UI Performance: Godot runs on a dedicated background
std::thread. Even under heavy 3D GPU load, your React Native UI remains perfectly smooth at 120 FPS. - 🔒 100% Thread Isolation: Bidirectional lock-free SPSC queues ensure the JS thread never calls a single Godot API, and the Godot thread never blocks JS. Zero mutexes, zero frame stutters.
- 🔄 Event-Driven Communication: An intelligent drain loop replaces blind polling — JS only processes messages when Godot enqueues them.
- 📱 OS Lifecycle Safety: Automatic suspend/resume on app background/foreground with ghost touch mitigation to prevent stuck inputs.
- 🎯 3D→2D Projection: Synchronous camera matrix math callable from any thread (including Reanimated worklets) for zero-latency UI tracking of 3D targets.
- 📊 CQRS State Sync: Legend-State v3 observables drive zero-render HUD updates at 60Hz+ without React re-renders.
- 👆 Native Touch Forwarding: React Native touch events are enqueued lock-free and dispatched on the Godot thread via
Input.parse_input_event(), allowing built-in Godot UI and physics interactions to work seamlessly.
🏗 Architecture
┌──────────────────────────────────────────────────────────┐
│ React Native (JavaScript / TypeScript) │
│ │
│ createGodotEngine(pckPath) → GodotEngineWrapper │
│ ├── onMessage(handler) ← event-driven callbacks │
│ ├── sendMessage(str) → enqueue (lock-free) │
│ ├── sendTouchEvent() → enqueue (lock-free) │
│ ├── startPolling() ← rAF drain loop │
│ ├── loadSceneAsync() ← async scene loading │
│ ├── unprojectPosition() ← pure math, atomic read │
│ ├── suspendOS() ← app background handler │
│ └── resumeOS(ptr) ← app foreground handler │
│ │
│ useGodotEngine(pckPath) ← React hook (recommended) │
│ ├── auto start/poll/destroy lifecycle │
│ ├── STATE_SYNC → Legend-State ingestion │
│ └── LOAD_PROGRESS → loading state updates │
│ │
│ <GodotView /> ← native surface component │
│ ├── onSurfaceCreated ← emits pointer (bigint) │
│ ├── onSurfaceChanged ← viewport resize │
│ ├── onSurfaceDestroyed ← cleanup callback │
│ └── onTouchEvent ← forwards touch streams │
├──────────────────────────────────────────────────────────┤
│ Nitro Modules (JSI / C++) 100% THREAD ISOLATION │
│ │
│ HybridGodotEngine.cpp │
│ ├── Outbound SPSC ← Godot→JS messages │
│ ├── Inbound SPSC ← JS→Godot messages │
│ ├── Inbound Touch SPSC ← JS→Godot touch/drag │
│ ├── Camera double-buf ← atomic read/write │
│ ├── Frame callback ← register_main_loop_cb │
│ │ ├── _processInboundMessages (JS→GDScript) │
│ │ ├── _processInboundTouches (JS→Input) │
│ │ ├── _relayGodotMessages (GDScript→JS) │
│ │ └── _updateCameraCache (Camera→JS) │
│ ├── libgodot C-API ← create/destroy instance │
│ └── GDExtension entry ← SERVERS + SCENE init │
├──────────────────────────────────────────────────────────┤
│ Platform Native │
│ │
│ Android: SurfaceView → ANativeWindow* → libgodot │
│ iOS: UIView + CAMetalLayer* → libgodot │
└──────────────────────────────────────────────────────────┘Thread Safety Model
| Thread | Allowed Operations | Never Touches | | ----------------- | ------------------------------------------------------- | -------------------------------- | | JS (120 Hz) | SPSC enqueue, SPSC dequeue, atomic reads | Godot API, variant_call, classdb | | Godot (60 Hz) | SPSC enqueue, SPSC dequeue, variant_call, atomic writes | JS runtime, JSI |
All cross-thread data flows through lock-free SPSC queues or atomic double-buffers. The Godot frame callback (register_main_loop_callbacks) processes all queues each frame after _process().
📦 Prerequisites
| Dependency | Version | | ------------- | ----------------- | | React Native | >= 0.73 | | Expo | >= 55 (optional) | | Nitro Modules | >= 0.35.0 | | Godot Engine | 4.7.2-stable (pinned) | | Android NDK | >= 27 |
Optional Peer Dependencies
| Package | Version | For |
| ------------------ | ------- | ------------------------------------- |
| @legendapp/state | >= 3.0 | CQRS state sync (Epic 5: zero-render) |
🚀 Installation
npm install react-native-nitro-godot react-native-nitro-modules
# Optional: Legend-State for CQRS zero-render state sync
npm install @legendapp/state@betaiOS
The podspec links the compiled libgodot.xcframework static library from your local engine_build/output/ios/ directory.
cd ios && pod installAndroid
The Gradle build links your locally compiled libgodot.so via CMake. Ensure the NDK and CMake versions match your React Native setup. (Note: Requires AGP 8.9.1+ and buildFeatures.prefab = true).
📖 API Reference
The useGodotEngine Hook (Recommended)
Convenience React hook that manages the full lifecycle with event-driven messaging, touch forwarding, and automatic Legend-State ingestion.
import { StyleSheet, View, Text } from "react-native";
import { useGodotEngine, GodotView } from "react-native-nitro-godot";
function GameScreen() {
const {
engine,
engineState,
lastError,
surfaceCallbacks,
handleTouchEvent,
sendMessage,
pause,
} = useGodotEngine(`${FileSystem.documentDirectory}game.pck`, (msg) =>
console.log("Godot says:", msg),
);
return (
<View style={StyleSheet.absoluteFill}>
<GodotView
style={StyleSheet.absoluteFill}
{...surfaceCallbacks}
onTouchEvent={handleTouchEvent}
/>
{engineState === "error" && (
<Text style={{ color: "red" }}>{lastError}</Text>
)}
</View>
);
}createGodotEngine(pckPath) — Lower-Level Wrapper
For non-React contexts or manual control. Returns a GodotEngineWrapper with event-driven messaging:
import { createGodotEngine } from "react-native-nitro-godot";
const engine = createGodotEngine("/path/to/game.pck");
// Subscribe to messages from Godot (returns an unsubscribe function)
const unsubscribe = engine.onMessage((msg) => console.log("Godot→JS:", msg));
engine.startPolling(); // Start the rAF drain loop
engine.sendMessage(JSON.stringify({ action: "START_GAME" }));
// Access the raw HybridObject for direct JSI calls
engine.raw.attachSurface(surfacePointer);
engine.raw.start();
// Clean up
engine.stopPolling();
engine.destroy();Core HybridObject Methods (GodotEngine)
For advanced manual lifecycle control:
| Method | Description |
| --------------------------------- | ------------------------------------------------------------------------------------------ |
| initialize(pckPath: string) | Load a .pck file (Must be an extracted, absolute file system path). |
| start() | Spawn the render thread; begins the Godot main loop at ~60 Hz. |
| pause() | Pauses the Godot main loop without destroying the engine state. |
| attachSurface(ptr: bigint) | Bind a native OS surface pointer (ANativeWindow* / CAMetalLayer*). |
| updateSharedBuffer(buf) | Push a native ArrayBuffer for true zero-copy memory sharing. |
| sendMessage(msg: string) | Enqueues a string into the inbound SPSC queue. Dispatched to GDScript on the Godot thread. |
| pollMessage(): string | Pop from the outbound SPSC lock-free queue. Returns "" if empty. Safe at 120Hz. |
| notifyPollingStopped() | Resets the wake-up flag after draining. Called automatically by the wrapper. |
| setOnWakeUp(callback) | Registers a JS callback invoked when Godot enqueues a message and JS is not polling. |
| suspendOS() | Pause engine on app background. Prevents GPU timeout crashes. |
| resumeOS(ptr: bigint) | Resume engine on foreground. Reattaches the native surface. |
| loadSceneAsync(pckPath: string) | Kick off async scene loading with progress events via SPSC queue. |
| unprojectPosition(x, y, z) | Pure-math 3D→2D projection using cached camera matrices. Thread-safe. |
| sendTouchEvent(x, y, pressed, index) | Forward a touch press/release to Godot's InputEventScreenTouch. |
| sendDragEvent(x, y, relX, relY, velX, velY, index) | Forward a touch drag to Godot's InputEventScreenDrag. |
| resizeSurface(w, h) | Updates Godot viewport + swapchain to match native surface dimensions. |
| getLastError(): string | Returns last critical engine error, or "". Check when Godot view is blank. |
| destroy() | Hard teardown: stop and join the render thread and forget the shared engine. Godot cannot be started again in this process afterwards — only for shutting the game down for good. |
Remounts, Fast Refresh and the shared engine
Godot is a process-wide singleton and cannot be re-created after it has started, so the wrapper
keeps one shared engine per process: every createGodotEngine() call after the first returns
the same wrapper. useGodotEngine releases the engine on unmount (release() → suspendOS(),
native instance kept) and a later mount adopts it and resumes on the new surface via resumeOS().
That makes React remounts of <GodotView> and Fast Refresh of the components around it work
without a black view. Two limits remain: a full JS reload (⌘R) creates a new JS runtime but the
native engine is still running — reload the app to restart Godot — and the engine cannot switch
to a different .pck in the same process (a warning is logged and the running engine is reused).
💬 Two-Way Messaging & GDScript Contract
To enable communication between JS and Godot, you must create an AutoLoad singleton named RNBridge in your Godot project.
1. Godot Setup (RNBridge.gd)
Create this script and add it to Project → Project Settings → AutoLoad.
extends Node
var _outgoing_queue: Array[String] = []
signal message_received(data: String)
# ─── INCOMING (JS → Godot) ───────────────────────────────
# Called by C++ _processInboundMessages() on the Godot thread.
func on_react_native_message(payload: String) -> void:
message_received.emit(payload)
var parsed = JSON.parse_string(payload)
if parsed is Dictionary:
var action = parsed.get("action", "")
if action == "LOAD_SCENE_ASYNC":
_start_async_load(parsed.get("path", ""))
# ─── OUTGOING (Godot → JS) ───────────────────────────────
# Call this from your game logic to send data to React Native.
# C++ _relayGodotMessages() drains this queue into the outbound SPSC.
func send_to_react_native(msg: String) -> void:
_outgoing_queue.append(msg)
func poll_message() -> String:
if _outgoing_queue.is_empty():
return ""
return _outgoing_queue.pop_front()
# ─── State Sync ──────────────────────────────────────────
func sync_state_to_rn(data: Dictionary) -> void:
send_to_react_native(JSON.stringify({
"type": "STATE_SYNC",
"data": data
}))
# ─── Camera Data (3D→2D Projection) ─────────────────────
# Called by C++ _updateCameraCache() on the Godot thread.
# Returns [view_matrix(16), proj_matrix(16), viewport_w, viewport_h]
func get_camera_data() -> PackedFloat32Array:
var vp := get_viewport()
if not vp: return PackedFloat32Array()
var cam := vp.get_camera_3d()
if not cam or not cam.current: return PackedFloat32Array()
var result := PackedFloat32Array()
result.resize(34)
# ... (see lab/RNBridge.gd for full implementation)
return result2. React Native Setup — Event-Driven (New)
The useGodotEngine hook automatically handles message draining. No manual polling needed:
const { engine } = useGodotEngine(pckPath, (msg) => {
// This fires for every message from Godot
console.log("Godot says:", msg);
});
// Sending to Godot
const spawnEnemy = () =>
engine.sendMessage(JSON.stringify({ action: "SPAWN" }));Under the hood, an intelligent requestAnimationFrame drain loop pulls messages from the lock-free SPSC queue only when data is available.
🎮 CQRS State Sync (Legend-State v3)
For high-frequency game state updates without React re-renders:
import {
state$,
dispatchGameIntent,
HealthBar,
} from "react-native-nitro-godot";
// 1. Dispatch commands (JS → Godot, one-way)
dispatchGameIntent(engine, { action: "EQUIP_SWORD" });
// 2. Read state reactively (zero React re-renders)
// In GDScript: RNBridge.sync_state_to_rn({"player": {"health": 80}})
// → Flows through SPSC queue → Legend-State observable → HealthBar updates
// 3. Use the provided zero-render component
<HealthBar />; // Receives 60Hz updates, React Profiler shows 0 re-rendersRule: Godot is the authoritative server; React Native is the reactive client. Never mutate state$ directly — send intents via dispatchGameIntent() and let Godot respond with STATE_SYNC events.
📐 3D→2D Projection
Project 3D world positions to screen coordinates at 120Hz — safe for Reanimated worklets:
const [sx, sy] = engine.unprojectPosition(worldX, worldY, worldZ) ?? [0, 0];
// Position a React Native view exactly over a 3D objectUses cached camera matrices (double-buffered, lock-free) — zero Godot API calls, pure C++ math.
🧠 Nitro Memory Tracking & Zero-Copy
react-native-nitro-godot leverages Nitro's native memory management for AI/ML workloads. Buffer data is written once from JS and read directly from the render thread with mutex protection.
import { NitroModules } from "react-native-nitro-modules";
// 1. Allocate a 10MB native-owned ArrayBuffer (No JS GC overhead)
const buf = NitroModules.createNativeArrayBuffer(10 * 1024 * 1024);
const view = new Float32Array(buf);
view[0] = 0.5; // E.g., Tensor output from On-Device AI
// 2. Push to engine (Zero-copy, pointer handoff only)
engine.updateSharedBuffer(buf);
// 3. Eagerly release memory footprint
NitroModules.updateMemorySize(engine);
engine.dispose();🛠 Building Godot from Source
Unlike other solutions, react-native-nitro-godot does not restrict you to pre-built binaries. You must compile Godot as a library directly from the pinned version (4.7.2-stable). The provided build_godot.sh handles downloading, patching, compiling, and verification.
See engine_build/README.md and engine_build/patches/README.md for details.
# Build both Android and iOS (from the repo root)
cd engine_build && ./build_godot.shImportant: The build script automatically applies version-pinned
.patchfiles. If you upgrade the Godot version, regenerate patches — seeengine_build/patches/README.md.
🧪 Testing
Run the full test suite:
npm testTypeScript Tests (Jest)
| Test File | Coverage |
| -------------------------------- | ----------------------------------------------------------------------------------------------------- |
| godotState.test.ts | ingestStateSync merging, damage sequences, enemy map, edge cases |
| GodotEngine.test.ts | Message handler subscribe/unsubscribe, multi-handler dispatch, error resilience, drain loop lifecycle |
| dispatchGameIntent.test.ts | Intent serialization, payload preservation |
| useGodotEngine.test.ts | Hook lifecycle, surface callbacks, AppState suspend/resume, touch forwarding |
| useGodotEngine.hook.test.tsx | React integration tests with renderHook, state transitions |
| GodotView.test.tsx | Native view component rendering and prop forwarding |
| unproject3DToScreen.test.ts | 3D→2D projection math, camera matrix edge cases |
C++ Compile-Time Test
cpp/tests/test_proc_resolution.h validates that GDExtensionProcs::resolve() correctly handles renamed/removed GDExtension API names across Godot versions. This guards against the class of bugs where a Godot API rename silently nulls out critical function pointers, breaking the Godot→JS message pipeline with no error.
What it tests:
- Simulates a "new Godot" environment where old API names (e.g.
get_type_from_variant_constructor) returnnullptr - Verifies
resolve()falls back to the correct new names (e.g.get_variant_to_type_constructor) - Verifies removed destructors (
string_name_destroy,string_destroy) resolve viavariant_get_ptr_destructorfallback - Runs automatically at library load time and logs results to logcat / Xcode console
How to enable:
Add the -DTEST_PROC_RESOLUTION preprocessor flag to your native build:
Android — add to android/CMakeLists.txt:
# After the add_library(NitroGodot ...) block:
target_compile_definitions(NitroGodot PRIVATE TEST_PROC_RESOLUTION)iOS — add to react-native-nitro-godot.podspec inside the pod_target_xcconfig:
s.pod_target_xcconfig = {
# ... existing config ...
"GCC_PREPROCESSOR_DEFINITIONS" => "$(inherited) TEST_PROC_RESOLUTION=1",
}Then rebuild (npm run android:device or npm run ios:device). Check native logs for:
PROC TEST: All 16 checks passed ✓Note: Remove the flag for production builds — the test adds a static constructor that runs at startup.
🔧 Troubleshooting
Godot View Not Rendering (Blank Screen)
Check
lastErrorfrom the hook:const { lastError } = useGodotEngine(pckPath); // If non-null, a critical engine error occurred console.log("lastError:", lastError);Check native logs for
[NitroGodot]prefixed messages:# Android adb logcat -s NitroGodot:* '*:S' # iOS (Xcode Console) # Filter by: [NitroGodot]Common causes: | Symptom | Likely Cause | Fix | |---|---|---| |
libgodot_create_godot_instance returned null| Missing/wrong PCK file | Verify pckPath, ensure PCK exported from matching Godot version | |Missing GDExtension procs| stale libgodot binary | Rebuild engine:cd engine_build && ./build_godot.sh| |Failed to set up Android JNI context| JNI environment error | Ensure React Native activity is running | | Crash on startup +RendererCompositor singleton| Double init (iOS) | Check for multipleGodotViewmounts | |Could not find base class "…"for a class the editor has | The shipped libgodot is built with some SCons features disabled | Since 0.1.10 the advanced GUI nodes (SubViewportContainer,RichTextLabel,PopupMenu,Tree, …) are included;disable_3dstays off.disable_2dwas never a real Godot option. If you build your own engine, checkengine_build/build_godot.sh|Read Godot's own errors. Script parse errors,
SCRIPT ERRORlines andpush_error()output from the embedded engine are written to the app's stderr, so they show up in theexpo run:ios/ Metro log (grep -E "SCRIPT ERROR|Parse Error"). A pack that runs fine under the desktop editor binary with--main-packbut fails on device usually fails to parse a script there — check that log first.
Build Errors
- Patch failed to apply: Godot source doesn't match the pinned version. Re-download the correct tarball or regenerate patches.
_libgodot_create_godot_instanceundefined: The entry point file wasn't included in the build. Check that patches 0001/0002 applied correctly.- iOS
nmcheck fails: Rebuildlibgodot.xcframework— the static library must contain the C-API symbols.
Engine Error Pipeline
Critical errors flow through a structured pipeline:
C++ LOGE() → _setLastError(layer, msg)
├── Stored in last_error_ (readable via getLastError())
└── Pushed to SPSC queue as ENGINE_ERROR JSON
└── useGodotEngine → setLastError(msg), setEngineState('error')
└── console.error(msg)📊 Measuring Real (Presented) FPS
Do not trust an in-engine fps counter to tell you what the user sees. Godot's
Engine.get_frames_per_second() (and any JS/RN fps readout) measures the
render / iteration rate — how often the Godot main loop runs. That is not the
present rate — how many frames CoreAnimation actually commits to the display.
They can diverge sharply. The render loop drives iteration() from a background
thread that dispatch_syncs onto the main thread. If a frame takes at least a
full frame budget to render+present, the loop stops yielding, the main run loop
can't cycle, and CADisplayLink can't commit the rendered frame — so most rendered
frames never reach the screen. This is most visible on the iOS Simulator,
whose software-emulated GL present is slow: we have observed the counter reading
~40fps while only ~9fps was actually presented. (The render loop guarantees a
minimum per-frame main-thread yield to keep CoreAnimation fed — see the frame
pacing in cpp/HybridGodotEngine.cpp — but slow environments can still present
below the iteration rate, so always measure the real thing.)
At runtime: getFrameStats()
For a live, in-app readout (dev HUD or production telemetry), the engine exposes
getFrameStats() — returning both rates so the gap is visible:
// Poll on a fixed cadence (e.g. every 500ms); rates are measured over the interval.
const { producedFps, presentedFps, worstFrameMs } = engine.getFrameStats();
// producedFps — engine iteration rate (the number a naive counter would show)
// presentedFps — frames actually reaching the display (iOS: CADisplayLink-based;
// collapses below producedFps when the main thread is starved)
// worstFrameMs — longest gap between presented frames since the last call (jank)A healthy pipeline reads presentedFps ≈ display rate with producedFps close
behind; presentedFps collapsing far below producedFps is the starvation
signal (e.g. "presented 9 / produced 40"). Validated against the recording
method below: on the iOS simulator getFrameStats() reported ~35 presentedFps
where a screen recording independently measured ~40 — i.e. it tracks reality, not
the inflated iteration count. (iOS today; Android Choreographer support is a
follow-up — presentedFps is 0 there for now.)
Measure it (recording — ground truth / CI)
scripts/measure-present-fps.sh reports the
true on-screen present rate by recording the composited screen and analyzing the
frame timestamps (simctl recordVideo is variable-frame-rate — it encodes a
frame only when the screen changes, so the gaps between frame PTS are the real
present intervals):
# Record the simulator for 12s while you interact with the app, then print the
# real presented-frame rate distribution (median/p90 interval + implied fps):
scripts/measure-present-fps.sh <simulator-udid> 12
# Or analyze a recording you already have, optionally within a time window:
scripts/measure-present-fps.sh --analyze gameplay.mp4 10.5 15For a reading that's independent of whether your scene is animating, add a node
that changes every frame (rotate/translate a sprite in _process) so every
presented frame differs — then the script measures the pure present rate.
On a real device, use Xcode Instruments → Core Animation FPS or Metal System Trace for hardware-accurate presented-frame timing.
📚 Additional Documentation
- Architecture Deep-Dive — detailed design document covering threading model, SPSC queue internals, and GDExtension integration
- RNBridge Setup Guide — step-by-step instructions for setting up the GDScript
RNBridgeautoload - Engine Build Guide — compiling Godot as a library from source
- Patch Management — managing version-pinned patches for custom engine builds
- Third-Party Licenses — license attributions for vendored dependencies
License
MIT
