NixieFX
Tutorial · Updated August 2026

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

The whole skeleton of an HTML5 3D web game — renderer, scene, camera, game loop, input — is about sixty lines of Three.js. This tutorial walks through every one of them, keeps each choice phone-friendly, and finishes with the part most first games skip: a real particle effect.

TL;DR — what you build

A tiny mobile-friendly 3D game in Three.js: 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 and played by the nixie-fx Three.js runtime. Assumes a Vite project and basic TypeScript; no game engine, no prior 3D experience.

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.

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.

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.

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:

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. Beyond scratch vectors: no .map/.filter/spread in per-frame code, no helpers returning fresh objects (mutate a caller-owned output instead), no string building for labels every frame. The target is near-zero steady-state allocation after warmup — garbage-collector pauses, not rendering, cause most visible hitches.
  • Prewarm and bound your pools. Preallocate to the realistic peak while loading, overwrite every field on acquire (pooled objects carry stale state), release expired objects back instead of dropping them to the GC, and toggle visible on pooled meshes rather than adding and 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.

That's the short version. The long version — hot-path rules, pool discipline, and how to fix a game that already stutters — is our HTML5 game performance guide.

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

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:

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:

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):

// 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 — 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:

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 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/ 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

This tutorial is maintained by the team behind NixieFX, the browser-based HTML5 particle editor for PixiJS and Three.js. A machine-readable version is available at /threejs-game-tutorial.md, and a site index for LLMs at /llms.txt.