Anup Shinde
JavaScript

How to Build the Foundation of a 3D Game in JavaScript

September 10, 2023 6 min read

How to build the core of a 3D game in the browser: Three.js, cannon-es, keyboard controls, physics - the ~100 lines everything else builds on top of.

Illustrated title graphic for 'How to Build the Foundation of a 3D Game in JavaScript', with a low-poly 3D game scene
3d Game Foundation - Part 1 of 2

A few years ago, I tried to build a 3D game in the browser. I expected it to be painful - WebGL is notoriously low-level, and the last time I’d touched anything 3D was modifying Quake 3 source code in C. But Three.js changed that equation entirely.

I went from a blank HTML file to a movable player in a lit 3D environment over a weekend. Not production-quality, not polished - but playable. Here’s how.

Why Three.js

You could write raw WebGL. I wouldn’t recommend it unless you enjoy manually managing shader programs and vertex buffers. Three.js abstracts that away and gives you a scene graph, a camera system, materials, lights, and a render loop - the stuff you actually want to think about when making a game.

There are other options (Babylon.js is excellent, PlayCanvas has a full editor), but Three.js hits a sweet spot: lightweight enough to understand, powerful enough to ship real things.

The Minimum Viable Scene

Every 3D game starts with the same three things: a scene, a camera, and a renderer. This is the skeleton everything hangs on.

import * as THREE from 'three';

const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
const renderer = new THREE.WebGLRenderer({ antialias: true });

renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);

camera.position.set(0, 5, 10);
camera.lookAt(0, 0, 0);

That’s it. You now have a black canvas staring at the origin. Not exciting yet, but everything builds from here.

Adding a Ground Plane and a Box

The first thing I always do is add a ground plane. Without it, objects float in void and you lose all spatial reference.

// Ground
const groundGeo = new THREE.PlaneGeometry(50, 50);
const groundMat = new THREE.MeshStandardMaterial({ color: 0x228833 });
const ground = new THREE.Mesh(groundGeo, groundMat);
ground.rotation.x = -Math.PI / 2;
scene.add(ground);

// A box to represent the player
const boxGeo = new THREE.BoxGeometry(1, 2, 1);
const boxMat = new THREE.MeshStandardMaterial({ color: 0xff2f6d });
const player = new THREE.Mesh(boxGeo, boxMat);
player.position.y = 1; // sit on top of the ground
scene.add(player);

Still dark though. Without lights, MeshStandardMaterial renders black.

Lighting Makes Everything

This is where most first attempts stall. You add objects, see nothing, and wonder what’s broken. The answer is almost always: you forgot lights.

// Ambient light so nothing is pure black
const ambient = new THREE.AmbientLight(0xffffff, 0.4);
scene.add(ambient);

// Directional light for shadows and depth
const sun = new THREE.DirectionalLight(0xffffff, 0.8);
sun.position.set(10, 20, 10);
scene.add(sun);

Two lights. That’s the minimum I’d use for any scene. The ambient prevents pitch-black shadows; the directional gives you depth and form. You can get fancy with point lights, spotlights, and shadow maps later - but these two get you 80% of the way.

Stage 1 - static scene

The Render Loop

Three.js doesn’t render continuously by default. You need an animation loop:

function animate() {
  requestAnimationFrame(animate);
  renderer.render(scene, camera);
}

animate();

Now you should see a green plane, a reddish box, and actual lighting. This is your playground.

Keyboard Controls

A game needs input. Here’s a simple approach - track which keys are held down and move the player each frame:

const keys = {};

window.addEventListener('keydown', (e) => { keys[e.code] = true; });
window.addEventListener('keyup', (e) => { keys[e.code] = false; });

const speed = 0.15;

function updatePlayer() {
  if (keys['KeyW']) player.position.z -= speed;
  if (keys['KeyS']) player.position.z += speed;
  if (keys['KeyA']) player.position.x -= speed;
  if (keys['KeyD']) player.position.x += speed;
}

Call updatePlayer() inside your animate() function. The box now moves with WASD.

This is intentionally simple. No velocity, no acceleration, no friction. Those come next, but I’ve learned the hard way that adding physics before you have basic movement working just creates confusion.

