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