How it works
Each step is a single HTML file. Open it in your browser and you will see the finished result. Your job is to recreate it yourself from scratch - not to copy the file, but to read the code, understand what it does, and write your own version.
Use the reference files to check your work, look up syntax, or get unstuck. The hints in this page tell you what to build without giving it all away.
Before you start - colours
Throughout these steps you will see colours written as values like
0x00aa44 or 0x87ceeb. These are called hex colour codes,
and they work exactly like the colour codes used in web design - the only difference
is that web design writes them with a # at the front (#00aa44)
while JavaScript writes them with 0x (0x00aa44). They mean
the same thing.
You do not need to memorise these or work them out manually. Use a colour
picker: go to g.co/color/picker,
pick any colour you like, and copy the hex code shown. Just replace the #
with 0x when you use it in your code.
Three.js also accepts plain colour names for common colours. Instead of
0x00aa44 you can write 'green', and instead of
0xcc2222 you can write 'red'. This works for:
'red', 'green', 'blue', 'yellow',
'white', 'black', 'orange', 'purple',
and other common names. For a specific shade, use the colour picker.
Before you start - coordinates
Three.js places everything in a 3D world using three axes:
Positive X is to the right.
Positive Y is upward.
Negative Z is forward (into the screen).
Positive Z is toward you.
So camera.position.z = 5 puts the camera 5 units toward you,
looking at whatever is in front of it. And translateZ(-speed * dt) moves a
tank forward because it moves in the negative Z direction.
Positions are always written as three numbers in order: (x, y, z).
position.set(0, 40, 80) means: centred left-right, 40 units up,
80 units toward the camera.
Want a visual? See mathsisfun.com - Cartesian Coordinates and scroll to the 3D section.
Before you start - radians
Rotations in Three.js use radians rather than degrees. You don't need to understand the maths behind them - just use this table as a reference:
| Degrees | Radians (what to write in code) |
|---|---|
| 360 | Math.PI * 2 |
| 180 | Math.PI |
| 90 | Math.PI / 2 |
| 45 | Math.PI / 4 |
| -90 | -Math.PI / 2 |
So terrainGeo.rotateX(-Math.PI / 2) means "rotate 90 degrees
around the X axis" - which tips the plane from vertical (its default) to flat on the ground.
Visual explanation: mathsisfun.com - Radians.
Before you start - experiment
The best way to understand what any value does is to change it and reload
the page. Every number in these files is adjustable. There is no magic to values like
1000 for the terrain size or 5 for the camera distance - they
were just chosen because they looked good.
Try making the terrain bigger, the camera higher, the tank faster, or the fog closer. If something breaks, Ctrl+Z will undo your change, or open the reference file to find the original value.
The browser console (F12 - Console tab) shows errors in plain English. If something stops working, that is the first place to look.
The five steps
- Open
starter.htmland save it with a new name - for examplestep1.html. This gives you the HTML structure and importmap already set up so you can focus on the Three.js code. - Write a
<script type="module">that creates a Scene, a Camera, and a Renderer - Add a BoxGeometry cube to the scene with a green MeshBasicMaterial
- Write an
animate()function that rotates the cube and callsrenderer.render() - Call
requestAnimationFrame(animate)insideanimateto loop it
camera.position.z = 5 so it's not inside the cube. The Renderer needs document.body.appendChild(renderer.domElement) to appear on screen.Your cube should look different from the reference. Change the colour, the size (the numbers in BoxGeometry), the rotation speed, or the rotation axis. Try rotating on Y instead of X, or on both axes at once.
- Remove the cube
- Add a large PlaneGeometry (try 1000 x 1000) - remember to rotate it flat with
terrainGeo.rotateX(-Math.PI / 2) - Add a GridHelper on top of it (raise it slightly with
grid.position.y = 0.01) - Set
scene.backgroundto a sky blue colour - Add
scene.fog = new THREE.Fog(skyColour, 100, 450)to hide the horizon - Import OrbitControls from
three/addons/controls/OrbitControls.jsand add them so you can click-drag to look around
Change the sky colour, the terrain size, the fog distance, or the grid colour. A closer fog makes the world feel small and enclosed; a further one makes it feel vast.
- Remove OrbitControls
- Write a
createTank(bodyColour, turretColour)function that returns aTHREE.Groupcontaining three boxes: hull, turret box, and barrel (the barrel should be positioned atz = -1.2inside its own sub-group, the turretGroup) - Add keyboard input: listen for
keydownandkeyup, storing state in akeys = {}object - In your animate loop, use
tank.translateZ(-speed * dt)for W/S andtank.rotation.y += turnSpeed * dtfor A/D - Get delta time with
const clock = new THREE.Clock()andclock.getDelta()each frame - Move the camera behind the tank each frame using
camOffset.applyEuler(tank.rotation)
translateZ moves along the object's OWN local axis, so it always goes "forward" relative to where the tank is pointing. That's what makes it useful here.Change the tank colours, the hull and turret proportions, the movement speed, the turn speed, and the camera distance behind the tank.
- Add Q/E to rotate
tank.turretGroup.rotation.y - Fire a shell on Space: create a SphereGeometry, set its position using
tank.turretGroup.localToWorld(new THREE.Vector3(0, 0.05, -2.0)), and setmesh.rotation.y = tank.rotation.y + tank.turretGroup.rotation.y - Keep an array of active shells; move each one with
translateZ(-speed * dt)each frame - Add a red BoxGeometry target somewhere on the terrain
- Use
new THREE.Box3().setFromObject(target)andbox.containsPoint(shell.position)to detect hits - On hit: flash the target, increment a score counter in the DOM, respawn the target
- When a shell expires or hits, call
scene.remove(mesh)andmesh.geometry.dispose()
for (let i = shells.length - 1; i >= 0; i--). Removing from a forwards loop skips the item after the removed one.Change the turret rotation speed, the shell speed, the shell size, and the target colour and size. Try making the target move to a new position after each hit.
- Call
createTank()twice with different colours (e.g. blue and red) - Define a player object for each:
{ tank, hp, maxHp, barEl, keys: { forward, backward, ... } } - Write a single
updatePlayer(player, dt)function that reads fromplayer.keysand movesplayer.tank- then call it for both players each frame - Each shell needs an
ownerproperty so it only checks collision against the opposing tank - Add two
<div>health bars to the HTML; update theirstyle.widthon hit - Add a
gameOverflag; when hp reaches 0, show a win banner and stop updating tanks until R is pressed - Write a
resetGame()function that sets both tanks back to their start positions and resets all state - Replace the single-tank follow camera with one that watches the midpoint between both tanks and pulls back based on their separation distance
new THREE.Vector3().addVectors(p1.tank.position, p2.tank.position).multiplyScalar(0.5)Change the player colours, the starting positions, the number of hits to win, and the health bar colours. No two students' finished games should look identical - that's how you know it worked.
What's next?
Once you have finished Step 5, here are ideas to keep going:
- Add terrain height variation (look at
SimplexNoise) - Add a third player (can you adapt
updatePlayerto handle three?) - Replace the boxes with a more detailed tank model
- Add a reload timer so you can't fire continuously
- Add a boundary so tanks can't drive off the edge
The full Treads of War source code is on GitHub if you want to see where all these ideas eventually lead.