Build your first game

Start from the games example in the Nayan UI repo and see how its Car Racing is built.

Start from the example

The Nayan UI repo has a ready-made games project: examples/games. It holds one complete game, Car Racing, that runs on iOS, Android and the web from the same code. The CLI copies it for you.

It is the same Car Racing as in Playtura and on nayanui.com/games. The only change is the car, which is loaded from glTF files instead of built in code.

Create and run it

Terminal / configuration

Create and run it
npx @nayan-ui/cli new my-game -t games # copy examples/games into ./my-game
cd my-game # go into the project
bun install # install the apps and @nayan-ui/engine from npm
bun run web # play in the browser at http://127.0.0.1:5173
bun run ios # or: bun run android (a development build, not Expo Go)

What is in the project

Three parts. shared/ holds the game, written once in React Native. mobile/ is an Expo app that renders it. web/ is a Vite app that renders the same component through react-native-web.

Project layout

React and React Native · shared engine API

Project layout
my-game/
├── shared/ # the game, used by both apps
│ ├── index.ts # exports CarRacing, GameProps, DEFAULT_GAME_SETTINGS
│ └── src/
│ ├── games/car-racing/ # the Car Racing game
│ ├── games/shared/ # the game screen: scoreboard, buttons, loading, results
│ ├── helpers/ # the settings modal and themes
│ ├── services/ # GameProps, game ids, themes, sounds
│ └── assets/ # car models, textures, sounds, font
├── mobile/src/ # Expo app (Expo Router)
│ ├── app/index.tsx # route /: the list of games
│ ├── app/[id].tsx # route /car-racing: the game, full screen
│ └── examples.ts # the list of games
└── web/src/ # Vite app (React Router)
├── Home.tsx # route /: the list of games
├── GamePage.tsx # route /car-racing: the game, full screen
└── examples.ts # the list of games

How Car Racing is built

Car Racing is seven files in shared/src/games/car-racing. The rules have no React, the scene only draws, and the screen connects them to the shared game screen.

How Car Racing is built reference
FileWhat it does
CarRacing.tsxThe screen: loads the assets, starts a run and passes the scene, scoreboard and buttons to EngineGameScreen.
CarRacingConstants.tsTuning (speed, lives, track length), colours, lights and loadAssets().
CarRacingGame.tsThe rules: steering, traffic, crashes, scoring, lives, the finish line and the camera.
CarRacingScene.tsWhat it draws: the curving road, the cars, the city and the horizon.
CarRacingScenery.tsSmall roadside meshes: benches, bins, planters, shelters and signs.
CarRacingFacade.tsWhich building windows light up at night.
CarMeshes.tsLoads the car's glTF models and builds the roadside tree.

shared/index.ts

React and React Native · shared engine API

What both apps import from the shared package.

shared/index.ts
// The game: a component that takes GameProps.
export { CarRacing } from "./src/games/car-racing/CarRacing";
// Its props and default settings.
export { DEFAULT_GAME_SETTINGS, type GameProps, type GameSettings } from "./src/services/UtilsService";
// Theme colours, so an app can paint its header to match.
export { resolveGameTheme } from "./src/services/ThemeService";

1. Load the car

The car is three glTF models: a white body that each car paints its own colour, the glass, and the trim. They load once per app, on native background threads, and every car reuses them.

shared/src/games/car-racing/CarMeshes.ts

React and React Native · shared engine API

shared/src/games/car-racing/CarMeshes.ts
let car: Promise<CarMeshes> | undefined; // Loaded once, then shared by every run.
export function loadCarMeshes(): Promise<CarMeshes> {
car ??= Promise.all([ // Start loading only on the first call.
// White paint: each car tints it.
loadModel(require("../../assets/models/car/car_body.glb"), { center: false, shine: 0.85 }),
// Windows, glossier.
loadModel(require("../../assets/models/car/car_glass.glb"), { center: false, shine: 0.95 }),
// Tyres, lights, mirrors in their own colours.
loadModel(require("../../assets/models/car/car_trim.glb"), { center: false, shine: 0.25 }),
]).then(([body, glass, trim]) => ({ body, glass, trim })); // One object with the three models.
car.catch(() => (car = undefined)); // If loading fails, try again next time.
return car; // Every caller gets the same promise.
}

2. Put cars on the road

Each car is an invisible root entity with the three models attached. The game moves only the root; the body takes the car's colour.

shared/src/games/car-racing/CarRacingScene.ts

React and React Native · shared engine API

shared/src/games/car-racing/CarRacingScene.ts
export function spawnCar(world: World, a: Assets, paint: Color, position: Vec3, night: boolean) {
const model = a.car; // The loaded body, glass and trim.
const root = world.spawn({ mesh: "none", position }); // An invisible root: the game moves this.
// The models sit with their wheels on the ground.
const offset: Vec3 = [0, -model.size[1] / 2, 0];
// The paint, in this car's colour.
const body = world.spawn({ mesh: model.body, parent: root, position: offset, color: paint });
const glass = world.spawn({ mesh: model.glass, parent: root, position: offset }); // The windows.
// Tyres, lights and the rest.
const trim = world.spawn({ mesh: model.trim, parent: root, position: offset });
// Everything the game needs to move or restyle it.
const car = { root, body, glass, trim, lights: [] as Entity[] };
// At night, glowing headlights and a tail-light bar.
setCarNight(world, car, night, model.size[1]);
return car;
}

