A 3D game engine for React and React Native on one Rust core.
@nayan-ui/engine is one TypeScript API for games on iOS, Android and the web. Rendering (wgpu), physics (Rapier), animation, particles, audio and drawing run in Rust: natively on phones, as WebAssembly in the browser.
useWorld creates a world and GameView draws and steps it, so this is a complete scene on every platform.
BouncingBall.tsx
React and React Native · shared engine API
import { GameView, useWorld } from "@nayan-ui/engine";export function BouncingBall() {const world = useWorld(100, (w) => {w.spawn({ mesh: "plane", scale: [20, 1, 20], color: "#4C9A4C", physics: "fixed" });w.spawn({ mesh: "sphere", position: [0, 5, 0], color: "#3399FF", physics: { type: "dynamic", bounce: 0.6 } });});return world && <GameView source={world} camera={{ eye: [0, 4, 8], target: [0, 0, 0], fov: 1 }} style={{ flex: 1 }} />;}
Everything is imported from @nayan-ui/engine, and each piece has its own guide.
| Piece | What it does | Guide |
|---|---|---|
| World / useWorld | Entities, physics, animation, timers and scoring | /game-engine/world-entities |
| GameView | Draws a world, steps it each frame, forwards touch input | /game-engine/game-view |
| loadModel / loadTexture / loadFont | glTF models, images and fonts, decoded off the JS thread | /game-engine/models-textures |
| createShape, lathe, tube, loft… | Meshes built in code, baked once and reused | /game-engine/procedural-geometry |
| effects | Confetti, sparkle, shatter, splash, sparks, explosion, dust, flash, pop | /game-engine/particles |
| audio / haptics | Sounds mixed in Rust; vibration feedback | /game-engine/audio-haptics |
| DrawingDocument / DrawingCanvas | 2D painting, also usable as a game texture | /game-engine/drawing |
crate.ts
React and React Native · shared engine API
Call from useWorld's setup and pass the returned function to GameView's onTap.
import { audio, effects, haptics, loadTexture, type AssetSource, type World } from "@nayan-ui/engine";export function addCrate(world: World, image: AssetSource, crack: AssetSource) {const sfx = audio.load({ crack });const crate = world.spawn({ mesh: "cube", position: [0, 0.5, 0], color: "#C8763A" });void loadTexture(image).then((texture) => world.set(crate, { texture }));return (x: number, y: number) => {if (world.pick(x, y)?.entity !== crate) return;world.despawn(crate);effects.shatter(world, { position: [0, 0.5, 0], colors: "#C8763A", size: 0.15, speed: 4, count: 24, lifetime: 0.8, gravity: -9 });sfx.play("crack");haptics.impact(0.6);};}
Y is up and ground games move on X/Z; angles are radians and rotations are quaternions from quat.y(angle) and friends. Screen callbacks use points (CSS pixels on the web) from the view's top-left.
Colors are CSS strings ('#33B3FF', 'rgba(0, 0, 0, 0.5)', or black, white, gray, red, green, blue, yellow, orange, purple, transparent) or 0..1 tuples, where channels above 1 glow. spawn returns an Entity handle: change it with set, remove it with despawn.
marker.ts
React and React Native · shared engine API
import { quat, type World } from "@nayan-ui/engine";export function placeMarker(world: World) {const marker = world.spawn({ mesh: "cone", position: [2, 0.5, -3], color: "#33B3FF" });world.set(marker, { rotation: quat.y(Math.PI / 2), color: [1.6, 0.4, 0.2] }); // a quarter turn, glowing orangereturn () => world.despawn(marker);}
On iOS and Android the display's clock asks for each frame: onUpdate(dt) runs your rules, then a native render thread culls and draws, so JavaScript never waits on the GPU. On the web, requestAnimationFrame drives the same loop.
GameView steps the world right after onUpdate, so don't run your own loop. Drawing stops in the background and in hidden tabs; spread long setup work over frames with afterNextFrame.
Orbit.tsx
React and React Native · shared engine API
import { useRef } from "react";import { GameView, useWorld, type Camera } from "@nayan-ui/engine";export function Orbit() {const world = useWorld(16, (w) => {w.spawn({ mesh: "torus", color: "#FFBF33" });});const camera = useRef<Camera>({ eye: [0, 3, 8], target: [0, 0, 0], fov: 1 }).current;const time = useRef(0);const onUpdate = (dt: number) => {time.current += dt; // GameView steps the world after this returnscamera.eye[0] = Math.sin(time.current) * 8;camera.eye[2] = Math.cos(time.current) * 8;};return world && <GameView source={world} camera={camera} onUpdate={onUpdate} style={{ flex: 1 }} />;}
Game code is identical on both hosts. Only the container, its style and asset sources change.
| React (web) | React Native | |
|---|---|---|
| Renders with | WebAssembly + WebGPU, WebGL2 fallback | Metal (iOS), Vulkan or GLES (Android) |
| Frame clock | requestAnimationFrame | Display clock + native render thread |
| style | CSS properties; the container needs a height | ViewStyle |
| Assets | URL strings | require(...) or URI strings |
| Audio | Starts after the first user gesture | Ready at once |
| Haptics | Browser vibration where supported | Native intensity and sharpness |
| Runs in | A modern browser over HTTPS or localhost | A development or store build, not Expo Go |
Board.tsx
React · browser
import { GameView, loadTexture, useWorld } from "@nayan-ui/engine";export function Board() {const world = useWorld(16, (w) => {const board = w.spawn({ mesh: "plane", scale: [8, 1, 8] });void loadTexture("/textures/board.png").then((texture) => w.set(board, { texture }));});return <div style={{ height: 480 }}>{world && <GameView source={world} style={{ height: "100%" }} />}</div>;}
Board.tsx
React Native · iOS and Android
import { View } from "react-native";import { GameView, loadTexture, useWorld } from "@nayan-ui/engine";export function Board() {const world = useWorld(16, (w) => {const board = w.spawn({ mesh: "plane", scale: [8, 1, 8] });void loadTexture(require("./board.png")).then((texture) => w.set(board, { texture }));});return <View style={{ flex: 1 }}>{world && <GameView source={world} style={{ flex: 1 }} />}</View>;}