# Your first Three.js game: mobile-first, with real particle VFX

> Tutorial · Updated August 2026 · by the NixieFX team · canonical: https://nixiefx.com/threejs-game-tutorial/

**TL;DR — what you build.** A tiny mobile-friendly 3D game in [Three.js](https://threejs.org): a `WebGLRenderer` with a capped pixel ratio, a scene and `PerspectiveCamera`, a ground plane and a player cube, a `requestAnimationFrame` loop driven by delta time, tap-to-move pointer input that works for touch and mouse — and one juicy tap-burst particle effect authored in the free [NixieFX editor](https://nixiefx.com/editor/) and played by the `nixie-fx` Three.js runtime. Assumes a Vite project and basic TypeScript; no game engine, no prior 3D experience.

```sh
npm install three   # later: npm install nixie-fx three
```

## How do you set up a Three.js scene for a mobile game?

Every Three.js app is three objects: a **renderer** that draws to a canvas, a **scene** that holds your objects, and a **camera** that defines the view. The one mobile-critical decision happens on the second line: `setPixelRatio` capped at 2. Modern phones report a `devicePixelRatio` of 3 or higher, and rendering at full native resolution more than doubles your per-pixel cost for detail nobody can see on a 6-inch screen.

```ts
import * as THREE from "three";

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);

const scene = new THREE.Scene();
scene.background = new THREE.Color(0x10131a);

const camera = new THREE.PerspectiveCamera(
  60,                                     // vertical field of view
  window.innerWidth / window.innerHeight, // aspect ratio
  0.1,                                    // near plane
  100                                     // far plane
);
camera.position.set(0, 7, 9);
camera.lookAt(0, 0, 0);

window.addEventListener("resize", () => {
  camera.aspect = window.innerWidth / window.innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(window.innerWidth, window.innerHeight);
});
```

The resize handler matters more on phones than on desktop: rotating the device fires it, and forgetting `updateProjectionMatrix()` is the classic stretched-scene bug.

### A floor and a player

The game world is deliberately minimal: a flat ground plane (rotated so it lies horizontally) and a box for the player. Two lights — one hemisphere, one directional — are the cheapest setup that makes `MeshStandardMaterial` look three-dimensional.

```ts
const ground = new THREE.Mesh(
  new THREE.PlaneGeometry(24, 24),
  new THREE.MeshStandardMaterial({ color: 0x23272f })
);
ground.rotation.x = -Math.PI / 2; // lie flat
scene.add(ground);

const player = new THREE.Mesh(
  new THREE.BoxGeometry(1, 1, 1),
  new THREE.MeshStandardMaterial({ color: 0xff8a3c })
);
player.position.y = 0.5; // stand on the floor
scene.add(player);

scene.add(new THREE.HemisphereLight(0xbfd4ff, 0x342a1f, 1.2));
const sun = new THREE.DirectionalLight(0xffffff, 1.5);
sun.position.set(4, 8, 3);
scene.add(sun);
```

## How do you write the game loop and read touch input?

The loop is a `requestAnimationFrame` callback that does three things every frame: measure elapsed time, advance the simulation by that amount, render. `THREE.Clock` gives you the delta in seconds. Always scale movement by delta time — a 120 Hz iPhone and a throttled mid-range Android must play the same game — and clamp it, so returning from a background tab doesn't teleport the player.

```ts
const clock = new THREE.Clock();
const target = new THREE.Vector3(0, 0.5, 0);

function tick() {
  requestAnimationFrame(tick);
  const dt = Math.min(clock.getDelta(), 0.1); // clamp tab-switch spikes

  player.position.lerp(target, Math.min(1, dt * 6));
  renderer.render(scene, camera);
}
tick();
```

For input, use **pointer events** — one `pointerdown` listener covers mouse, touch, and stylus, so you never write separate mobile code. A `Raycaster` converts the tap position into a point on the ground plane, which becomes the player's move target:

```ts
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();

renderer.domElement.addEventListener("pointerdown", (event) => {
  pointer.x = (event.clientX / window.innerWidth) * 2 - 1;
  pointer.y = -(event.clientY / window.innerHeight) * 2 + 1;

  raycaster.setFromCamera(pointer, camera);
  const hit = raycaster.intersectObject(ground)[0];
  if (hit) target.set(hit.point.x, 0.5, hit.point.z);
});
```

That's the entire game skeleton: tap anywhere, the cube glides there, at any frame rate, on any device. Everything else — enemies, score, levels — is ordinary TypeScript layered on this loop.

## What makes a Three.js game fast (or slow) on phones?

Phone GPUs are strong but thermally limited: a scene that benchmarks fine for two minutes can throttle by minute ten. The habits that keep you at a steady 60 fps:

- **Cap the pixel ratio.** Already done above — it is the single biggest lever. Rendering at DPR 3 shades more than twice the pixels of DPR 2 for no visible gain.
- **Keep draw calls low.** Every mesh is a draw call. Merge static level geometry into one mesh, and use `InstancedMesh` for repeated objects (coins, trees, bullets). A mobile scene budget lives in the low hundreds of calls, not thousands.
- **Prefer baked lighting.** One hemisphere plus one directional light is plenty. Real-time shadow maps and per-pixel point lights are the first things to cut; bake shadows and ambient occlusion into textures instead.
- **Pool your objects.** Never allocate inside `tick()` — a `new THREE.Vector3()` per frame means garbage-collector hitches. Reuse scratch vectors and recycle bullets and enemies from a pool instead of creating and destroying them.
- **Watch transparent overdraw.** Large overlapping transparent sprites re-shade the same pixels layer after layer. This is the usual killer of naive particle systems — more on that below.

- **Treat allocation as a bug, not a cleanup.** No `.map`/`.filter`/spread in per-frame code, no helpers returning fresh objects (mutate caller-owned outputs), no string building every frame — GC pauses, not rendering, cause most visible hitches.
- **Prewarm and bound your pools.** Preallocate to the realistic peak while loading, overwrite every field on acquire, release back instead of dropping to the GC, and toggle `visible` on pooled meshes instead of adding/removing scene objects per frame.

The phone budget in one sentence: **capped pixel ratio, draw calls in the low hundreds, no dynamic shadows, zero allocations inside the game loop.** The long version is the [HTML5 game performance guide](https://nixiefx.com/html5-game-performance/).

## How do you add real particle effects with NixieFX?

A tap-to-move cube works; a tap-to-move cube with a burst of sparks at the destination feels like a game. You could hand-roll particles with `THREE.Points` and shaders, but iterating on feel in code is slow. The faster path is a particle editor: [the NixieFX editor](https://nixiefx.com/editor/) is free, runs entirely in the browser, and saves every effect as plain JSON inside your project folder — versionable, diffable, agent-readable.

The workflow has a deliberate boundary: you author in the editor's project workspace, then export. The export writes an `out/vfx` bundle — a `manifest.json`, one JSON file per effect, and any textures the effects use — and that bundle, not the editable project, is what your game loads. Design a quick burst effect (a one-shot emitter, 20–40 small additive sparks, short lifetime), export it, and serve the bundle as static files. Then install the runtime next to Three.js:

```sh
npm install nixie-fx three
```

Loading the bundle is two steps: fetch the manifest and effect JSON, then hand them to the official loader, which verifies hashes, validation, and — importantly — that each effect actually supports the Three.js backend:

```ts
import { ThreeVfxRenderer } from "nixie-fx/three";
import { loadVfxExportBundle, parseVfxExportManifest } from "nixie-fx/export";

const root = "/vfx"; // wherever you serve the exported out/vfx folder
const rawManifest = await (await fetch(`${root}/manifest.json`)).json();
const manifest = parseVfxExportManifest(rawManifest);

const effectsByPath = Object.fromEntries(
  await Promise.all(
    manifest.effects.map(async ({ path }) => [
      path,
      await (await fetch(`${root}/${path}`)).json(),
    ])
  )
);

const bundle = loadVfxExportBundle(
  { manifest: rawManifest, effectsByPath },
  { requiredBackend: "three3d" }
);
```

The runtime never fetches assets on its own — your game owns its asset pipeline, and you hand the renderer a small **texture provider**. Preload the effect's textures once up front; `getTexture` is called during rendering and must be synchronous:

```ts
const textureCache = new Map<string, THREE.Texture>();
const textureLoader = new THREE.TextureLoader();

const textureProvider = {
  async preload(refs) {
    await Promise.all(
      refs.map(async (ref) => {
        textureCache.set(ref.path, await textureLoader.loadAsync(`${root}/${ref.path}`));
      })
    );
  },
  getTexture(ref) {
    return textureCache.get(ref.path);
  },
};

const vfx = new ThreeVfxRenderer({ scene, camera, textureProvider });

const effect = bundle.effectsById.get("tap-burst");
if (!effect) throw new Error("Missing tap-burst effect");
await textureProvider.preload(
  effect.assets.filter((asset) => asset.type === "texture")
);
```

Wiring it into the game is two lines. In the `pointerdown` handler, spawn a burst at the tap point; in the loop, advance the simulation once per frame (in seconds, like everything else):

```ts
// inside the pointerdown handler, after the raycast hit:
vfx.createEffect(effect, { position: [hit.point.x, 0, hit.point.z] });

// inside tick(), next to renderer.render:
vfx.update(dt);
```

When the scene is torn down, call `vfx.destroy()` and release your cached textures. Two habits worth keeping from day one: pass a `seed` to `createEffect` when you need deterministic playback, and check the manifest's backend support report — an effect marked blocked or partial for `three3d` is telling you something, not decoration.

## Which agent skills help with Three.js game development?

If an AI coding agent is part of your workflow, teach it this stack instead of letting it guess. NixieFX ships two official [agent skills](https://nixiefx.com/skills/) — one for authoring and exporting effects, one for runtime integration like the code above. One command installs both for Claude Code, Cursor, Codex, Grok, Copilot, Gemini CLI and 40+ other agents:

```sh
npx skills add https://github.com/azakhary/nixie-fx
```

Three.js itself has no official skills yet. Community packs exist — start with the [awesome-gamedev-agent-skills](https://github.com/gamedev-skills/awesome-gamedev-agent-skills) directory — and it is worth writing a small internal skill of your own that encodes your game's conventions: the pixel-ratio cap, the draw-call budget, how scenes and pools are structured. An agent with those rules produces mobile-safe code on the first try.

## Frequently asked questions

### Is Three.js good for mobile games?

Yes. WebGL 2 runs on effectively every phone sold in the last five years, and Three.js is a thin, fast layer over it — there is no engine overhead you didn't write yourself. Mobile performance comes down to your own discipline: cap the pixel ratio, keep draw calls low, and watch transparent overdraw. Casual and mid-core 3D scenes hold 60 fps on mid-range phones.

### Do I need a game engine to make a 3D web game?

No. A renderer (Three.js), a bundler (Vite), and npm packages for the pieces you actually need — physics, audio, particles — replace the monolithic engine. Everything stays plain TypeScript files that an AI coding agent can read and refactor, and you ship to any browser without an export step.

### How do I add particle effects to a Three.js scene?

Two routes: hand-code them with `THREE.Points` and custom shaders, or author them visually in a particle editor and play them through a runtime. The NixieFX editor is free and browser-based; effects are JSON files in your project, exported to an `out/vfx` bundle that the `nixie-fx` Three.js runtime (`ThreeVfxRenderer`) loads, spawns into your scene, and advances once per frame.

### How many particles can a phone handle in Three.js?

Fill rate, not particle count, is usually the wall. Thousands of small particles are fine; a few dozen huge overlapping transparent sprites are not, because every layer of overdraw re-shades the same pixels. Keep particles small or short-lived, cap emitter counts, and profile on a real mid-range phone rather than a desktop GPU.

### Is the NixieFX editor free?

Yes. It runs entirely in the browser at [nixiefx.com/editor/](https://nixiefx.com/editor/) and saves effects as plain JSON in your project folder. The runtime that plays them — `nixie-fx` on npm — is MIT-licensed open source, with adapters for both Three.js and PixiJS.

## Resources

- [Three.js](https://threejs.org) — docs, manual, and examples
- [Three.js source](https://github.com/mrdoob/three.js) — github.com/mrdoob/three.js
- [nixie-fx](https://github.com/azakhary/nixie-fx) — runtime + agent skills
- [NixieFX LLM Skills](https://nixiefx.com/skills/) — install the authoring and runtime skills
- [The web-native mobile game stack](https://nixiefx.com/mobile-game-stack/) — where Three.js + NixieFX fit in a full F2P stack

*This tutorial is maintained by the team behind [NixieFX](https://nixiefx.com/), the browser-based HTML5 particle editor for PixiJS and Three.js. A machine-readable version of the HTML page is this file; a site index for LLMs is at [/llms.txt](https://nixiefx.com/llms.txt).*