3. The screen

CarRacing.tsx hands the run to EngineGameScreen, the shared game screen. It renders the GameView, the scoreboard, the Start/Stop button, the results card and the settings modal, so the game itself only supplies the scene and its numbers.

shared/src/games/car-racing/CarRacing.tsx (part)

React and React Native · shared engine API

shared/src/games/car-racing/CarRacing.tsx (part)
<EngineGameScreen
gameId={GAME_IDS.CAR_RACING} // Picks the game's title, loading scene and sounds.
settings={settings} // Sounds, haptics, theme and where the scoreboard starts (from the app).
onSettingsChange={onSettingsChange} // The settings modal saves its changes here.
source={game.world} // The engine world this run draws.
camera={game.camera} // The chase camera behind the player's car.
light={night ? NIGHT_LIGHT : DAY_LIGHT} // Day or night lighting.
background={night ? NIGHT_SKY : SKY} // The sky colour.
onUpdate={onUpdate} // Runs the rules every frame (tick).
onDrag={onDrag} // Dragging steers the car.
// The scoreboard.
columns={[scoreColumn(score), livesColumn(lives, LIVES), textColumn("TRACK", `${left} m`)]}
playing={phase === "playing"} // Shows Start or Stop.
onStart={handleStartGame} // Start pressed.
onStop={handleStopGame} // Stop pressed: report a cancel and start a fresh run.
// The results card.
gameOver={
showResults // After the crash or the finish has been seen:
? { title: won ? "FINISHED!" : "CRASHED!", score, buttonText: "Race Again!", onPlayAgain: handleResetGame }
: null // No results card during the race.
}
/>

4. Show it on mobile

The Expo app opens each game at /<id> with Expo Router. The header floats over the game, and settings.offset tells the game to start its scoreboard below it.

mobile/src/app/[id].tsx (part)

React Native · iOS and Android

mobile/src/app/[id].tsx (part)
const { id } = useLocalSearchParams<{ id: string }>(); // The route's id: "car-racing".
const example = findExample(id); // The game for that id, from examples.ts.
const headerHeight = useHeaderHeight(); // The header's height, status bar included.
// What the game reads.
const [settings, setSettings] = useState<GameSettings>({ ...DEFAULT_GAME_SETTINGS, theme: deviceTheme });
return (
<example.Screen
settings={{ ...settings, offset: headerHeight }} // The scoreboard starts below the header.
onSettingsChange={setSettings} // The settings modal saves its changes here.
// No swipe-back mid-race.
onPlayingChange={(playing) => navigation.setOptions({ gestureEnabled: !playing })}
/>
);

5. Show it on the web

The Vite app opens each game at /<id> with React Router. react-native-web renders the game's React Native code, and createKeyboardInput turns keys into the same gestures as touch.

web/src/GamePage.tsx (part)

React · browser

web/src/GamePage.tsx (part)
const example = findExample(useParams().id); // The game for this route's id.
// Scoreboard below the toolbar.
const [settings, setSettings] = useState<GameSettings>({ ...DEFAULT_GAME_SETTINGS, offset: 70 });
useEffect(() => {
// Arrow keys and A/D drag, so they steer.
const keyboard = createKeyboardInput(surface.current!, { mode: "drag" });
return () => keyboard.dispose(); // Stop listening when the page closes.
}, []);
return (
<div ref={surface} tabIndex={0}> {/* The game draws here; tabIndex lets it take the keys. */}
<Suspense fallback={<p>Loading…</p>}> {/* Shown while the game's code downloads. */}
{/* The same component as on mobile. */}
<example.Screen settings={settings} onSettingsChange={setSettings} />
</Suspense>
</div>
);

web/vite.config.ts (part)

React · browser

The browser build's two changes to the shared React Native code.

web/vite.config.ts (part)
resolve: {
// React Native components render as HTML.
alias: [{ find: /^react-native$/, replacement: "react-native-web" }],
// A file's .web.ts version wins in the browser.
extensions: [".web.tsx", ".web.ts", ".tsx", ".ts", ".js"],
},
// Plus a small plugin: require("./engine.wav") becomes a URL that Vite copies into the build.

Add your own game

Put the game in shared/ and list it in both apps. Each app then gives it a route at /<id> with no other changes.

Steps

React and React Native · shared engine API

Steps
1. Write the game in shared/src/games/<id>/ (copy car-racing as a starting point).
2. Add its id to GAME_IDS in shared/src/services/UtilsService.ts.
3. Add its title and instructions to GAMES_METADATA in shared/src/services/GamesMetadata.ts.
4. Export its component from shared/index.ts.
5. Add { id, title, description, Screen } to mobile/src/examples.ts and web/src/examples.ts.
6. Run bun run typecheck, then bun run web and bun run ios.

Try it

Each line is one expected result.

Checklist

React and React Native · shared engine API

Checklist
[ ] The list shows Car Racing; opening it shows the loading scene, then the race
[ ] Start: the car drives; drag (or arrow keys / A D) to steer
[ ] Pass a car: the score goes up
[ ] Hit a car from behind: one heart is lost; side swipes only slow you down
[ ] Settings: Dark Mode switches to the night city with lit windows and headlights
[ ] Finish the 2000 m track, or lose all lives: the results card shows; Race Again starts over
[ ] Back (header arrow, toolbar button or browser back): returns to the list