npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

react-native-nitro-godot

v0.1.10

Published

React Native Nitro Module for Godot Engine integration via libgodot

Readme

👾 react-native-nitro-godot

npm version License: MIT React Native: Fabric

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 libgodot straight from the official Godot source. You keep 100% control over C++ modules, engine features, and updates.
  • ⚡️ True Zero-Copy Memory: Pass massive ArrayBuffer payloads (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@beta

iOS

The podspec links the compiled libgodot.xcframework static library from your local engine_build/output/ios/ directory.

cd ios && pod install

Android

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 result

2. 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-renders

Rule: 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 object

Uses 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.sh

Important: The build script automatically applies version-pinned .patch files. If you upgrade the Godot version, regenerate patches — see engine_build/patches/README.md.


🧪 Testing

Run the full test suite:

npm test

TypeScript 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) return nullptr
  • 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 via variant_get_ptr_destructor fallback
  • 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)

  1. Check lastError from the hook:

    const { lastError } = useGodotEngine(pckPath);
    // If non-null, a critical engine error occurred
    console.log("lastError:", lastError);
  2. Check native logs for [NitroGodot] prefixed messages:

    # Android
    adb logcat -s NitroGodot:* '*:S'
    
    # iOS (Xcode Console)
    # Filter by: [NitroGodot]
  3. 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 multiple GodotView mounts | | 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_3d stays off. disable_2d was never a real Godot option. If you build your own engine, check engine_build/build_godot.sh |

  4. Read Godot's own errors. Script parse errors, SCRIPT ERROR lines and push_error() output from the embedded engine are written to the app's stderr, so they show up in the expo run:ios / Metro log (grep -E "SCRIPT ERROR|Parse Error"). A pack that runs fine under the desktop editor binary with --main-pack but 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_instance undefined: The entry point file wasn't included in the build. Check that patches 0001/0002 applied correctly.
  • iOS nm check fails: Rebuild libgodot.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 15

For 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

License

MIT