JS THREAD · 120 Hz GODOT THREAD · 60 Hz

The Godot Engine,
inside React Native.

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.

0mutexes on the hot path
0copies for ArrayBuffers
0Hz UI under 3D load
0% thread isolation
SCROLL

THREAD SAFETY MODEL

Two clocks. Zero contention.

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

JS THREAD 120 Hz
  • SPSC enqueue / dequeue
  • atomic camera reads
  • unprojectPosition() — pure math
  • Godot API · variant_call · classdb
GODOT THREAD 60 Hz
  • SPSC enqueue / dequeue
  • variant_call · Input.parse_input_event()
  • atomic camera writes
  • JS runtime · JSI

WHY THIS EXISTS

Built like an engine,
shipped like a module.

Zero vendor lock-in

Compile libgodot straight from official Godot source (pinned 4.7-stable). Your C++ modules, your engine flags, your upgrade schedule.

True zero-copy memory

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.

Deterministic UI performance

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.

Event-driven messaging

No blind polling. A rAF drain loop wakes only when Godot enqueues — setOnWakeUp() replaces the busy-wait tax entirely.

3D→2D projection at 120 Hz

Pin a React Native view over a 3D target with unprojectPosition() — cached camera matrices, pure math, safe inside Reanimated worklets.

CQRS state sync

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.

Native touch forwarding

Touches enqueue lock-free and dispatch on the Godot thread through Input.parse_input_event() — Godot UI, physics, and drag all just work.

OS lifecycle safety

Automatic suspend/resume on background/foreground with ghost-touch mitigation. No GPU-timeout crashes when the app leaves the screen.

MEASURE WHAT SHIPS

Your FPS counter is lying to you.

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 →
producedFps40.1// what a naive counter shows
presentedFps9.3// what the user actually sees
worstFrameMs216// the jank you'd have missed
engine.getFrameStats()

presentedFps is CADisplayLink-based and iOS-only today; it reads 0 on Android.

QUICKSTART

One hook. Full lifecycle.

useGodotEngine manages start, polling, touch forwarding, suspend/resume, and teardown. Drop a <GodotView /> in and you're rendering.

install
# 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
GameScreen.tsx
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

The HybridObject surface.

Every method below is a direct JSI call into C++ — no bridge serialization, no async tax unless the operation is genuinely async.

LIFECYCLE

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.

SURFACE & RENDERING

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.

MESSAGING

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.

INPUT & PROJECTION

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

One AutoLoad. Two-way traffic.

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.

RNBridge.gd — AutoLoad singleton
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

Compile Godot from source.

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)

Ship the whole engine.