Build a 3D F1 Racing Game

Build a browser-based 3D F1 racing game with Three.js and keyboard controls.

Introduction

30 Second Summary

Racing games are one of the most satisfying types of games to play. Building one from scratch teaches you how 3D graphics actually work in the browser.

In this project, you will build a 3D F1 racing game using Three.js. You'll load a car model, create a track with barriers, wire up keyboard controls for acceleration and steering, and implement a smooth chase camera that follows your car through turns.

What You'll Build

A browser-based 3D racing game where you drive an F1 car around a circular track using WASD keyboard controls, complete with directional lighting, real-time shadows, and a speed HUD overlay.

By the end of this project, you'll have:

  • A Vite project with Three.js rendering a full 3D scene including a ground plane, circular track, and trackside barriers.
  • A loaded GLB car model with keyboard-controlled acceleration, braking, and steering physics.
  • A smooth lerp-based chase camera that follows behind the car through turns, creating a cinematic racing feel.
  • Secret Mission: a lap timer that tracks your best lap time around the circuit.

Are there any prerequisites?

You should be comfortable with JavaScript, specifically ES6 modules, DOM manipulation, and event listeners. No prior experience with 3D graphics or Three.js is needed.

Set Up Your Three.js Project

Every 3D game needs a solid foundation before you can start placing cars on tracks and cameras in the sky. In this step, you will create the project folder, install the tools that power your game, and set up the files that make it all run in a browser.

You will use Three.js as your 3D rendering engine and Vite as your local development server. By the end of this step, you will have a running dev server and a 3D car model ready to load into your scene.

In this step, get ready to:
  • Create and initialize a new project with Three.js and Vite installed.
  • Build the HTML, CSS, and JavaScript entry files for your game.
  • Download a low-poly F1 car model to use as your player vehicle.
Create and initialize the project

Your project needs a dedicated folder with npm package management so you can install Three.js and Vite as dependencies.

  • Open your terminal by pressing Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or Command Prompt (Windows) and press Enter to open it.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir f1-racing-game
cd f1-racing-game
  • Initialize npm in the project by running:
npm init -y

What does this command do?

The -y flag tells npm to accept all default values and instantly generate a package.json file. This file tracks your project's dependencies and scripts.

  • Install Three.js and Vite by running these commands:
npm install --save three@0.184.0
npm install --save-dev vite@8.0.16

What do these packages do?

Three.js (three@0.184.0) is the 3D rendering library that handles scenes, cameras, lights, and models in your browser.

Vite (vite@8.0.16) is a fast development server that automatically refreshes your browser when you change code. It also resolves JavaScript module imports for you.

  • Confirm the installs completed by running:
ls
  • You should see node_modules, package.json, and package-lock.json in the output.

Command not found?

If you see an error about npm not being recognized, Node.js may not be installed or not in your PATH. Revisit your Node.js installation and make sure you can run node --version successfully before continuing.

Need help debugging? Help me fix npm command not found

Now you need to tell Vite how to start your development server. You will add a script to package.json so you can launch the server with a single command.

  • Open VS Code by pressing Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type VS Code and press Enter to open it.
  • Click File in the top menu bar.
  • Click Open Folder.
  • Select the f1-racing-game folder on your Desktop, then click Open.
  • In the VS Code Explorer sidebar on the left, click package.json to open it.
  • Find this line inside the "scripts" section:
"scripts": {
  "test": "echo \"Error: no test specified\" && exit 1"
}
  • Add a "dev" script above the test line, so the section now looks like this:
"scripts": {
  "dev": "vite",
  "test": "echo \"Error: no test specified\" && exit 1"
}

What does this script do?

Adding "dev": "vite" lets you start Vite's development server by running npm run dev in your terminal. Vite will serve your game files and automatically reload the browser whenever you save a change.

  • Save package.json by pressing Cmd+S (macOS) or Ctrl+S (Windows).
Set up the HTML, CSS, and entry point

Your game needs three files to run: an HTML page that holds the canvas and HUD, a CSS stylesheet for full-screen rendering, and a JavaScript entry point where Three.js code will live.

  • In the VS Code Explorer sidebar, hover over the F1-RACING-GAME section header to reveal the icon buttons.
  • Click the New File icon (a page with a plus sign).
  • Type index.html and press Enter.
  • Paste the following code into index.html:
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>F1 Racing Game</title>
  <link rel="stylesheet" href="style.css">
</head>
<body>
  <div id="hud">
    <div id="speed">0 km/h</div>
  </div>
  <script type="module" src="main.js"></script>
</body>
</html>

What does this HTML do?

The <div id="hud"> creates a heads-up display overlay that will show the car's speed on top of the 3D scene.

The <script type="module"> tag is required for Three.js to work. The type="module" attribute enables ES6 module imports, which is how Three.js loads its components.

  • Save index.html.

File not appearing in the sidebar?

Make sure you clicked the New File icon at the top level of the Explorer, not inside node_modules. The file should sit at the same level as package.json.

Still stuck? Help me create a file in VS Code

  • Create another new file using the same New File icon.
  • Name it style.css and press Enter.
  • Paste the following code into style.css:
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  overflow: hidden;
  background: #000;
}

canvas {
  display: block;
}

#hud {
  position: absolute;
  bottom: 30px;
  left: 50%;
  transform: translateX(-50%);
  color: #fff;
  font-family: 'Courier New', monospace;
  font-size: 24px;
  text-shadow: 0 0 10px rgba(0, 255, 0, 0.8);
  pointer-events: none;
  z-index: 10;
}

#speed {
  background: rgba(0, 0, 0, 0.6);
  padding: 10px 20px;
  border-radius: 8px;
  border: 1px solid rgba(0, 255, 0, 0.3);
}

What do these styles do?

The universal selector (*) removes all default margins and padding so the 3D canvas fills the entire browser window.

The overflow: hidden on the body prevents scrollbars from appearing when Three.js renders the canvas. The #hud styles position the speed display at the bottom center of the screen with a glowing green game-style font.

  • Save style.css.
  • Create one more new file and name it main.js.
  • Leave main.js empty for now and save it. You will add Three.js code here in the next step.

