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
InstancedMeshfor 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()— anew 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
visibleon 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
- Three.js threejs.org — docs, manual, and examples
- Three.js source github.com/mrdoob/three.js
- nixie-fx runtime + agent skills: github.com/azakhary/nixie-fx
- NixieFX LLM Skills install the authoring and runtime skills
- The web-native mobile game stack where Three.js + NixieFX fit in a full F2P stack
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.