Your first game
This page builds a small but complete 3D game from an empty file: Orb Hunt. You roll a ball around a round floor with a thumb stick (or WASD in a browser) and catch as many glowing orbs as you can in 30 seconds. It has a start hint, a score, a timer, a finish card with Play again, and an exit button.
The whole game is one file, src/game/index.tsx, about 170 lines. It uses five engine pieces: EngineCanvas for the 3D scene, Environment and PostFX for light and glow, Joystick for input, and the HUD chrome (TopBar, Banner, FinishCard). The code on this page was type-checked with npm run typecheck, exported with npm run export:web and played in Chrome.
You need the template installed and running first: follow Quickstart up to step 3.
How a game plugs in
The template's src/App.tsx imports src/game/index.tsx and renders the Component of whatever that file default-exports. That export is a GameDefinition:
| Field | What it is |
|---|---|
id | A URL-safe id, orb-hunt |
title | The game's name |
description | One line about the game |
Component | The full-screen game. It receives onExit, which your exit button calls |
So making a game means replacing src/game/index.tsx with a file that default-exports a GameDefinition. The starter game is still in git; you can bring it back at any time with git checkout src/game/index.tsx.
Open src/game/index.tsx, delete everything in it, and write the code below in order, top to bottom. The steps follow the file.
Step 1: imports
import React, { useCallback, useMemo, useRef, useState } from 'react';
import { StyleSheet, View } from 'react-native';
import { useFrame } from '@react-three/fiber';
import type * as THREE from 'three';
import {
Banner,
EngineCanvas,
Environment,
FinishCard,
Joystick,
PostFX,
TopBar,
createCharacterInput,
makeRng,
resetCharacterInput,
type CharacterInput,
type GameDefinition,
type GameScreenProps,
} from '@/engine';
@/engine is the template's engine (src/engine/index.ts; @/ is an alias for src/). useFrame comes straight from React Three Fiber: inside EngineCanvas you use the normal R3F API. three is only imported for its types here; the template resolves it to three.js's WebGPU build.
Step 2: the rules as numbers
const ARENA = 4.5; // radius of the floor, in metres
const SPEED = 5; // metres per second with the stick at full tilt
const ROUND = 30; // seconds per round
const REACH = 1; // how close the ball must get to catch an orb
type Phase = 'ready' | 'playing' | 'finished';
Keep the tuning in named constants at the top. A round goes through three phases: ready (waiting for the first move), playing, and finished.
Step 3: world state and HUD state
/** Everything the loop changes every frame. A plain object, never React state. */
interface World {
phase: Phase;
x: number;
z: number;
orbX: number;
orbZ: number;
score: number;
timeLeft: number;
}
const newWorld = (): World => ({ phase: 'ready', x: 0, z: 0, orbX: 0, orbZ: -3, score: 0, timeLeft: ROUND });
/** What the HUD shows. It changes a few times a second at most. */
interface Hud {
phase: Phase;
score: number;
seconds: number;
}
const sameHud = (a: Hud, b: Hud) => a.phase === b.phase && a.score === b.score && a.seconds === b.seconds;
This split is the most important habit in an engine game. The ball's position changes 60 times a second; putting it in React state would re-render the whole scene every frame. So the loop works on a plain mutable object, World, kept in a ref. React state holds only what the HUD prints (the phase, the score, the whole seconds left), and it is updated only when one of those values changes. sameHud is that check.
Step 4: the scene component
interface SceneProps {
input: CharacterInput;
world: React.RefObject<World>;
onHud: (hud: Hud) => void;
}
/** The 3D scene and the game loop. Everything here renders inside EngineCanvas. */
const Scene = ({ input, world, onHud }: SceneProps) => {
const ball = useRef<THREE.Mesh>(null);
const orb = useRef<THREE.Mesh>(null);
const rng = useMemo(() => makeRng(7), []);
Scene will render inside EngineCanvas, so it can call useFrame. It gets three things from the screen above it: the input object the joystick writes into, the world ref, and onHud to report what the HUD should show. ball and orb are refs to the two meshes so the loop can move them without React. makeRng(7) from the engine is a small seeded random number generator: the orbs appear in the same places every round, which makes the game easy to test.
Step 5: the loop, moving the ball
useFrame((state, delta) => {
const w = world.current;
const dt = Math.min(delta, 0.05);
if (w.phase !== 'finished') {
// The stick reads on the unit disc, +y up on screen: up rolls the ball away from the camera.
w.x += input.moveX * SPEED * dt;
w.z -= input.moveY * SPEED * dt;
const r = Math.hypot(w.x, w.z);
if (r > ARENA) {
w.x *= ARENA / r;
w.z *= ARENA / r;
}
if (w.phase === 'ready' && (input.moveX !== 0 || input.moveY !== 0)) w.phase = 'playing';
}
useFrame runs once per frame with the time since the last one, delta, in seconds. Capping it at 0.05 keeps a slow frame (or a tab coming back from the background) from teleporting the ball.
The joystick writes moveX and moveY on the unit disc, with +y meaning up on the screen. The camera looks down the -z axis, so pushing up should roll the ball toward -z: that is why moveY is subtracted. If the ball leaves the floor, it is scaled back onto the rim. The first push of the stick starts the round.
Step 6: the loop, catching orbs and the clock
if (w.phase === 'playing') {
w.timeLeft = Math.max(0, w.timeLeft - dt);
if (Math.hypot(w.x - w.orbX, w.z - w.orbZ) < REACH) {
w.score += 1;
const angle = rng() * Math.PI * 2;
const distance = 1.5 + rng() * (ARENA - 2);
w.orbX = Math.cos(angle) * distance;
w.orbZ = Math.sin(angle) * distance;
}
if (w.timeLeft === 0) w.phase = 'finished';
}
While the round plays, the clock counts down and a distance check decides a catch. A caught orb scores a point and jumps to a random spot on the floor, not too close to the centre or the rim. No physics engine is needed for a game this size; when you want things that bounce and topple, see Physics.
Step 7: the loop, drawing the result
ball.current?.position.set(w.x, 0.5, w.z);
if (orb.current) {
orb.current.position.set(w.orbX, 0.8 + Math.sin(state.clock.elapsedTime * 3) * 0.15, w.orbZ);
orb.current.rotation.y += dt * 2;
}
onHud({ phase: w.phase, score: w.score, seconds: Math.ceil(w.timeLeft) });
});
The last part of the loop copies the world onto the meshes: the ball sits on the floor (its radius is 0.5), and the orb bobs and spins so it reads as a pickup. Then it reports the HUD values. onHud is called every frame, but as you will see in step 9 it only causes a re-render when a value changed.
Step 8: the meshes
return (
<>
<mesh position={[0, -0.2, 0]} receiveShadow>
<cylinderGeometry args={[ARENA + 0.5, ARENA + 0.5, 0.4, 48]} />
<meshStandardMaterial color="#4A5468" />
</mesh>
<mesh ref={ball} castShadow>
<sphereGeometry args={[0.5, 32, 16]} />
<meshStandardMaterial color="#B8BCC6" />
</mesh>
<mesh ref={orb} castShadow>
<octahedronGeometry args={[0.4]} />
<meshStandardMaterial color="#FFD27A" emissive="#FFB020" emissiveIntensity={2} />
</mesh>
</>
);
};
This is plain React Three Fiber JSX: every three.js class is a lower-case element, constructor arguments go in args. The floor is a flat cylinder, the ball a sphere, the orb an octahedron with an emissive material, which the bloom in the next step turns into a glow. castShadow and receiveShadow work because Environment sets up the sun's shadows.
Step 9: the screen
/** The full-screen game: the canvas, the stick over it, the HUD on top. */
const OrbHunt = ({ onExit }: GameScreenProps) => {
const input = useMemo(() => createCharacterInput(), []);
const world = useRef<World>(newWorld());
const [hud, setHud] = useState<Hud>({ phase: 'ready', score: 0, seconds: ROUND });
// Called every frame; React only re-renders when a shown value changed.
const onHud = useCallback((next: Hud) => setHud((prev) => (sameHud(prev, next) ? prev : next)), []);
const restart = () => {
resetCharacterInput(input);
world.current = newWorld();
setHud({ phase: 'ready', score: 0, seconds: ROUND });
};
const status = hud.phase === 'ready' ? 'Catch the orbs' : hud.phase === 'finished' ? 'Time!' : `${hud.score} caught`;
OrbHunt is the Component of the game. It owns the three pieces of state from step 3:
inputcomes fromcreateCharacterInput(): a plain object withmoveX,moveYand a few fields for look pads and buttons. The joystick writes into it; the loop reads it.worldis the ref the loop mutates.hudis React state. When the updater inonHudreturns the previous object unchanged, React skips the re-render, so the HUD updates about once a second (when the timer ticks) or when you score.
restart clears any held input, puts a fresh world in the ref and resets the HUD. Nothing in the scene needs to remount: the next frame draws the new world.
Step 10: canvas, controls and HUD
return (
<View style={styles.root}>
<EngineCanvas camera={{ position: [0, 16, 9], fov: 55 }}>
<Environment preset="sunset" />
<PostFX preset="glow" />
<Scene input={input} world={world} onHud={onHud} />
</EngineCanvas>
<Joystick input={input} zone="full" disabled={hud.phase === 'finished'} />
<View style={StyleSheet.absoluteFill} pointerEvents="box-none">
<TopBar title="ORB HUNT" status={status} rightLabel="TIME" rightValue={`${hud.seconds}s`} onExit={onExit} />
{hud.phase === 'ready' && <Banner text="Move to start" />}
{hud.phase === 'finished' && (
<FinishCard
title="TIME!"
lines={[`${hud.score} orbs caught`]}
restartLabel="Play again"
onRestart={restart}
onExit={onExit}
/>
)}
</View>
</View>
);
};
const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: '#101114' },
});
The screen is three layers, in this order, because later siblings sit on top:
EngineCanvas: the WebGPU canvas with a React Three Fiber scene inside. The camera sits high and slightly behind the floor; with no rotation given, it looks at the origin.Environment preset="sunset"gives the sky, the sun with shadows, fill light and fog.PostFX preset="glow"adds bloom and a vignette. See Canvas and rendering.Joystick: a floating thumb stick.zone="full"lets a thumb start it anywhere below the top 14 percent of the screen; the base appears where the thumb lands. In a desktop browser it also answers to WASD and the arrow keys, and a mouse drag works like a thumb. See Input.- The HUD, a full-screen view with
pointerEvents="box-none"so touches that miss its buttons fall through to the joystick.TopBarhas the exit button, the title, the status line and the timer.Bannershows the start hint.FinishCardshows the result with Play again and Back to games. See HUD and UI.
Both exits call onExit. In the template's App.tsx that unmounts the game (its loop and sounds stop) and shows a "Play Orb Hunt" button. Inside the Moonspell app it returns the player to the app.
Step 11: the definition
const game: GameDefinition = {
id: 'orb-hunt',
title: 'Orb Hunt',
description: 'Roll the ball into as many glowing orbs as you can in 30 seconds.',
Component: OrbHunt,
};
export default game;
The default export is what App.tsx renders. The title also names the Play button after an exit.
Run it
Check the types, then start the dev server:
npm run typecheck
npm run web
Open http://localhost:8081. You should see a slate-blue floor under a grey sunset sky, a pale ball in the middle, a glowing gold orb ahead of it and Move to start across the screen. Press W (or drag up from the middle of the screen with the mouse) and the ball rolls into the orb: the status line says 1 caught and the orb jumps somewhere else. After 30 seconds the finish card appears.
The console shows two THREE.Clock: This module has been deprecated warnings; they come from three.js and are harmless.
To check the build players would get, run npm run export:web and serve dist/ as in Quickstart.
The whole file
import React, { useCallback, useMemo, useRef, useState } from 'react';
import { StyleSheet, View } from 'react-native';
import { useFrame } from '@react-three/fiber';
import type * as THREE from 'three';
import {
Banner,
EngineCanvas,
Environment,
FinishCard,
Joystick,
PostFX,
TopBar,
createCharacterInput,
makeRng,
resetCharacterInput,
type CharacterInput,
type GameDefinition,
type GameScreenProps,
} from '@/engine';
const ARENA = 4.5; // radius of the floor, in metres
const SPEED = 5; // metres per second with the stick at full tilt
const ROUND = 30; // seconds per round
const REACH = 1; // how close the ball must get to catch an orb
type Phase = 'ready' | 'playing' | 'finished';
/** Everything the loop changes every frame. A plain object, never React state. */
interface World {
phase: Phase;
x: number;
z: number;
orbX: number;
orbZ: number;
score: number;
timeLeft: number;
}
const newWorld = (): World => ({ phase: 'ready', x: 0, z: 0, orbX: 0, orbZ: -3, score: 0, timeLeft: ROUND });
/** What the HUD shows. It changes a few times a second at most. */
interface Hud {
phase: Phase;
score: number;
seconds: number;
}
const sameHud = (a: Hud, b: Hud) => a.phase === b.phase && a.score === b.score && a.seconds === b.seconds;
interface SceneProps {
input: CharacterInput;
world: React.RefObject<World>;
onHud: (hud: Hud) => void;
}
/** The 3D scene and the game loop. Everything here renders inside EngineCanvas. */
const Scene = ({ input, world, onHud }: SceneProps) => {
const ball = useRef<THREE.Mesh>(null);
const orb = useRef<THREE.Mesh>(null);
const rng = useMemo(() => makeRng(7), []);
useFrame((state, delta) => {
const w = world.current;
const dt = Math.min(delta, 0.05);
if (w.phase !== 'finished') {
// The stick reads on the unit disc, +y up on screen: up rolls the ball away from the camera.
w.x += input.moveX * SPEED * dt;
w.z -= input.moveY * SPEED * dt;
const r = Math.hypot(w.x, w.z);
if (r > ARENA) {
w.x *= ARENA / r;
w.z *= ARENA / r;
}
if (w.phase === 'ready' && (input.moveX !== 0 || input.moveY !== 0)) w.phase = 'playing';
}
if (w.phase === 'playing') {
w.timeLeft = Math.max(0, w.timeLeft - dt);
if (Math.hypot(w.x - w.orbX, w.z - w.orbZ) < REACH) {
w.score += 1;
const angle = rng() * Math.PI * 2;
const distance = 1.5 + rng() * (ARENA - 2);
w.orbX = Math.cos(angle) * distance;
w.orbZ = Math.sin(angle) * distance;
}
if (w.timeLeft === 0) w.phase = 'finished';
}
ball.current?.position.set(w.x, 0.5, w.z);
if (orb.current) {
orb.current.position.set(w.orbX, 0.8 + Math.sin(state.clock.elapsedTime * 3) * 0.15, w.orbZ);
orb.current.rotation.y += dt * 2;
}
onHud({ phase: w.phase, score: w.score, seconds: Math.ceil(w.timeLeft) });
});
return (
<>
<mesh position={[0, -0.2, 0]} receiveShadow>
<cylinderGeometry args={[ARENA + 0.5, ARENA + 0.5, 0.4, 48]} />
<meshStandardMaterial color="#4A5468" />
</mesh>
<mesh ref={ball} castShadow>
<sphereGeometry args={[0.5, 32, 16]} />
<meshStandardMaterial color="#B8BCC6" />
</mesh>
<mesh ref={orb} castShadow>
<octahedronGeometry args={[0.4]} />
<meshStandardMaterial color="#FFD27A" emissive="#FFB020" emissiveIntensity={2} />
</mesh>
</>
);
};
/** The full-screen game: the canvas, the stick over it, the HUD on top. */
const OrbHunt = ({ onExit }: GameScreenProps) => {
const input = useMemo(() => createCharacterInput(), []);
const world = useRef<World>(newWorld());
const [hud, setHud] = useState<Hud>({ phase: 'ready', score: 0, seconds: ROUND });
// Called every frame; React only re-renders when a shown value changed.
const onHud = useCallback((next: Hud) => setHud((prev) => (sameHud(prev, next) ? prev : next)), []);
const restart = () => {
resetCharacterInput(input);
world.current = newWorld();
setHud({ phase: 'ready', score: 0, seconds: ROUND });
};
const status = hud.phase === 'ready' ? 'Catch the orbs' : hud.phase === 'finished' ? 'Time!' : `${hud.score} caught`;
return (
<View style={styles.root}>
<EngineCanvas camera={{ position: [0, 16, 9], fov: 55 }}>
<Environment preset="sunset" />
<PostFX preset="glow" />
<Scene input={input} world={world} onHud={onHud} />
</EngineCanvas>
<Joystick input={input} zone="full" disabled={hud.phase === 'finished'} />
<View style={StyleSheet.absoluteFill} pointerEvents="box-none">
<TopBar title="ORB HUNT" status={status} rightLabel="TIME" rightValue={`${hud.seconds}s`} onExit={onExit} />
{hud.phase === 'ready' && <Banner text="Move to start" />}
{hud.phase === 'finished' && (
<FinishCard
title="TIME!"
lines={[`${hud.score} orbs caught`]}
restartLabel="Play again"
onRestart={restart}
onExit={onExit}
/>
)}
</View>
</View>
);
};
const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: '#101114' },
});
const game: GameDefinition = {
id: 'orb-hunt',
title: 'Orb Hunt',
description: 'Roll the ball into as many glowing orbs as you can in 30 seconds.',
Component: OrbHunt,
};
export default game;
Make it yours
- Tune it. Change
ARENA,SPEED,ROUNDandREACH. A bigger arena needs the camera further back: the screen is portrait, so the view is narrow. - Add sound. Put a short
.wavinassets/, load it withconst sfx = useOneShots({ pop: require('../../assets/sfx/pop.wav') })(Tap Pop's sound, already in the template) and callsfx.play('pop')on a catch, or use the audio mixer for positional sound (Audio). - Add juice. A camera shake or a flash on every catch: Game feel.
- Keep a best score.
useLeaderboardworks on the device with no backend (Net); saves are on Saves. - Name it. The browser tab and the phone app take their name from
expo.nameinapp.json(it says "Sunset Sprint"). The other names to change are on Project structure. - Update
playtest.json. The template's playtest drives Sunset Sprint's buttons. If you use playtests, rewrite it for your controls (GDD.md and playtest.json).
Before you share the game, read The game contract: the rules that keep it playing on the web, iOS and Android.