Making the Camera Follow

A fixed camera is useless once the player moves away. The simplest third-person camera:

function updateCamera() {
  camera.position.x = player.position.x;
  camera.position.z = player.position.z + 10;
  camera.position.y = 5;
  camera.lookAt(player.position);
}

Again, intentionally naive. A proper camera would use lerping for smooth follow, collision detection to avoid clipping through walls, and maybe orbit controls for mouse look. But this works for now, and “works for now” is how you make progress.

Stage 2 - keyboard controls

Adding Physics with Cannon.js

At some point, you want gravity, collisions, and objects that bounce. Three.js doesn’t do physics - it’s a renderer. You need a separate physics engine.

I used Cannon-es (a maintained fork of Cannon.js). The pattern is straightforward: create a physics world that mirrors your Three.js scene, step the simulation each frame, and copy positions back.

import * as CANNON from 'cannon-es';

const world = new CANNON.World({ gravity: new CANNON.Vec3(0, -9.82, 0) });

// Physics body for the ground
const groundBody = new CANNON.Body({
  type: CANNON.Body.STATIC,
  shape: new CANNON.Plane()
});
groundBody.quaternion.setFromEuler(-Math.PI / 2, 0, 0);
world.addBody(groundBody);

// Physics body for the player
const playerBody = new CANNON.Body({
  mass: 5,
  shape: new CANNON.Box(new CANNON.Vec3(0.5, 1, 0.5)),
  position: new CANNON.Vec3(0, 5, 0)
});
world.addBody(playerBody);

Then in your animation loop:

function animate() {
  requestAnimationFrame(animate);

  world.step(1 / 60);

  // Sync Three.js mesh with physics body
  player.position.copy(playerBody.position);
  player.quaternion.copy(playerBody.quaternion);

  updatePlayer();
  updateCamera();
  renderer.render(scene, camera);
}

Drop the player from height 5. It falls, hits the ground, and stops. Physics.

Where It Gets Interesting

Once you have movement, camera, and physics, you’re past the hard part. Everything from here is additive:

  • More geometry: Load .glb models with Three.js GLTFLoader instead of primitive boxes
  • Textures: Apply images to materials for realistic surfaces
  • Sound: The Web Audio API or Howler.js for spatial audio
  • Enemies/NPCs: More physics bodies with simple AI (state machines work surprisingly well)
  • UI overlay: Plain HTML/CSS on top of the canvas - don’t fight WebGL for UI

Each of these deserves its own post. The point here is that the foundation - scene, camera, renderer, physics, controls - is maybe 100 lines of JavaScript. The rest is building on top.

What I Wish I Knew Earlier

Start with boxes. Seriously. Don’t spend your first day importing 3D models and setting up animation rigs. Get a box moving around a plane. Make it feel right. Then replace the box with a model.

Separate rendering from logic. Three.js handles how things look. Cannon (or Rapier, or Ammo.js) handles how things behave. Your game logic should talk to the physics engine, and the renderer should just display whatever the physics says is true. Mixing these is how you get bugs that are impossible to reproduce.

Performance will surprise you. Modern browsers can handle thousands of objects with decent materials and lighting. The bottleneck is usually draw calls, not polygon count. Instancing and geometry merging matter more than reducing triangle counts.

Use requestAnimationFrame, not setInterval. This seems obvious now, but I’ve seen it in tutorials. setInterval doesn’t sync with the display refresh rate, and you’ll get tearing and inconsistent frame times.

The Full Loop

Here’s what the complete animation function looks like:

function animate() {
  requestAnimationFrame(animate);

  world.step(1 / 60);

  player.position.copy(playerBody.position);
  player.quaternion.copy(playerBody.quaternion);

  updatePlayer();
  updateCamera();

  renderer.render(scene, camera);
}

animate();

About 10 lines that tie everything together. The scene renders, physics simulates, the player moves, and the camera follows. From here, you’re extending the foundation - not fighting a framework.

Stage 3 - with physics

The browser is a surprisingly capable game platform. It won’t replace Unity or Unreal for AAA titles, but for prototyping, web-native games, and creative experiments? It’s more than enough. And you don’t need to install anything to get started.