Now verify everything works by starting the development server.

  • Open the VS Code integrated terminal by pressing Ctrl+` (backtick key, same shortcut on macOS and Windows).
  • Start the dev server by running:
npm run dev
  • Open your browser and navigate to http://localhost:5173.

You should see a completely black page with a small green-bordered speed display showing 0 km/h at the bottom center. This confirms Vite is serving your files correctly.

Seeing a blank white page or an error?

If the page is white instead of black, double check that style.css is saved and that your index.html has <link rel="stylesheet" href="style.css"> in the <head>.

If the terminal shows an error about a missing script, make sure you saved package.json after adding the "dev": "vite" line.

Need more help? Help me get my Vite dev server running

✔️ Awesome, I've got everything!

Great. Make sure all three files (index.html, style.css, main.js) are saved before continuing.

ⓧ I'd like to double check the full code

Here is the complete index.html:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>F1 Racing Game</title>
  <link rel="stylesheet" href="style.css">
</head>
<body>
  <div id="hud">
    <div id="speed">0 km/h</div>
  </div>
  <script type="module" src="main.js"></script>
</body>
</html>

Here is the complete style.css:

* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  overflow: hidden;
  background: #000;
}

canvas {
  display: block;
}

#hud {
  position: absolute;
  bottom: 30px;
  left: 50%;
  transform: translateX(-50%);
  color: #fff;
  font-family: 'Courier New', monospace;
  font-size: 24px;
  text-shadow: 0 0 10px rgba(0, 255, 0, 0.8);
  pointer-events: none;
  z-index: 10;
}

#speed {
  background: rgba(0, 0, 0, 0.6);
  padding: 10px 20px;
  border-radius: 8px;
  border: 1px solid rgba(0, 255, 0, 0.3);
}

And main.js should be an empty file for now.

Download the F1 car model

Three.js supports loading 3D models in glTF format, which is the recommended format for web-based 3D. You will download a free low-poly F1 car model from Sketchfab to use as your player vehicle.

Why a low-poly model?

High-polygon models (300k+ triangles) can cause frame rate drops in browser-based WebGL. A model under 10k triangles runs smoothly at 60fps without any extra optimization work.

  • Open your browser and go to sketchfab.com.
  • Create a free account if you do not have one (required to download models).
  • Search for low poly f1 in the search bar.
  • Filter results by clicking Downloadable on the left sidebar.
  • Look for a low-poly F1 car model with a CC Attribution license (free to use with credit).

Recommended model

Search for "low poly f1" by malik-alqarasinih. It is around 7.8k triangles with a CC Attribution license and a glTF download option.

  • On the model page, click the Download 3D Model button.
  • Select the glTF format option and download the file.
  • Unzip the download if it comes as a .zip file. Look for a file ending in .glb inside.

Now you need to place the model file where Vite can serve it. Vite serves files from a public folder at the root URL path.

  • In your VS Code terminal, create the model directory by running:
mkdir -p public/models
  • Copy or move the downloaded .glb file into the public/models/ folder.
  • Rename the file to car.glb so the code can find it at a consistent path.

Why rename to car.glb?

Downloaded models often have long or random filenames. Renaming to car.glb keeps things simple and matches the path you will use in your Three.js code later (models/car.glb).

  • Confirm the file is in place by running:
ls public/models
  • You should see car.glb in the output.

Don't see car.glb?

Make sure you moved the file into public/models/ inside your f1-racing-game project folder, not somewhere else on your Desktop. The file must be named exactly car.glb (lowercase, no spaces).

Having trouble finding or moving the file? Help me place my 3D model file in the right folder

Your project foundation is solid. Next up, you will bring this black screen to life by creating a 3D scene with a camera, lights, and a race track.

Create the Scene, Camera, and Renderer

Your project folder is ready with Three.js installed and your car model waiting in the wings. Now it's time to bring the 3D world to life.

Every Three.js application needs three core pieces working together: a Scene (the container for all 3D objects), a Camera (what the player sees through), and a Renderer (the engine that draws pixels to the screen). In this step, you'll create all three, add lighting, and build a circular race track with barriers.

In this step, get ready to:
  • Set up the Three.js rendering pipeline with a scene, camera, renderer, and animation loop.
  • Add ambient and directional lighting with shadow support.
  • Build a circular race track with a green ground plane, dark asphalt ring, and colored barriers.
Set up the scene, camera, and renderer

The scene, camera, and renderer form the foundation of every Three.js app. Without all three wired together, nothing appears on screen.

  • In VS Code, open main.js and add the following code to set up the rendering pipeline:
import * as THREE from 'three';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';

// Scene setup
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x87ceeb);
scene.fog = new THREE.Fog(0x87ceeb, 100, 400);

// Camera
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);

// Renderer
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(window.devicePixelRatio);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFShadowMap;
document.body.appendChild(renderer.domElement);

What does this code do?

The two import lines pull Three.js and the GLTFLoader (which you'll use to load the car model in a later step) into your file.

  • The Scene is the container that holds every 3D object, light, and camera. The sky-blue background and fog give the world atmosphere and depth.
  • The PerspectiveCamera mimics how human eyes see. Its 75-degree field of view, 0.1 near plane, and 1000 far plane define what's visible.
  • The WebGLRenderer draws the scene to a canvas element. Enabling shadowMap lets objects cast realistic shadows.

You won't see a change yet because nothing is rendering in a loop. The next code block starts the animation loop.

  • Below the renderer code in main.js, add the window resize handler and animation loop:
// Window resize
window.addEventListener('resize', () => {
  camera.aspect = window.innerWidth / window.innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(window.innerWidth, window.innerHeight);
});

// Animation loop
function animate() {
  renderer.render(scene, camera);
}

renderer.setAnimationLoop(animate);

What does this code do?

The resize listener keeps the 3D view from stretching or squishing when the browser window changes size. It recalculates the camera's aspect ratio and updates the renderer dimensions.

The renderer.setAnimationLoop(animate) call tells Three.js to call your animate function every frame (roughly 60 times per second). Each frame re-renders the scene from the camera's perspective.

  • Save main.js.
  • Check your browser at http://localhost:5173. You should see a solid sky-blue screen filling the entire browser window.

Still seeing a black screen?

  • Make sure renderer.setAnimationLoop(animate) is at the very bottom of the file. Without it, no frames are drawn.
  • Check that document.body.appendChild(renderer.domElement) is present. This attaches the canvas to the page.
  • Confirm your dev server is still running. If it stopped, run npm run dev again in the terminal.

Help me debug my black screen

Add lighting to the scene

A 3D scene without lights is like a room with no windows. Objects exist, but you can't see them.

  • In main.js, find the line that says document.body.appendChild(renderer.domElement); and add the following lighting code below it (above the resize handler):
// Lighting
const ambientLight = new THREE.AmbientLight(0xffffff, 0.4);
scene.add(ambientLight);

const directionalLight = new THREE.DirectionalLight(0xffffff, 1.5);
directionalLight.position.set(50, 100, 50);
directionalLight.castShadow = true;
directionalLight.shadow.mapSize.width = 2048;
directionalLight.shadow.mapSize.height = 2048;
directionalLight.shadow.camera.near = 0.5;
directionalLight.shadow.camera.far = 300;
directionalLight.shadow.camera.left = -100;
directionalLight.shadow.camera.right = 100;
directionalLight.shadow.camera.top = 100;
directionalLight.shadow.camera.bottom = -100;
scene.add(directionalLight);

What does this code do?

Two types of light work together to create realistic illumination:

  • AmbientLight illuminates everything equally from all directions. It prevents objects from being pitch black on their unlit sides.
  • DirectionalLight acts like sunlight. It shines from a specific position and casts shadows. The shadow camera bounds define the area where shadows are calculated.

The 2048x2048 shadow map resolution produces crisp shadow edges without heavy GPU cost.

  • Save main.js.

You won't see a visible change yet because there are no objects for the light to illuminate. The next substep adds geometry to the scene.

Build the race track

A racing game needs a track to drive on. You'll build yours from three layers: a green ground plane, a dark asphalt ring, and colored barriers lining both edges.

  • In main.js, add the ground plane directly below the scene.add(directionalLight) line:
// Ground
const groundGeometry = new THREE.CircleGeometry(200, 64);
const groundMaterial = new THREE.MeshStandardMaterial({ color: 0x228b22 });
const ground = new THREE.Mesh(groundGeometry, groundMaterial);
ground.rotation.x = -Math.PI / 2;
ground.receiveShadow = true;
scene.add(ground);

What does this code do?

CircleGeometry creates a flat disc (radius 200 units, 64 edge segments for smoothness). MeshStandardMaterial responds to lights and shadows realistically.

The rotation.x = -Math.PI / 2 rotates the circle from vertical (the default) to horizontal, so it becomes a ground surface. receiveShadow lets shadows from the car and barriers appear on the ground.

  • Save main.js and refresh your browser.
  • You should see a green circle filling the viewport. The camera is inside the scene looking at the ground from above.

Don't see the green ground?

  • Check that ground.rotation.x = -Math.PI / 2 is set. Without the rotation, the ground faces the camera edge-on and is invisible.
  • Make sure the ground code is placed above the // Window resize comment and below the lighting code.

Help me debug my ground plane

  • Below the ground code, add the circular track surface:
// Track
const trackRadius = 80;
const trackWidth = 14;
const trackShape = new THREE.RingGeometry(trackRadius - trackWidth / 2, trackRadius + trackWidth / 2, 64);
const trackMaterial = new THREE.MeshStandardMaterial({ color: 0x333333 });
const track = new THREE.Mesh(trackShape, trackMaterial);
track.rotation.x = -Math.PI / 2;
track.position.y = 0.01;
track.receiveShadow = true;
scene.add(track);

What does this code do?

RingGeometry creates a flat donut shape. The inner radius (73) and outer radius (87) give the track a 14-unit width centered at radius 80.

Setting position.y = 0.01 lifts the track just barely above the ground. This prevents z-fighting, a visual glitch where two surfaces at the exact same height flicker against each other.

  • Save main.js and refresh your browser.
  • You should see a dark grey ring appear on top of the green ground.

Track not visible?

  • Confirm track.rotation.x = -Math.PI / 2 is set, just like the ground.
  • Check that the track color (0x333333) is different from the ground color. If both are the same, the track blends in.

Help me debug my track ring

  • Below the track code, add the barrier loop to line both edges of the track:
// Barriers
const barrierCount = 40;
for (let i = 0; i < barrierCount; i++) {
  const angle = (i / barrierCount) * Math.PI * 2;

  // Inner barrier
  const innerBarrier = new THREE.Mesh(
    new THREE.BoxGeometry(2, 1, 1),
    new THREE.MeshStandardMaterial({ color: 0xff0000 })
  );
  innerBarrier.position.set(
    Math.cos(angle) * (trackRadius - trackWidth / 2 - 1),
    0.5,
    Math.sin(angle) * (trackRadius - trackWidth / 2 - 1)
  );
  innerBarrier.rotation.y = -angle + Math.PI / 2;
  innerBarrier.castShadow = true;
  scene.add(innerBarrier);

  // Outer barrier
  const outerBarrier = new THREE.Mesh(
    new THREE.BoxGeometry(2, 1, 1),
    new THREE.MeshStandardMaterial({ color: 0xffffff })
  );
  outerBarrier.position.set(
    Math.cos(angle) * (trackRadius + trackWidth / 2 + 1),
    0.5,
    Math.sin(angle) * (trackRadius + trackWidth / 2 + 1)
  );
  outerBarrier.rotation.y = -angle + Math.PI / 2;
  outerBarrier.castShadow = true;
  scene.add(outerBarrier);
}

What does this code do?

This loop places 40 red barriers along the inner edge and 40 white barriers along the outer edge of the track. Each barrier is a small box (2 x 1 x 1 units).

The Math.cos(angle) and Math.sin(angle) calculations place each barrier at the correct point around the circle. The rotation.y rotates each barrier to face tangent to the track, so they look like real racing barriers.

  • Save main.js and refresh your browser.
  • You should see red and white barrier dots lining the inside and outside of the dark track ring. The camera's default position (origin) means you're looking at the scene from the center.

Barriers not showing up?

  • Check that trackRadius and trackWidth are defined above the barrier loop. The loop uses these constants to calculate positions.
  • Make sure the barrier code is placed before the // Window resize section. The barriers need to exist before the first render frame.

Help me debug my barriers

✔️ Awesome, I've got everything!

Great. Double check you've saved main.js and your browser shows the complete track with barriers.

ⓧ I'd like to double check the full code

Here is the full main.js at this point:

import * as THREE from 'three';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';

// Scene setup
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x87ceeb);
scene.fog = new THREE.Fog(0x87ceeb, 100, 400);

// Camera
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);

// Renderer
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(window.devicePixelRatio);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFShadowMap;
document.body.appendChild(renderer.domElement);

// Lighting
const ambientLight = new THREE.AmbientLight(0xffffff, 0.4);
scene.add(ambientLight);

const directionalLight = new THREE.DirectionalLight(0xffffff, 1.5);
directionalLight.position.set(50, 100, 50);
directionalLight.castShadow = true;
directionalLight.shadow.mapSize.width = 2048;
directionalLight.shadow.mapSize.height = 2048;
directionalLight.shadow.camera.near = 0.5;
directionalLight.shadow.camera.far = 300;
directionalLight.shadow.camera.left = -100;
directionalLight.shadow.camera.right = 100;
directionalLight.shadow.camera.top = 100;
directionalLight.shadow.camera.bottom = -100;
scene.add(directionalLight);

// Ground
const groundGeometry = new THREE.CircleGeometry(200, 64);
const groundMaterial = new THREE.MeshStandardMaterial({ color: 0x228b22 });
const ground = new THREE.Mesh(groundGeometry, groundMaterial);
ground.rotation.x = -Math.PI / 2;
ground.receiveShadow = true;
scene.add(ground);

// Track
const trackRadius = 80;
const trackWidth = 14;
const trackShape = new THREE.RingGeometry(trackRadius - trackWidth / 2, trackRadius + trackWidth / 2, 64);
const trackMaterial = new THREE.MeshStandardMaterial({ color: 0x333333 });
const track = new THREE.Mesh(trackShape, trackMaterial);
track.rotation.x = -Math.PI / 2;
track.position.y = 0.01;
track.receiveShadow = true;
scene.add(track);

// Barriers
const barrierCount = 40;
for (let i = 0; i < barrierCount; i++) {
  const angle = (i / barrierCount) * Math.PI * 2;

  const innerBarrier = new THREE.Mesh(
    new THREE.BoxGeometry(2, 1, 1),
    new THREE.MeshStandardMaterial({ color: 0xff0000 })
  );
  innerBarrier.position.set(
    Math.cos(angle) * (trackRadius - trackWidth / 2 - 1),
    0.5,
    Math.sin(angle) * (trackRadius - trackWidth / 2 - 1)
  );
  innerBarrier.rotation.y = -angle + Math.PI / 2;
  innerBarrier.castShadow = true;
  scene.add(innerBarrier);

  const outerBarrier = new THREE.Mesh(
    new THREE.BoxGeometry(2, 1, 1),
    new THREE.MeshStandardMaterial({ color: 0xffffff })
  );
  outerBarrier.position.set(
    Math.cos(angle) * (trackRadius + trackWidth / 2 + 1),
    0.5,
    Math.sin(angle) * (trackRadius + trackWidth / 2 + 1)
  );
  outerBarrier.rotation.y = -angle + Math.PI / 2;
  outerBarrier.castShadow = true;
  scene.add(outerBarrier);
}

// Window resize
window.addEventListener('resize', () => {
  camera.aspect = window.innerWidth / window.innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(window.innerWidth, window.innerHeight);
});

// Animation loop
function animate() {
  renderer.render(scene, camera);
}

renderer.setAnimationLoop(animate);

Your 3D world now has a fully lit race track with barriers marking the course. Next up, you'll load the F1 car model onto this track and get it rendering with proper shadows.

Load the F1 Car Model

Your track is built, lit, and lined with barriers. Now it needs a driver.

In this step, you will load your downloaded F1 car model into the scene using GLTFLoader and define the physics properties that will control how the car accelerates, brakes, and steers.

In this step, get ready to:
  • Define car physics state (speed, acceleration, steering).
  • Load and position the GLB car model on the track.
  • Enable shadow casting on the car for realistic lighting.
Define the car physics state

Before loading the car visually, you need an object that tracks how the car behaves. This includes its current speed, maximum speed, how fast it accelerates, and how quickly it decelerates from friction.

Why a pure-JS approach instead of a physics engine?

A full physics engine (like cannon-es or rapier) would double the project scope and shift your focus away from learning Three.js. A simple velocity and steering model gives you a convincing racing game feel while keeping all your code in Three.js territory.

  • In main.js, find the closing } brace of the barriers for loop.
  • Add the car state object below it by pasting this code:
// Car state
const carState = {
  speed: 0,
  steering: 0,
  maxSpeed: 1.2,
  acceleration: 0.008,
  braking: 0.015,
  friction: 0.003,
  steeringSpeed: 0.03,
  steeringReturn: 0.05,
  maxSteering: 0.04
};

What does this code do?

This object stores every property that controls how the car moves each frame.

  • speed is the car's current velocity. It starts at 0 (stationary).
  • maxSpeed caps how fast the car can go.
  • acceleration and braking control how quickly the car speeds up and slows down.
  • friction gradually slows the car when no key is pressed.
  • steeringSpeed determines how quickly the car turns.
  • Save main.js.

You won't see a visible change yet. The car state is pure data that the next chunk will use.

Load and position the car model

A GLB file is a binary version of the glTF format, the recommended 3D model format for Three.js. The GLTFLoader reads this file and returns a scene object you can add directly to your Three.js scene.

  • In main.js, directly below the carState object you just added, paste the following code to load your car model:
// Load car model
let car = null;
const loader = new GLTFLoader();
loader.load('models/car.glb', (gltf) => {
  car = gltf.scene;
  car.scale.set(0.5, 0.5, 0.5);
  car.position.set(trackRadius, 0, 0);
  car.rotation.y = Math.PI / 2;

  car.traverse((child) => {
    if (child.isMesh) {
      child.castShadow = true;
      child.receiveShadow = true;
    }
  });

  scene.add(car);
}, undefined, (error) => {
  console.error('Error loading car model:', error);
});

What does this code do?

  • let car = null creates a variable to hold the car model once it loads. It starts as null because loading is asynchronous.
  • loader.load('models/car.glb', ...) fetches the GLB file from your public/models/ folder. Vite serves the public/ folder at the root path, so models/car.glb resolves correctly.
  • car.scale.set(0.5, 0.5, 0.5) shrinks the model to half size so it fits the track proportionally.
  • car.position.set(trackRadius, 0, 0) places the car on the track surface at the start position.
  • car.rotation.y = Math.PI / 2 rotates it 90 degrees so it faces along the track tangent.
  • car.traverse() walks through every child object in the model. A GLB file typically contains multiple nested meshes, and shadows must be enabled on each one individually.
  • Save main.js.
  • Switch to your browser and open the developer console by pressing Cmd+Option+J (macOS) or Ctrl+Shift+J (Windows).
  • Check the Console tab. If you see no error messages, the model loaded successfully.

Seeing an error about the model?

  • Check that your file is located at public/models/car.glb in your project folder. The path in the code is relative to the public/ root.
  • Make sure the file is named exactly car.glb (lowercase). If you downloaded it with a different name, rename it.
  • Verify the file is a .glb binary format, not a .gltf text file. If you downloaded the gltf folder format, look for the .glb file inside it.

Still stuck? Ask for help: Why isn't my GLB model loading in Three.js?

Verify the car renders on track

The car is now loaded and sitting on the track. The camera hasn't been positioned to look at the car yet (that comes in Step 5 with the chase camera), but you can confirm the model loaded by checking the Network tab in your browser's developer tools.

  • In developer tools, click the Network tab.
  • Refresh the page.
  • Look for car.glb in the list of network requests. It should show a status of 200, confirming the model was fetched successfully.

Why can't I see the car yet?

The camera is still at its default position (the origin of the scene) and is not pointed at the track. The car is sitting at position (80, 0, 0), which is outside the camera's current view.

In Step 5, you will build a chase camera that follows behind the car. Once that is in place, you will see the car, track, barriers, and shadows all together.

✔️ Awesome, I've got everything!

Great. Double check you have saved main.js and confirmed no console errors.

ⓧ I'd like to double check the full code

Here is the full main.js at this point:

import * as THREE from 'three';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';

// Scene setup
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x87ceeb);
scene.fog = new THREE.Fog(0x87ceeb, 100, 400);

// Camera
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);

// Renderer
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(window.devicePixelRatio);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFShadowMap;
document.body.appendChild(renderer.domElement);

// Lighting
const ambientLight = new THREE.AmbientLight(0xffffff, 0.4);
scene.add(ambientLight);

const directionalLight = new THREE.DirectionalLight(0xffffff, 1.5);
directionalLight.position.set(50, 100, 50);
directionalLight.castShadow = true;
directionalLight.shadow.mapSize.width = 2048;
directionalLight.shadow.mapSize.height = 2048;
directionalLight.shadow.camera.near = 0.5;
directionalLight.shadow.camera.far = 300;
directionalLight.shadow.camera.left = -100;
directionalLight.shadow.camera.right = 100;
directionalLight.shadow.camera.top = 100;
directionalLight.shadow.camera.bottom = -100;
scene.add(directionalLight);

// Ground
const groundGeometry = new THREE.CircleGeometry(200, 64);
const groundMaterial = new THREE.MeshStandardMaterial({ color: 0x228b22 });
const ground = new THREE.Mesh(groundGeometry, groundMaterial);
ground.rotation.x = -Math.PI / 2;
ground.receiveShadow = true;
scene.add(ground);

// Track
const trackRadius = 80;
const trackWidth = 14;
const trackShape = new THREE.RingGeometry(trackRadius - trackWidth / 2, trackRadius + trackWidth / 2, 64);
const trackMaterial = new THREE.MeshStandardMaterial({ color: 0x333333 });
const track = new THREE.Mesh(trackShape, trackMaterial);
track.rotation.x = -Math.PI / 2;
track.position.y = 0.01;
track.receiveShadow = true;
scene.add(track);

// Barriers
const barrierCount = 40;
for (let i = 0; i < barrierCount; i++) {
  const angle = (i / barrierCount) * Math.PI * 2;

  const innerBarrier = new THREE.Mesh(
    new THREE.BoxGeometry(2, 1, 1),
    new THREE.MeshStandardMaterial({ color: 0xff0000 })
  );
  innerBarrier.position.set(
    Math.cos(angle) * (trackRadius - trackWidth / 2 - 1),
    0.5,
    Math.sin(angle) * (trackRadius - trackWidth / 2 - 1)
  );
  innerBarrier.rotation.y = -angle + Math.PI / 2;
  innerBarrier.castShadow = true;
  scene.add(innerBarrier);

  const outerBarrier = new THREE.Mesh(
    new THREE.BoxGeometry(2, 1, 1),
    new THREE.MeshStandardMaterial({ color: 0xffffff })
  );
  outerBarrier.position.set(
    Math.cos(angle) * (trackRadius + trackWidth / 2 + 1),
    0.5,
    Math.sin(angle) * (trackRadius + trackWidth / 2 + 1)
  );
  outerBarrier.rotation.y = -angle + Math.PI / 2;
  outerBarrier.castShadow = true;
  scene.add(outerBarrier);
}

// Car state
const carState = {
  speed: 0,
  steering: 0,
  maxSpeed: 1.2,
  acceleration: 0.008,
  braking: 0.015,
  friction: 0.003,
  steeringSpeed: 0.03,
  steeringReturn: 0.05,
  maxSteering: 0.04
};

// Load car model
let car = null;
const loader = new GLTFLoader();
loader.load('models/car.glb', (gltf) => {
  car = gltf.scene;
  car.scale.set(0.5, 0.5, 0.5);
  car.position.set(trackRadius, 0, 0);
  car.rotation.y = Math.PI / 2;

  car.traverse((child) => {
    if (child.isMesh) {
      child.castShadow = true;
      child.receiveShadow = true;
    }
  });

  scene.add(car);
}, undefined, (error) => {
  console.error('Error loading car model:', error);
});

// Window resize
window.addEventListener('resize', () => {
  camera.aspect = window.innerWidth / window.innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(window.innerWidth, window.innerHeight);
});

// Animation loop
function animate() {
  renderer.render(scene, camera);
}

renderer.setAnimationLoop(animate);

Your car model is loaded, positioned on the track, and ready to cast shadows. Next up, you will wire keyboard controls to this car so you can actually drive it around the circuit.

Wire Up Keyboard Controls and Movement

Your F1 car sits on the track looking great, but it can't move yet. A 3D racing game needs real-time input handling that reads keypresses every single frame and translates them into physics-like movement.

In this step, you'll wire up WASD and arrow key controls, build an acceleration and friction system, and connect the speed HUD so it updates live as you drive.

In this step, get ready to:
  • Create a keyboard input handler that tracks pressed keys.
  • Build a movement function with acceleration, braking, friction, and steering.
  • Connect the speed HUD to display real-time km/h.
Create a keyboard input handler

Games need to know which keys are pressed right now, not just when a key was initially tapped. The standard approach is to maintain an object that records true when a key goes down and false when it comes back up. Your animation loop can then check this object every frame.

  • In main.js, find the carState object. Add the following keyboard input code directly below it (before the // Load car model comment):
// Keyboard input
const keys = {};
window.addEventListener('keydown', (e) => { keys[e.code] = true; });
window.addEventListener('keyup', (e) => { keys[e.code] = false; });

What does this code do?

The keys object acts like a live snapshot of the keyboard. When you hold W, keys['KeyW'] becomes true. When you release it, the keyup listener sets it back to false.

Using e.code instead of e.key means the controls work regardless of whether Caps Lock is on or the user has a non-QWERTY keyboard layout.

  • Save main.js.

You won't see a change yet. The next chunk uses this input object to actually move the car.

Build the car movement function

Real cars don't teleport when you press a button. They accelerate gradually, slow down from friction, and can only turn when they have speed. Your movement function will simulate this by adjusting the car's speed and rotation every frame based on which keys are held.

  • In main.js, find the // Window resize comment. Add the following function directly above it:
// Update car movement
function updateCar() {
  if (!car) return;

  // Acceleration and braking
  if (keys['KeyW'] || keys['ArrowUp']) {
    carState.speed += carState.acceleration;
  }
  if (keys['KeyS'] || keys['ArrowDown']) {
    carState.speed -= carState.braking;
  }

  // Friction
  if (!keys['KeyW'] && !keys['ArrowUp'] && !keys['KeyS'] && !keys['ArrowDown']) {
    if (carState.speed > 0) {
      carState.speed -= carState.friction;
      if (carState.speed < 0) carState.speed = 0;
    } else if (carState.speed < 0) {
      carState.speed += carState.friction;
      if (carState.speed > 0) carState.speed = 0;
    }
  }

  // Clamp speed
  carState.speed = Math.max(-carState.maxSpeed / 3, Math.min(carState.maxSpeed, carState.speed));

  // Steering
  const speedFactor = Math.abs(carState.speed) / carState.maxSpeed;
  if (keys['KeyA'] || keys['ArrowLeft']) {
    car.rotation.y += carState.steeringSpeed * speedFactor;
  }
  if (keys['KeyD'] || keys['ArrowRight']) {
    car.rotation.y -= carState.steeringSpeed * speedFactor;
  }

  // Move car forward in its local direction
  car.translateZ(carState.speed);

  // Update world matrix for camera
  car.updateMatrixWorld();
}

What does this code do?

The function runs every frame and updates the car's position based on input:

  • Pressing W or ArrowUp adds carState.acceleration to speed each frame, gradually increasing velocity.
  • Pressing S or ArrowDown subtracts carState.braking (a stronger value than acceleration) for realistic braking.
  • When no throttle or brake key is held, friction slowly brings the car to a stop.
  • Steering multiplies by speedFactor so the car cannot turn while stationary. This prevents unrealistic spinning in place.
  • The translateZ() method moves the car along its own forward axis, so it always drives in the direction it's facing.
  • Save main.js.

The function is defined but not called yet. You'll wire it into the animation loop in the next substep alongside the HUD.

Connect the speed HUD and wire everything into the animation loop

The HTML already has a #speed div ready to display the speedometer. You need a function that reads the car's current speed and updates that element every frame.

  • In main.js, find the end of the car model loading block (the line with console.error('Error loading car model:', error); and its closing braces). Add the following code directly below it:
// HUD
const speedDisplay = document.getElementById('speed');

function updateHUD() {
  const displaySpeed = Math.abs(Math.round(carState.speed * 200));
  speedDisplay.textContent = displaySpeed + ' km/h';
}

What does this code do?

The speedDisplay constant grabs the DOM element once so you don't search for it every frame.

Inside updateHUD(), the raw speed value (a small decimal like 0.8) is multiplied by 200 to produce a human-readable km/h number. Math.abs() ensures the display never shows negative values when reversing.

Now both functions exist but the animation loop doesn't call them yet. Time to fix that.

  • Scroll down to the animate() function at the bottom of main.js.
  • Find this code:
// Animation loop
function animate() {
  renderer.render(scene, camera);
}
  • Replace the entire animate() function with this updated version that calls your new functions before rendering:
// Animation loop
function animate() {
  updateCar();
  updateHUD();
  renderer.render(scene, camera);
}

Why does order matter here?

The animation loop runs about 60 times per second. Each frame, updateCar() moves the car based on current input, updateHUD() writes the new speed to the DOM, and then renderer.render() draws the updated scene. Running updates before rendering ensures the learner always sees the latest frame.

  • Save main.js.
  • Switch to your browser at http://localhost:5173. Press W on your keyboard. The car should accelerate forward along the track, and the speed display at the bottom should climb from 0 km/h.
  • Try pressing A or D while moving to steer left and right.
  • Release all keys. The car should coast to a stop due to friction.

Car not moving?

  • Make sure your browser tab is focused. If you click outside the browser window, keyboard events won't fire.
  • Check the browser console for errors. If you see a reference error for keys or updateCar, make sure the keyboard input section is placed before the animation loop in your file.
  • Confirm that your animate() function includes the updateCar() call. Without it, the movement code never runs.

Need more help? Why is my car not responding to keyboard input in Three.js?

✔️ Awesome, I've got everything!

Great. Double check you've saved main.js and your car is responding to WASD.

ⓧ I'd like to double check the full code

Here is the complete main.js at this point:

import * as THREE from 'three';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';

// Scene setup
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x87ceeb);
scene.fog = new THREE.Fog(0x87ceeb, 100, 400);

// Camera
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);

// Renderer
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(window.devicePixelRatio);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFShadowMap;
document.body.appendChild(renderer.domElement);

// Lighting
const ambientLight = new THREE.AmbientLight(0xffffff, 0.4);
scene.add(ambientLight);

const directionalLight = new THREE.DirectionalLight(0xffffff, 1.5);
directionalLight.position.set(50, 100, 50);
directionalLight.castShadow = true;
directionalLight.shadow.mapSize.width = 2048;
directionalLight.shadow.mapSize.height = 2048;
directionalLight.shadow.camera.near = 0.5;
directionalLight.shadow.camera.far = 300;
directionalLight.shadow.camera.left = -100;
directionalLight.shadow.camera.right = 100;
directionalLight.shadow.camera.top = 100;
directionalLight.shadow.camera.bottom = -100;
scene.add(directionalLight);

// Ground
const groundGeometry = new THREE.CircleGeometry(200, 64);
const groundMaterial = new THREE.MeshStandardMaterial({ color: 0x228b22 });
const ground = new THREE.Mesh(groundGeometry, groundMaterial);
ground.rotation.x = -Math.PI / 2;
ground.receiveShadow = true;
scene.add(ground);

// Track
const trackRadius = 80;
const trackWidth = 14;
const trackShape = new THREE.RingGeometry(trackRadius - trackWidth / 2, trackRadius + trackWidth / 2, 64);
const trackMaterial = new THREE.MeshStandardMaterial({ color: 0x333333 });
const track = new THREE.Mesh(trackShape, trackMaterial);
track.rotation.x = -Math.PI / 2;
track.position.y = 0.01;
track.receiveShadow = true;
scene.add(track);

// Barriers
const barrierCount = 40;
for (let i = 0; i < barrierCount; i++) {
  const angle = (i / barrierCount) * Math.PI * 2;

  const innerBarrier = new THREE.Mesh(
    new THREE.BoxGeometry(2, 1, 1),
    new THREE.MeshStandardMaterial({ color: 0xff0000 })
  );
  innerBarrier.position.set(
    Math.cos(angle) * (trackRadius - trackWidth / 2 - 1),
    0.5,
    Math.sin(angle) * (trackRadius - trackWidth / 2 - 1)
  );
  innerBarrier.rotation.y = -angle + Math.PI / 2;
  innerBarrier.castShadow = true;
  scene.add(innerBarrier);

  const outerBarrier = new THREE.Mesh(
    new THREE.BoxGeometry(2, 1, 1),
    new THREE.MeshStandardMaterial({ color: 0xffffff })
  );
  outerBarrier.position.set(
    Math.cos(angle) * (trackRadius + trackWidth / 2 + 1),
    0.5,
    Math.sin(angle) * (trackRadius + trackWidth / 2 + 1)
  );
  outerBarrier.rotation.y = -angle + Math.PI / 2;
  outerBarrier.castShadow = true;
  scene.add(outerBarrier);
}

// Car state
const carState = {
  speed: 0,
  steering: 0,
  maxSpeed: 1.2,
  acceleration: 0.008,
  braking: 0.015,
  friction: 0.003,
  steeringSpeed: 0.03,
  steeringReturn: 0.05,
  maxSteering: 0.04
};

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

// Load car model
let car = null;
const loader = new GLTFLoader();
loader.load('models/car.glb', (gltf) => {
  car = gltf.scene;
  car.scale.set(0.5, 0.5, 0.5);
  car.position.set(trackRadius, 0, 0);
  car.rotation.y = Math.PI / 2;

  car.traverse((child) => {
    if (child.isMesh) {
      child.castShadow = true;
      child.receiveShadow = true;
    }
  });

  scene.add(car);
}, undefined, (error) => {
  console.error('Error loading car model:', error);
});

// HUD
const speedDisplay = document.getElementById('speed');

function updateHUD() {
  const displaySpeed = Math.abs(Math.round(carState.speed * 200));
  speedDisplay.textContent = displaySpeed + ' km/h';
}

// Update car movement
function updateCar() {
  if (!car) return;

  if (keys['KeyW'] || keys['ArrowUp']) {
    carState.speed += carState.acceleration;
  }
  if (keys['KeyS'] || keys['ArrowDown']) {
    carState.speed -= carState.braking;
  }

  if (!keys['KeyW'] && !keys['ArrowUp'] && !keys['KeyS'] && !keys['ArrowDown']) {
    if (carState.speed > 0) {
      carState.speed -= carState.friction;
      if (carState.speed < 0) carState.speed = 0;
    } else if (carState.speed < 0) {
      carState.speed += carState.friction;
      if (carState.speed > 0) carState.speed = 0;
    }
  }

  carState.speed = Math.max(-carState.maxSpeed / 3, Math.min(carState.maxSpeed, carState.speed));

  const speedFactor = Math.abs(carState.speed) / carState.maxSpeed;
  if (keys['KeyA'] || keys['ArrowLeft']) {
    car.rotation.y += carState.steeringSpeed * speedFactor;
  }
  if (keys['KeyD'] || keys['ArrowRight']) {
    car.rotation.y -= carState.steeringSpeed * speedFactor;
  }

  car.translateZ(carState.speed);
  car.updateMatrixWorld();
}

// Window resize
window.addEventListener('resize', () => {
  camera.aspect = window.innerWidth / window.innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(window.innerWidth, window.innerHeight);
});

// Animation loop
function animate() {
  updateCar();
  updateHUD();
  renderer.render(scene, camera);
}

renderer.setAnimationLoop(animate);

Your car is drivable. Next up, you'll build a smooth chase camera that follows behind the car as it races around the track.

Implement the Chase Camera

Your car drives around the track with responsive controls and a working speed HUD. There is one major problem though: the camera stays frozen in place while the car zooms off into the distance.

In this step, you will build a chase camera that smoothly follows behind the car. The technique uses Vector3 math and linear interpolation to create the smooth, cinematic feel of a real racing game camera.

In this step, get ready to:
  • Build a chase camera using an offset position and world matrix transforms.
  • Use lerp interpolation to smooth the camera movement.
  • Test the full game with responsive steering and smooth camera tracking.
Create the chase camera system

A chase camera needs to know where it should sit relative to the car, where it currently is in the world, and where it wants to be. Each frame, the camera computes its target position in world space and smoothly moves toward it.

  • In main.js, find the // HUD comment.
  • Add the following chase camera code directly above that line:
// Chase camera
const cameraOffset = new THREE.Vector3(0, 5, -15);
const currentCameraPosition = new THREE.Vector3();
const desiredCameraPosition = new THREE.Vector3();

function updateCamera() {
  if (!car) return;

  desiredCameraPosition.copy(cameraOffset);
  desiredCameraPosition.applyMatrix4(car.matrixWorld);

  currentCameraPosition.lerp(desiredCameraPosition, 0.05);
  camera.position.copy(currentCameraPosition);
  camera.lookAt(car.position.x, car.position.y + 1, car.position.z);
}

What does this code do?

  • cameraOffset defines where the camera sits relative to the car: 5 units above and 15 units behind. This is a position in the car's local space.
  • applyMatrix4(car.matrixWorld) transforms that local offset into world coordinates. As the car rotates, the offset rotates with it, keeping the camera behind the car no matter which direction it faces.
  • lerp(desiredCameraPosition, 0.05) moves the camera only 5% of the remaining distance toward the target each frame. This creates the smooth trailing effect instead of rigidly snapping to position.
  • camera.lookAt() points the camera at the car, offset slightly upward for a better viewing angle.

What is lerp?

Lerp stands for linear interpolation. It blends between two values by a fraction (the alpha). An alpha of 0.05 means "move 5% closer to the target each frame."

When the camera is far from its target, 5% of a large distance is a big step. When it gets close, 5% of a small distance is tiny. This naturally produces a smooth ease-out motion without any extra math.

Connect the camera to the animation loop

The updateCamera() function exists now, but nothing calls it yet. You also need to give the camera a sensible starting position so it does not start at the origin and then fly toward the car on the first frame.

  • In main.js, scroll down to the // Animation loop comment.
  • Add this initialization line directly above that comment:
// Initialize camera position
currentCameraPosition.set(trackRadius, 5, -15);

What does this do?

This places the camera at the car's starting location (x = trackRadius) but offset 5 units up and 15 units back. Without this, the camera would start at (0, 0, 0) and visibly fly across the scene to catch up.

  • Now find the animate() function. It currently looks like this:
function animate() {
  updateCar();
  updateHUD();
  renderer.render(scene, camera);
}
  • Add updateCamera(); between updateCar(); and updateHUD(); so the function looks like this:
function animate() {
  updateCar();
  updateCamera();
  updateHUD();
  renderer.render(scene, camera);
}

Why does order matter here?

The car must move first (updateCar) so its world matrix is up to date. Then the camera can compute its position based on the car's new location (updateCamera). Finally, the HUD and render call use the latest state.

  • Save main.js.

Camera snaps to a weird angle on load?

Make sure the currentCameraPosition.set(trackRadius, 5, -15); line appears BEFORE the animation loop starts. If it comes after renderer.setAnimationLoop(animate);, the first few frames will render before the position is set.

help me debug my chase camera position

Test the full game loop
  • Switch to your browser at http://localhost:5173. The page should auto-refresh from Vite's hot reload.
  • Press W to accelerate. The camera should now follow behind the car as it moves forward.
  • Hold A or D while driving to steer through turns. The camera should trail smoothly behind the car, swinging through corners with a slight delay.
  • Check the speed HUD at the bottom of the screen. It should still update in real time as you accelerate and brake.

You should see the car driving around the circular track from a smooth third-person perspective. The camera never jitters or snaps. It gently trails behind the car through every turn.

Camera not moving at all?

  • Confirm that updateCamera(); is inside the animate() function. If it is outside, it only runs once instead of every frame.
  • Check your browser console for errors. A typo in updateCamera or currentCameraPosition will throw a reference error and stop the loop.
  • Make sure the chase camera code is placed after the car model loading section. The car variable needs to be declared before updateCamera() references it.

help me fix my Three.js chase camera

✔️ Awesome, I've got everything!

Great. Double check you have saved main.js and your chase camera follows the car smoothly.

ⓧ I'd like to double check the full code

Here is the complete main.js file at this point. Compare yours against it:

import * as THREE from 'three';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';

// Scene setup
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x87ceeb);
scene.fog = new THREE.Fog(0x87ceeb, 100, 400);

// Camera
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);

// Renderer
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(window.devicePixelRatio);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFShadowMap;
document.body.appendChild(renderer.domElement);

// Lighting
const ambientLight = new THREE.AmbientLight(0xffffff, 0.4);
scene.add(ambientLight);

const directionalLight = new THREE.DirectionalLight(0xffffff, 1.5);
directionalLight.position.set(50, 100, 50);
directionalLight.castShadow = true;
directionalLight.shadow.mapSize.width = 2048;
directionalLight.shadow.mapSize.height = 2048;
directionalLight.shadow.camera.near = 0.5;
directionalLight.shadow.camera.far = 300;
directionalLight.shadow.camera.left = -100;
directionalLight.shadow.camera.right = 100;
directionalLight.shadow.camera.top = 100;
directionalLight.shadow.camera.bottom = -100;
scene.add(directionalLight);

// Ground
const groundGeometry = new THREE.CircleGeometry(200, 64);
const groundMaterial = new THREE.MeshStandardMaterial({ color: 0x228b22 });
const ground = new THREE.Mesh(groundGeometry, groundMaterial);
ground.rotation.x = -Math.PI / 2;
ground.receiveShadow = true;
scene.add(ground);

// Track
const trackRadius = 80;
const trackWidth = 14;
const trackShape = new THREE.RingGeometry(trackRadius - trackWidth / 2, trackRadius + trackWidth / 2, 64);
const trackMaterial = new THREE.MeshStandardMaterial({ color: 0x333333 });
const track = new THREE.Mesh(trackShape, trackMaterial);
track.rotation.x = -Math.PI / 2;
track.position.y = 0.01;
track.receiveShadow = true;
scene.add(track);

// Barriers
const barrierCount = 40;
for (let i = 0; i < barrierCount; i++) {
  const angle = (i / barrierCount) * Math.PI * 2;

  const innerBarrier = new THREE.Mesh(
    new THREE.BoxGeometry(2, 1, 1),
    new THREE.MeshStandardMaterial({ color: 0xff0000 })
  );
  innerBarrier.position.set(
    Math.cos(angle) * (trackRadius - trackWidth / 2 - 1),
    0.5,
    Math.sin(angle) * (trackRadius - trackWidth / 2 - 1)
  );
  innerBarrier.rotation.y = -angle + Math.PI / 2;
  innerBarrier.castShadow = true;
  scene.add(innerBarrier);

  const outerBarrier = new THREE.Mesh(
    new THREE.BoxGeometry(2, 1, 1),
    new THREE.MeshStandardMaterial({ color: 0xffffff })
  );
  outerBarrier.position.set(
    Math.cos(angle) * (trackRadius + trackWidth / 2 + 1),
    0.5,
    Math.sin(angle) * (trackRadius + trackWidth / 2 + 1)
  );
  outerBarrier.rotation.y = -angle + Math.PI / 2;
  outerBarrier.castShadow = true;
  scene.add(outerBarrier);
}

// Car state
const carState = {
  speed: 0,
  steering: 0,
  maxSpeed: 1.2,
  acceleration: 0.008,
  braking: 0.015,
  friction: 0.003,
  steeringSpeed: 0.03,
  steeringReturn: 0.05,
  maxSteering: 0.04
};

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

// Load car model
let car = null;
const loader = new GLTFLoader();
loader.load('models/car.glb', (gltf) => {
  car = gltf.scene;
  car.scale.set(0.5, 0.5, 0.5);
  car.position.set(trackRadius, 0, 0);
  car.rotation.y = Math.PI / 2;

  car.traverse((child) => {
    if (child.isMesh) {
      child.castShadow = true;
      child.receiveShadow = true;
    }
  });

  scene.add(car);
}, undefined, (error) => {
  console.error('Error loading car model:', error);
});

// Chase camera
const cameraOffset = new THREE.Vector3(0, 5, -15);
const currentCameraPosition = new THREE.Vector3();
const desiredCameraPosition = new THREE.Vector3();

function updateCamera() {
  if (!car) return;

  desiredCameraPosition.copy(cameraOffset);
  desiredCameraPosition.applyMatrix4(car.matrixWorld);

  currentCameraPosition.lerp(desiredCameraPosition, 0.05);
  camera.position.copy(currentCameraPosition);
  camera.lookAt(car.position.x, car.position.y + 1, car.position.z);
}

// HUD
const speedDisplay = document.getElementById('speed');

function updateHUD() {
  const displaySpeed = Math.abs(Math.round(carState.speed * 200));
  speedDisplay.textContent = displaySpeed + ' km/h';
}

// Update car movement
function updateCar() {
  if (!car) return;

  if (keys['KeyW'] || keys['ArrowUp']) {
    carState.speed += carState.acceleration;
  }
  if (keys['KeyS'] || keys['ArrowDown']) {
    carState.speed -= carState.braking;
  }

  if (!keys['KeyW'] && !keys['ArrowUp'] && !keys['KeyS'] && !keys['ArrowDown']) {
    if (carState.speed > 0) {
      carState.speed -= carState.friction;
      if (carState.speed < 0) carState.speed = 0;
    } else if (carState.speed < 0) {
      carState.speed += carState.friction;
      if (carState.speed > 0) carState.speed = 0;
    }
  }

  carState.speed = Math.max(-carState.maxSpeed / 3, Math.min(carState.maxSpeed, carState.speed));

  const speedFactor = Math.abs(carState.speed) / carState.maxSpeed;
  if (keys['KeyA'] || keys['ArrowLeft']) {
    car.rotation.y += carState.steeringSpeed * speedFactor;
  }
  if (keys['KeyD'] || keys['ArrowRight']) {
    car.rotation.y -= carState.steeringSpeed * speedFactor;
  }

  car.translateZ(carState.speed);
  car.updateMatrixWorld();
}

// Window resize
window.addEventListener('resize', () => {
  camera.aspect = window.innerWidth / window.innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(window.innerWidth, window.innerHeight);
});

// Initialize camera position
currentCameraPosition.set(trackRadius, 5, -15);

// Animation loop
function animate() {
  updateCar();
  updateCamera();
  updateHUD();
  renderer.render(scene, camera);
}

renderer.setAnimationLoop(animate);

Your 3D racing game is now fully playable. You have a car driving around a track with responsive keyboard controls, a smooth chase camera trailing behind, and a live speed readout. This is the complete game loop: input, physics, camera, render.

Secret mission

Add a Lap Timer

Your racing game is fun, but without a timer, there's no way to track progress. Can you add a lap timer that detects when you cross the start/finish line and records your best lap time?

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your project files for future tinkering, pause the dev server to free up memory, or delete everything entirely.

Resources you used:

  • The f1-racing-game project folder on your Desktop (includes node_modules, source files, and the car model).
  • A running Vite dev server process in your terminal.

Keep everything running

No action needed. Choose this if you are still actively building or want to add more features like particle effects, multiplayer, or new tracks.

  • Your dev server stays running at http://localhost:5173 until you close the terminal.
  • The project folder remains on your Desktop with all dependencies intact.

Pause - I'll come back to this later

Shut down the running dev server to free up memory. Your project files stay on disk so you can pick up where you left off.

  • Switch back to the terminal where your dev server is running.
  • Press Ctrl+C to stop the Vite dev server.
  • To resume later, open a terminal in the f1-racing-game folder and run npm run dev again.

Delete - I don't want to use this again

Remove all project files and start fresh. This deletes everything including the downloaded car model and installed dependencies.

  • Switch back to the terminal where your dev server is running and press Ctrl+C to stop it.
  • Navigate to your Desktop by running:
cd ~/Desktop
  • Delete the project folder by running:

macOS

rm -rf f1-racing-game

Windows

rmdir /s /q f1-racing-game
  • Confirm the folder is gone by running ls (macOS) or dir (Windows). You should no longer see f1-racing-game in the output.

That's a Wrap!

That's a Wrap!

Nice work! You just built a fully playable 3D racing game in the browser using Three.js.

You've learned how to:

  • Load a GLTF 3D model into a scene with directional lighting and real-time shadow mapping.
  • Implement real-time keyboard controls with acceleration, braking, and steering physics using an animation loop.
  • Create a smooth chase camera system using Vector3 math and lerp interpolation.
  • Secret Mission: Added a lap timer with checkpoint detection, giving the game a clear objective.

Ready to quiz yourself?