Rules, file layout, API choices and a template for coding agents.
Start from /engine.json and the first game; check every name in the API reference. Never guess names from another engine.
| Need | Page |
|---|---|
| Machine summary | /engine.json |
| Working baseline | /game-engine/first-game |
| One feature | /game-engine/recipes |
| Lifecycle and entities | /game-engine/world-entities |
| Exact signatures | /game-engine/api-reference |
| Blank screen, build errors | /game-engine/troubleshooting |
Fetch the summary
Terminal / configuration
curl -s https://www.nayanui.com/engine.json
Rules live in plain TypeScript with no UI imports, so one file serves web and native. The hook owns the world; the screen owns layout and GameView.
| File | Contains | Never |
|---|---|---|
| gameConstants.ts | Tuning, colors, asset sources, loadAssets() | Worlds or entities |
| game.ts | createGame(world, …): rules and round actions | React, DOM, react-native |
| useGame.ts | useWorld + createGame | Rules or per-frame state |
| GameScreen.tsx / GamePage.tsx | HUD, buttons, GameView | Its own frame loop |
Layout
React and React Native · shared engine API
src/game/gameConstants.tsgame.tsuseGame.tsGameScreen.tsx # React Native (GamePage.tsx on the web)
Each task has one way. Use it.
| Task | Use |
|---|---|
| Own a world | useWorld(capacity, setup) |
| Game loop | GameView onUpdate(dt); the world is stepped right after |
| Spawn / change / remove | world.spawn → Entity; world.set; world.despawn |
| Per-frame moves | world.transforms.position / rotation / scale / color / transform |
| Rotations | quat.y / x / z / fromEuler / multiply / slerp, with out |
| Per-frame queries | world.position(e, out), raycast(…, out), toScreen(p, out) |
| Physics | physics option, groundVelocity, world.impulse |
| Collisions | world.forEachCollision in onUpdate |
| Tap / drag | onTap + world.pick; onDrag + world.screenToWorld |
| Tweens | await world.animate(e, target, options) |
| Effects | effects.confetti / sparkle / shatter / splash / sparks / explosion / dust / flash |
| Clear a piece | await effects.pop(world, e, { scale }); world.despawn(e) |
| Score, lives, clock | scoreboard.reset(lives, seconds), resume(), add(); handlers = rules, listen = UI |
| Delays | world.after(s, fn); cancelTimer(id); cancelTimers() on reset |
| Colors | '#33B3FF' or 0..1 tuples; toColor for numbers |
| Flat boards | light={frontLight(direction, ambient)} |
| Long setup | afterNextFrame(fn) slices; ProceduralCache.peek(key) |
| Characters | loadModel(src).animations; world.playClip / stopClip |
| Settings | audio.enabled, haptics.enabled |
| Painting | DrawingDocument + DrawingCanvas |
| No native module | isEngineAvailable |
The whole loop
React and React Native · shared engine API
import { useRef } from "react";import { GameView, useWorld, type Entity } from "@nayan-ui/engine";export function Slider() {const box = useRef<Entity | null>(null);const time = useRef(0);const world = useWorld(16, (w) => {box.current = w.spawn({ mesh: "roundedBox", position: [0, 1, 0], color: "#33B3FF" });});if (!world) return null;return (<GameView source={world} camera={{ eye: [0, 3, 6], target: [0, 1, 0], fov: 1 }} style={{ flex: 1 }}onUpdate={(dt) => {time.current += dt;if (box.current) world.transforms.position(box.current, Math.sin(time.current) * 2, 1, 0);}}onTap={(x, y) => { if (world.pick(x, y)) world.scoreboard.add(1); }} />);}
Each row is a real bug. Check finished code against all of them.
| Never | Instead |
|---|---|
| new World in render, useMemo or module scope | useWorld(capacity, setup) |
| onUpdate={(dt) => world.update(dt)} | Nothing: GameView steps the world |
| requestAnimationFrame / setInterval loops | GameView onUpdate |
| setTimeout for gameplay delays | world.after |
| Per-frame positions in React state | world.transforms; push only HUD values |
| Read a position right after queuing a move | world.transforms.flush() first |
| Allocate arrays per frame | out arguments, quat(…, out) |
| Hand-written quaternions | quat |
| Hand-built particle layers | effects.* |
| Replace scoreboard.handlers for UI | scoreboard.listen |
| A custom disposed flag | world.isDisposed |
| spawn / effects after dispose | Check world.isDisposed after an await |
| Meshes per spawn or per frame | Build once, reuse (ids are never freed) |
| Culling scenery in JS | Nothing: Rust culls every frame |
| A long job in one frame | Slice it with afterNextFrame |
| Reporting a result twice | Guard finish() with the status |
| Unsupported color names ('pink') | Hex or rgba strings |
| Testing native in Expo Go | A development build |
Wrong and right frame
React and React Native · shared engine API
import { quat, type Entity, type MutableQuat, type World } from "@nayan-ui/engine";// Wrong: allocates and steps the world a second time.export function wrong(world: World, piece: Entity, angle: number, dt: number) {world.set(piece, { rotation: quat.y(angle) });world.update(dt);}// Right: reused scratch, one queued move; GameView steps once after.const turn: MutableQuat = [0, 0, 0, 1];export function right(world: World, piece: Entity, angle: number) {quat.y(angle, turn);world.transforms.rotation(piece, turn[0], turn[1], turn[2], turn[3]);}
Copy and replace the rules. It covers a ready screen, start, per-frame moves, taps, lives, pop, confetti, one result per run, replay and cancel on unmount.
game.ts
React and React Native · shared engine API
import { effects, quat, type Camera, type Entity, type MutableQuat, type World } from "@nayan-ui/engine";export type Status = "ready" | "playing" | "win" | "lose" | "cancel";export interface Hud { status: Status; score: number; seconds: number; lives: number }export interface Result { status: "win" | "lose" | "cancel"; score: number }const SECONDS = 30;const LIVES = 3;const BLUE = "#4DB3FF";export const camera: Camera = { eye: [0, 8, 10], target: [0, 0, 0], fov: 0.9 };export function createGame(world: World, onHud: (hud: Hud) => void, onEnd: (result: Result) => void) {const board = world.scoreboard;const pieces = new Set<Entity>();const turn: MutableQuat = [0, 0, 0, 1]; // Per-frame scratch.let status: Status = "ready";let time = 0;const hud = () => onHud({ status, score: board.score, seconds: board.shownSeconds, lives: board.lives });function finish(result: Result["status"]) {if (status !== "playing") return; // One result per run.status = result;board.stop();world.cancelTimers();if (result === "win") effects.confetti(world, { position: [0, 3, 0], colors: ["#FF4D4D", "#FFD933"],size: 0.12, speed: 6, count: 40, lifetime: 1.2, gravity: -6 });hud();onEnd({ status: result, score: board.score });}function reset() {world.cancelTimers();for (const piece of pieces) world.despawn(piece);pieces.clear();for (let i = 0; i < 5; i++)pieces.add(world.spawn({ mesh: "roundedBox", position: [i * 1.5 - 3, 0.5, 0], color: BLUE }));board.reset(LIVES, SECONDS); // Ready screen: full, stopped countdown.status = "ready";hud();}world.spawn({ mesh: "plane", scale: [20, 1, 20], color: "#404D59", pickable: false });board.handlers = { onTimeUp: () => finish("lose"), onLivesOut: () => finish("lose") };board.listen({ onScore: hud, onSecond: hud, onLives: hud });reset();return {reset,stop: () => finish("cancel"),dispose: () => finish("cancel"), // Unmounting mid-run reports a cancel.start() {if (status === "playing") return;if (status !== "ready") reset();status = "playing";board.resume();hud();},update(dt: number) { // GameView's onUpdate: queue moves only; the world steps after.time += dt;let i = 0;for (const piece of pieces) {quat.y(time + i++, turn);world.transforms.rotation(piece, turn[0], turn[1], turn[2], turn[3]);}},tap(x: number, y: number) {if (status !== "playing") return;const hit = world.pick(x, y);if (!hit || !pieces.delete(hit.entity)) {board.addLives(-1); // A miss costs a life.return;}const piece = hit.entity;board.add(100);void effects.pop(world, piece, { scale: 1, color: BLUE }).then(() => world.despawn(piece));if (pieces.size === 0) finish("win");},};}export type Game = ReturnType<typeof createGame>;
useGame.ts
React and React Native · shared engine API
import { useRef, useState } from "react";import { useWorld } from "@nayan-ui/engine";import { createGame, type Game, type Hud, type Result } from "./game";export function useGame(onEnd: (result: Result) => void) {const [game, setGame] = useState<Game | null>(null);const [hud, setHud] = useState<Hud | null>(null);const [error, setError] = useState<string | null>(null);const end = useRef(onEnd);end.current = onEnd;const world = useWorld(256, (w) => { // Capacity: entities + text letters + particles.const g = createGame(w, setHud, (result) => end.current(result));setGame(g);return g.dispose; // Runs before the world is disposed.}, [], (e) => setError(e.message));return { world, game, hud, error };}
GameScreen.tsx
React Native · iOS and Android
React Native; on the web swap Text/View/Button for p/div/button.
import { Button, Text, View } from "react-native";import { GameView } from "@nayan-ui/engine";import { camera, type Result } from "./game";import { useGame } from "./useGame";export default function GameScreen({ onEnd }: { onEnd: (result: Result) => void }) {const { world, game, hud, error } = useGame(onEnd);if (error) return <Text>{error}</Text>;if (!world || !game || !hud) return <Text>Loading…</Text>;const playing = hud.status === "playing";return (<View style={{ flex: 1 }}><Text>{hud.status} · {hud.score} · {hud.seconds}s · {hud.lives} lives</Text><Button title={playing ? "Stop" : "Start"} onPress={playing ? game.stop : game.start} /><GameView source={world} camera={camera} onUpdate={game.update} onTap={game.tap} style={{ flex: 1 }} /></View>);}
Paste into a task and fill in the brackets.
Prompt
React and React Native · shared engine API
Build [game] with @nayan-ui/engine for [React | React Native | both].Read first: https://www.nayanui.com/engine.json, https://www.nayanui.com/game-engine/agent-guide,https://www.nayanui.com/game-engine/first-game. Check every API name in /game-engine/api-reference.Follow the agent-guide template: gameConstants.ts, game.ts (no UI imports), useGame.ts (useWorld), one screen per host.- GameView steps the world after onUpdate; no other loops, no world.update unless stepping differently.- Per-frame: world.transforms, out arguments, quat with out; no allocations, no React state.- scoreboard: reset(lives, seconds) for ready, resume() to start; handlers = rules, listen = UI.- world.after for delays; effects.* for particles; effects.pop then despawn for cleared pieces.- CSS hex colors; frontLight for flat boards; audio.enabled / haptics.enabled from settings.- Build meshes once; slice long setup with afterNextFrame.- start / stop / reset in game.ts; one result per run; stop and unmount report cancel.Done when: typecheck passes; start, score, win, lose, stop, pause, replay and unmount behave;the game view has a real size; errors are visible. Claim native behavior only after a development build ran.
Types prove little about picking, cameras or timing: run the game. Web needs localhost or HTTPS; native needs a development build.
Commands
Terminal / configuration
bunx tsc --noEmit -p . # each host appbun run dev # web: open the game over localhostbunx expo run:ios # native development buildbunx expo run:android