Own a world, spawn and change entities, and dispose them safely.
useWorld and GameView make a complete scene; these five rules keep it leak-free and crash-free.
1. Own the world with useWorld(capacity, setup), or create it in an effect after await warmUpEngine(); never during render or at module scope.
2. GameView steps the world right after onUpdate; call world.update(dt) yourself only to step differently, and GameView then skips its step (it watches world.updateCount).
3. Queue per-frame moves on world.transforms in onUpdate; the step applies them, so call flush() only before reading positions back.
4. Dispose the world on unmount (useWorld does) and release your subscriptions with it.
5. Check world.isDisposed instead of keeping a flag: afterwards set, despawn, has, animate, stopAnimation, impulse, clips, timers, queries and forEachCollision are no-ops, while spawn and update throw.
SpinningCube.tsx
React and React Native · shared engine API
A complete scene: useWorld owns the world, onUpdate queues the spin and GameView steps the world after it.
import { useRef } from "react";import { GameView, quat, useWorld, type Entity, type MutableQuat } from "@nayan-ui/engine";const turn: MutableQuat = [0, 0, 0, 1]; // reused every frameexport function SpinningCube() {const cube = useRef<Entity | null>(null);const angle = useRef(0);const world = useWorld(64, (w) => {cube.current = w.spawn({ mesh: "cube", color: "#33B3FF" });});if (!world) return null; // ready after warm-up (one frame)return (<GameView source={world} style={{ flex: 1 }} camera={{ eye: [0, 2, 5], target: [0, 0, 0], fov: 0.9 }}onUpdate={(dt) => {if (!cube.current) return;angle.current += dt;quat.y(angle.current, turn);world.transforms.rotation(cube.current, turn[0], turn[1], turn[2], turn[3]);}} />);}
new World(capacity) reserves slots for entities, text letters and particles; spawn returns an Entity handle and throws when the world is full or an option is invalid.
set(entity, options) changes only what you pass (null removes) and returns false if the entity is gone. despawn removes an entity with its children; has and count report what is alive.
Spawn, change and remove
React and React Native · shared engine API
import { quat, type World } from "@nayan-ui/engine";export function addPlayer(world: World) {const player = world.spawn({ mesh: "capsule", position: [0, 1, 0], color: "#4DB3FF" });world.set(player, { scale: [1, 1.5, 1], rotation: quat.y(Math.PI / 2) });world.set(player, { bob: null }); // null removes an optionconsole.log(world.count, world.has(player));return () => world.despawn(player);}
spawn and set take the same EntityOptions; in set, null removes physics, parent, follow, bob, lifetime and impact.
| Option | Behavior |
|---|---|
| mesh | Built-in Shape or loaded Model; default cube. none draws nothing (groups, pivots). |
| texture / textureRegion | Texture or null; atlas rect [u0, v0, u1, v1]. In set, pass mesh too. |
| text / font | 3D text (1 unit tall at scale 1) instead of a mesh; needs font. |
| align / letterSpacing / tabular | Text alignment (default center), extra em spacing, equal-width digits. |
| position / rotation | Vec3 and quat [x, y, z, w]; relative to the parent. |
| scale | Number or Vec3; visual only once a body exists. |
| color | CSS string or 0..1 tuple; alpha < 1 is see-through, channels > 1 glow. |
| velocity / groundVelocity | Units per second; groundVelocity sets [x, z] and keeps vertical speed. |
| acceleration / spin | Units/s² for non-dynamic entities; radians per second per axis. |
| bob | { amplitude, speed, phase? }: visual sine offset of the drawn position. |
| follow | { target, speed }: chase an entity along the ground. |
| lifetime | Seconds until it shrinks away and despawns. |
| parent | Attach to an entity: moves, turns, scales and despawns with it. |
| physics / impact | BodyType or PhysicsOptions; automatic impact sound and haptic. |
| pickable | Whether pick can hit it; default true. |
Reusable options
React and React Native · shared engine API
import type { EntityOptions, World } from "@nayan-ui/engine";const coin: EntityOptions = {mesh: "cylinder",scale: [0.6, 0.1, 0.6],color: "#FFC83D",spin: [0, 3, 0],bob: { amplitude: [0, 0.2, 0], speed: 4 },pickable: false,};export function dropCoin(world: World, x: number, z: number) {return world.spawn({ ...coin, position: [x, 1, z], lifetime: 10 });}
Rotations are quaternions [x, y, z, w] built with quat, with angles in radians. Every helper takes an optional out array; reuse one in per-frame code so nothing allocates.
| Helper | Result |
|---|---|
| quat.identity | No rotation (frozen). |
| quat.x / y / z(angle, out?) | Pitch, yaw (heading) or roll; z spins 2D games. |
| quat.axisAngle(x, y, z, angle, out?) | Rotation around a unit axis. |
| quat.multiply(a, b, out?) | a after b (b turns first); out may be a or b. |
| quat.fromEuler(x, y, z, out?) | Turns around x, then y, then z. |
| quat.slerp(a, b, t, out?) | Shortest-path blend from a (t = 0) to b (t = 1). |
Turn smoothly toward travel
React and React Native · shared engine API
Call the returned function from onUpdate; it allocates nothing.
import { quat, type Entity, type MutableQuat, type World } from "@nayan-ui/engine";export function makeSteering(world: World, actor: Entity) {const current: MutableQuat = [0, 0, 0, 1];const target: MutableQuat = [0, 0, 0, 1];return (dt: number, x: number, z: number) => {if (x || z) quat.y(Math.atan2(x, z), target); // face +z toward (x, z)quat.slerp(current, target, Math.min(1, dt * 10), current);world.transforms.rotation(actor, current[0], current[1], current[2], current[3]);};}export const tilted = quat.multiply(quat.y(Math.PI / 4), quat.x(-0.3)); // tilt, then turn
world.transforms queues draw-only position, rotation, scale, color and transform updates without allocating; the next world.update applies them in one native call.
Call flush() only before reading positions or animating in the same frame; set(entity, options) takes the object form. Use world.set for text entities.
Move many pieces in one call
React and React Native · shared engine API
Call from onUpdate; GameView's step applies the batch.
import type { Entity, World } from "@nayan-ui/engine";type Piece = { entity: Entity; x: number; z: number };export function placePieces(world: World, pieces: readonly Piece[], time: number) {const batch = world.transforms;for (const p of pieces) {batch.position(p.entity, p.x, Math.sin(time + p.x) * 0.2, p.z);batch.scale(p.entity, 1 + Math.sin(time) * 0.05);}}
Children inherit their parent's position, rotation and scale and despawn with it. Put physics on the parent: attached entities can't have bodies.
Compose a collectible
React and React Native · shared engine API
import type { World } from "@nayan-ui/engine";export function addCollectible(world: World) {const root = world.spawn({ mesh: "none", position: [2, 1, 0], spin: [0, 1, 0] });world.spawn({ mesh: "torus", parent: root, scale: 0.8, color: "orange" });world.spawn({ mesh: "sphere", parent: root, scale: 0.3, color: "#FFF080" });return root; // despawn(root) removes all three}
position (parent-relative), worldPosition (as drawn) and velocity return null once an entity is gone; pass an out tuple per frame. toScreen and screenToWorld use the last drawn camera, so they return null before the first frame.
world.bounds = [minX, minZ, maxX, maxZ] keeps non-physics entities inside; bodies need walls. world.gravity defaults to [0, -9.81, 0], and world.listener pans impact sounds.
Read state without allocating
React and React Native · shared engine API
import type { Entity, World } from "@nayan-ui/engine";export function makeTracker(world: World, player: Entity, onFall: () => void) {const at: [number, number, number] = [0, 0, 0];const screen: [number, number] = [0, 0];world.bounds = [-20, -20, 20, 20];world.listener = player;return () => {if (world.position(player, at) && at[1] < -5) onFall();return world.toScreen(at, screen); // place a label over the player};}
A reset is your own function: cancelTimers(), despawn the round's entities, reset the scoreboard and spawn the next round in the same world. Loaded assets and mesh ids stay valid.
dispose() is idempotent, resolves pending animation promises and drops timers. Code that spawns after an await checks world.isDisposed; cleanup calls need no guard.
Spawn safely after an await
React and React Native · shared engine API
import type { Entity, World } from "@nayan-ui/engine";export async function popAndReplace(world: World, piece: Entity) {await world.animate(piece, { scale: 0 }, { duration: 0.2 });world.despawn(piece); // safe even if the screen closedif (world.isDisposed) return null; // spawn would throwreturn world.spawn({ mesh: "cube", position: [0, 4, 0] });}export function resetRound(world: World, actors: Entity[]) {world.cancelTimers();for (const actor of actors.splice(0)) world.despawn(actor);world.scoreboard.reset(3);}