Engine guide for AI agents

Rules, file layout, API choices and a template for coding agents.

Read first

Start from /engine.json and the first game; check every name in the API reference. Never guess names from another engine.

Read first reference
NeedPage
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

Fetch the summary
curl -s https://www.nayanui.com/engine.json

File structure

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 structure reference
FileContainsNever
gameConstants.tsTuning, colors, asset sources, loadAssets()Worlds or entities
game.tscreateGame(world, …): rules and round actionsReact, DOM, react-native
useGame.tsuseWorld + createGameRules or per-frame state
GameScreen.tsx / GamePage.tsxHUD, buttons, GameViewIts own frame loop

Layout

React and React Native · shared engine API

Layout
src/game/
gameConstants.ts
game.ts
useGame.ts
GameScreen.tsx # React Native (GamePage.tsx on the web)

One API per task

Each task has one way. Use it.

One API per task reference
TaskUse
Own a worlduseWorld(capacity, setup)
Game loopGameView onUpdate(dt); the world is stepped right after
Spawn / change / removeworld.spawn → Entity; world.set; world.despawn
Per-frame movesworld.transforms.position / rotation / scale / color / transform
Rotationsquat.y / x / z / fromEuler / multiply / slerp, with out
Per-frame queriesworld.position(e, out), raycast(…, out), toScreen(p, out)
Physicsphysics option, groundVelocity, world.impulse
Collisionsworld.forEachCollision in onUpdate
Tap / dragonTap + world.pick; onDrag + world.screenToWorld
Tweensawait world.animate(e, target, options)
Effectseffects.confetti / sparkle / shatter / splash / sparks / explosion / dust / flash
Clear a pieceawait effects.pop(world, e, { scale }); world.despawn(e)
Score, lives, clockscoreboard.reset(lives, seconds), resume(), add(); handlers = rules, listen = UI
Delaysworld.after(s, fn); cancelTimer(id); cancelTimers() on reset
Colors'#33B3FF' or 0..1 tuples; toColor for numbers
Flat boardslight={frontLight(direction, ambient)}
Long setupafterNextFrame(fn) slices; ProceduralCache.peek(key)
CharactersloadModel(src).animations; world.playClip / stopClip
Settingsaudio.enabled, haptics.enabled
PaintingDrawingDocument + DrawingCanvas
No native moduleisEngineAvailable

The whole loop

React and React Native · shared engine API

The whole loop
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); }} />
);
}

Never do

Each row is a real bug. Check finished code against all of them.

Never do reference
NeverInstead
new World in render, useMemo or module scopeuseWorld(capacity, setup)
onUpdate={(dt) => world.update(dt)}Nothing: GameView steps the world
requestAnimationFrame / setInterval loopsGameView onUpdate
setTimeout for gameplay delaysworld.after
Per-frame positions in React stateworld.transforms; push only HUD values
Read a position right after queuing a moveworld.transforms.flush() first
Allocate arrays per frameout arguments, quat(…, out)
Hand-written quaternionsquat
Hand-built particle layerseffects.*
Replace scoreboard.handlers for UIscoreboard.listen
A custom disposed flagworld.isDisposed
spawn / effects after disposeCheck world.isDisposed after an await
Meshes per spawn or per frameBuild once, reuse (ids are never freed)
Culling scenery in JSNothing: Rust culls every frame
A long job in one frameSlice it with afterNextFrame
Reporting a result twiceGuard finish() with the status
Unsupported color names ('pink')Hex or rgba strings
Testing native in Expo GoA development build

Wrong and right frame

React and React Native · shared engine API

Wrong and right frame
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]);
}

Game template

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

game.ts
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

useGame.ts
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.

GameScreen.tsx
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>
);
}

Agent prompt

Paste into a task and fill in the brackets.

Prompt

React and React Native · shared engine API

Prompt
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.

Verify

Types prove little about picking, cameras or timing: run the game. Web needs localhost or HTTPS; native needs a development build.

Commands

Terminal / configuration

Commands
bunx tsc --noEmit -p . # each host app
bun run dev # web: open the game over localhost
bunx expo run:ios # native development build
bunx expo run:android