Start from the games example in the Nayan UI repo and see how its Car Racing is built.
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
npx @nayan-ui/cli new my-game -t games # copy examples/games into ./my-gamecd my-game # go into the projectbun install # install the apps and @nayan-ui/engine from npmbun run web # play in the browser at http://127.0.0.1:5173bun run ios # or: bun run android (a development build, not Expo Go)
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
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
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.
| File | What it does |
|---|---|
| CarRacing.tsx | The screen: loads the assets, starts a run and passes the scene, scoreboard and buttons to EngineGameScreen. |
| CarRacingConstants.ts | Tuning (speed, lives, track length), colours, lights and loadAssets(). |
| CarRacingGame.ts | The rules: steering, traffic, crashes, scoring, lives, the finish line and the camera. |
| CarRacingScene.ts | What it draws: the curving road, the cars, the city and the horizon. |
| CarRacingScenery.ts | Small roadside meshes: benches, bins, planters, shelters and signs. |
| CarRacingFacade.ts | Which building windows light up at night. |
| CarMeshes.ts | Loads 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.
// 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";
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
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.}
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
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;}
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
<EngineGameScreengameId={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.}/>
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
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.Screensettings={{ ...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 })}/>);
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
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.
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.
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
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.
Each line is one expected result.
Checklist
React and React Native · shared engine API
[ ] 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