Threenity / Learn / Make a 3D game with Three.js
Make a 3D game with Three.js, step by step
Three.js is the most direct way to put real-time 3D in a browser — and it's genuinely enough to build a game with. This tutorial walks the raw-Three.js path first: scene, game loop, input, collisions, deploy. At the end, we show the same game with an engine layer on top, so you can judge the difference yourself.
1 · Set up the project (Vite + TypeScript)
A starter project with a dev server and hot reload saves you from refreshing and re-bundling by hand:
npm create vite@latest my-game -- --template vanilla-ts cd my-game npm install three @types/three npm run dev
Add a full-screen <canvas> to index.html and
you have a home for the renderer.
2 · Scene, camera, renderer
The Three.js trinity — a scene graph to hold objects, a camera, and a WebGL renderer:
import * as THREE from "three";
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(70, innerWidth / innerHeight, 0.1, 100);
camera.position.set(0, 2, 5);
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.setSize(innerWidth, innerHeight);
const player = new THREE.Mesh(
new THREE.BoxGeometry(1, 1, 1),
new THREE.MeshStandardMaterial({ color: 0x38e08a }),
);
scene.add(player);
3 · The game loop and delta time
Games are driven by a loop: read input, update the world, render a frame. Use
requestAnimationFrame and always scale movement by
delta time, so the game runs at the same speed on a 60 Hz
laptop and a 144 Hz monitor:
let last = performance.now();
function tick(now: number) {
const dt = Math.min((now - last) / 1000, 0.1); // clamp big pauses
last = now;
update(dt); // move things
renderer.render(scene, camera); // draw things
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
4 · Input handling
Track pressed keys in a set, then read it inside update — never
move objects directly in event handlers, or movement speed becomes tied to
key-repeat rate:
const keys = new Set<string>();
addEventListener("keydown", (e) => keys.add(e.code));
addEventListener("keyup", (e) => keys.delete(e.code));
function update(dt: number) {
const speed = 4; // units per second
if (keys.has("KeyW")) player.position.z -= speed * dt;
if (keys.has("KeyS")) player.position.z += speed * dt;
if (keys.has("KeyA")) player.position.x -= speed * dt;
if (keys.has("KeyD")) player.position.x += speed * dt;
}
5 · Lights, shadows, a skybox
MeshStandardMaterial needs light. A directional light plus a soft
ambient is the classic outdoor setup; enable shadow maps on the renderer and
per object. For a skybox, a large inverted sphere or a cube texture on
scene.background both work.
const sun = new THREE.DirectionalLight(0xffffff, 2.5); sun.position.set(5, 10, 4); sun.castShadow = true; scene.add(sun, new THREE.AmbientLight(0xffffff, 0.4)); renderer.shadowMap.enabled = true; player.castShadow = true;
6 · Loading glTF models
Real games use authored assets. glTF is the standard interchange format for the web; Three.js ships a loader:
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
const gltf = await new GLTFLoader().loadAsync("/models/enemy.glb");
scene.add(gltf.scene);
7 · Collision detection
Three.js renders; it doesn't simulate. The simplest collision test that ships real games is a bounding-box or sphere-distance check each frame:
const a = new THREE.Box3().setFromObject(player); const b = new THREE.Box3().setFromObject(enemy); if (a.intersectsBox(b)) onHit();
For anything beyond pickups and walls — slopes, stacking, character movement that doesn't tunnel through geometry — you'll want a physics library (Rapier and cannon-es are the usual choices) and a fixed timestep. This is the first place raw Three.js projects start growing engine-shaped code.
8 · Spawning and destroying objects
Add enemies with scene.add, remove them with
scene.remove — and remember to dispose geometries, materials, and
textures you created, or the GPU memory stays allocated. Keeping a list of
live entities with an update(dt) method per entity is the natural
next refactor... at which point you have invented components.
9 · Deploy to the web
npm run build produces a static dist/ folder. Host it
anywhere static files live — GitHub Pages, Netlify, itch.io (zip the folder and
upload as an HTML game). No plugin, no install for your players: that's the
whole appeal of shipping 3D games on the web.
The same game, with an engine on top
Everything above is honest, necessary work — and none of it is your game. The loop, input mapping, collision plumbing, asset wiring, and scene management are the same in every project. An engine's job is to own that layer. In Threenity, the player movement from steps 3–4 is one component; the scene from step 2 is a JSON file you author in a visual editor; physics is the Rapier pack.
import { Component, type GameContext } from "@threenity/components-sdk";
export class PlayerMove extends Component {
static override readonly schema = {
speed: { type: "number", default: 4 },
};
speed = 4;
override onUpdate(ctx: GameContext, dt: number) {
// WASD / gamepad, pre-mapped by the input system
this.object.position.x += ctx.input.axis("horizontal") * this.speed * dt;
this.object.position.z -= ctx.input.axis("vertical") * this.speed * dt;
}
}
The game loop, delta time, input mapping, scene loading, and rendering are the
engine's; the ten lines that remain are gameplay. Your Three.js knowledge
transfers untouched — the world underneath is still Object3Ds.
Honest caveat: Threenity is in pre-release (waitlist). If you need to ship this week, the raw path above works today — that's why we taught it first.
Next steps
- Getting started with Threenity — what setup looks like.
- How the engine is architected — GameObjects, components, packs.
- Threenity homepage — everything in the engine today.