# Your first game

> Build Orb Hunt, a small 3D game, from scratch in src/game/index.tsx, step by step, with EngineCanvas, a joystick that also answers to the keyboard, a game loop in useFrame, a HUD and a finish screen, then run it in the browser.

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](https://docs.moonspell.app/start/quickstart.md) 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

```tsx
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

```tsx
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

```tsx
/** 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

```tsx
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

```tsx
  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

```tsx
    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](https://docs.moonspell.app/engine/physics.md).

## Step 7: the loop, drawing the result

```tsx
    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

```tsx
  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

```tsx
/** 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:

- `input` comes from `createCharacterInput()`: a plain object with `moveX`, `moveY` and a few fields for look pads and buttons. The joystick writes into it; the loop reads it.
- `world` is the ref the loop mutates.
- `hud` is React state. When the updater in `onHud` returns 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

```tsx
  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:

1. **`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](https://docs.moonspell.app/engine/canvas-and-rendering.md).
2. **`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](https://docs.moonspell.app/engine/input.md).
3. **The HUD**, a full-screen view with `pointerEvents="box-none"` so touches that miss its buttons fall through to the joystick. `TopBar` has the exit button, the title, the status line and the timer. `Banner` shows the start hint. `FinishCard` shows the result with **Play again** and **Back to games**. See [HUD and UI](https://docs.moonspell.app/engine/hud-and-ui.md).

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

```tsx
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:

```bash
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](https://docs.moonspell.app/start/quickstart.md#4-make-a-static-web-build).

## The whole file

```tsx
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`, `ROUND` and `REACH`. A bigger arena needs the camera further back: the screen is portrait, so the view is narrow.
- **Add sound.** Put a short `.wav` in `assets/`, load it with `const sfx = useOneShots({ pop: require('../../assets/sfx/pop.wav') })` (Tap Pop's sound, already in the template) and call `sfx.play('pop')` on a catch, or use the audio mixer for positional sound ([Audio](https://docs.moonspell.app/engine/audio.md)).
- **Add juice.** A camera shake or a flash on every catch: [Game feel](https://docs.moonspell.app/engine/game-feel.md).
- **Keep a best score.** `useLeaderboard` works on the device with no backend ([Net](https://docs.moonspell.app/engine/net.md)); saves are on [Saves](https://docs.moonspell.app/engine/saves.md).
- **Name it.** The browser tab and the phone app take their name from `expo.name` in `app.json` (it says "Sunset Sprint"). The other names to change are on [Project structure](https://docs.moonspell.app/start/project-structure.md).
- **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](https://docs.moonspell.app/ai/game-design-doc.md)).

Before you share the game, read [The game contract](https://docs.moonspell.app/start/game-contract.md): the rules that keep it playing on the web, iOS and Android.
