G GAMESRICH
3D Games · Advanced

How to Build a 3D Kart Racing Game in Three.js: Sunset Circuit

Master Catmull-Rom spline track extrusion, cel-shaded toon materials, drifting physics, and AI kart racers.

By GamesRich Engineering 11 min read Zero External Libraries

Play the Live Game First

Experience the mechanics firsthand to understand what you are building.

PLAY LIVE GAME ↗

Architecture & Core Concept

The race takes place in a true real-time 3D world rendered with Three.js: 1. Track Geometry: - 17 3D control points form a closed THREE.CatmullRomCurve3 loop. - The curve is sampled at 900 equidistant slices. For each slice, we compute tangent T and right vector R. - Custom BufferGeometry generates road surface, red/white rumble curbs, and sand runoff. 2. Vehicle Physics & Drift Mechanics: - Each kart updates longitudinal velocity along its yaw vector. - When drifting, lateral slip velocity is introduced and drift charge builds up. Releasing drift discharges into a multi-tier turbo boost. 3. Cartoon Toon Shading: - THREE.MeshToonMaterial with a custom stepped 3-shade gradient map (RAMP). - Inverted-hull geometry with BackSide rendering creates classic comic-book black outlines. 4. AI Opponents: - AI karts sample a lookahead point on the spline curve, steer toward it, and adjust throttle dynamically before sharp bends.

Key Game Mechanics Taught:

Step-by-Step Implementation

1

Construct the 3D Spline Track Curve

Define 3D control points with varying elevations. Sample 900 points along the curve to calculate tangent and right-hand normal vectors for ribbon extrusion.

Step 1 Implementation
const controlPts = [
  [0, 0, 0], [62, 0, -26], [118, 3, -74], [128, 7, -142],
  [96, 10, -196], [36, 10, -224], [-30, 7, -220], [-78, 3, -186],
  [-96, 0, -132], [-74, 1.5, -92], [-34, 4, -84], [-22, 7, -44],
  [-58, 8, -14], [-96, 6, 26], [-84, 2, 76], [-28, 0, 90], [30, 0, 64]
];

const curve = new THREE.CatmullRomCurve3(
  controlPts.map(p => new THREE.Vector3(p[0], p[1], p[2])), true, 'catmullrom', 0.5
);

// Sample 900 points, tangents, and right vectors
const P = [], T = [], R = [];
const up = new THREE.Vector3(0, 1, 0);
for (let i = 0; i < 900; i++) {
  const u = i / 900;
  const p = curve.getPointAt(u);
  const t = curve.getTangentAt(u).normalize();
  const r = new THREE.Vector3().crossVectors(t, up).normalize();
  P.push(p); T.push(t); R.push(r);
}
2

Generate Extruded Track Ribbons with Vertex Colors

Extrude 3D polygon strips along the spline to create alternating asphalt blocks, red-and-white rumble curbs, and sandy runoff borders.

Step 2 Implementation
function createRibbon(innerOffset, outerOffset, yElevation, colorFn) {
  const positions = [], colors = [];
  for (let i = 0; i < 900; i++) {
    const j = (i + 1) % 900;
    const c = colorFn(i);
    const a1 = P[i].clone().addScaledVector(R[i], innerOffset); a1.y += yElevation;
    const b1 = P[i].clone().addScaledVector(R[i], outerOffset); b1.y += yElevation;
    const a2 = P[j].clone().addScaledVector(R[j], innerOffset); a2.y += yElevation;
    const b2 = P[j].clone().addScaledVector(R[j], outerOffset); b2.y += yElevation;

    // Two triangles form each quad
    [a1, b1, b2, a1, b2, a2].forEach(v => {
      positions.push(v.x, v.y, v.z);
      colors.push(c.r, c.g, c.b);
    });
  }
  const geo = new THREE.BufferGeometry();
  geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
  geo.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3));
  geo.computeVertexNormals();
  return new THREE.Mesh(geo, new THREE.MeshToonMaterial({ vertexColors: true }));
}
3

Toon Shading & Inverted-Hull Cartoon Outlines

To get an authentic vibrant cartoon look, use a 3-step stepped ramp texture with THREE.MeshToonMaterial, and duplicate geometries slightly scaled up with BackSide rendering for dark ink outlines.

Step 3 Implementation
// Stepped 3-tone shading ramp
const RAMP = new THREE.DataTexture(
  new Uint8Array([150,150,150,255, 205,205,205,255, 255,255,255,255]),
  3, 1, THREE.RGBAFormat
);
RAMP.needsUpdate = true;
RAMP.minFilter = RAMP.magFilter = THREE.NearestFilter;

function toonMaterial(color) {
  return new THREE.MeshToonMaterial({ color, gradientMap: RAMP });
}

// Inverted-hull outline
const OUTLINE_MAT = new THREE.MeshBasicMaterial({ color: 0x2b1c3d, side: THREE.BackSide });
function addInkOutline(mesh, thickness = 0.08) {
  const outline = new THREE.Mesh(mesh.geometry, OUTLINE_MAT);
  outline.scale.setScalar(1 + thickness);
  mesh.add(outline);
  return mesh;
}
4

Implement Drifting Physics and Turbo Boost

When drifting into turns, steer angle induces lateral slip. Holding drift charges up three tiers of blue, orange, and hot-pink sparks that release into explosive turbo speed boosts.

Step 4 Implementation
function updateKartPhysics(kart, input, dt) {
  // Drift initiation
  if (input.drift && kart.speed > 16 && !kart.drifting && input.steer !== 0) {
    kart.drifting = true;
    kart.driftDir = Math.sign(input.steer);
    kart.driftCharge = 0;
    kart.hop = 1.1; // Visual hop
  }

  // Drift release -> Turbo boost
  if (kart.drifting && (!input.drift || kart.speed < 9)) {
    if (kart.driftCharge > 2.4) kart.boost = 1.5;      // Pink boost
    else if (kart.driftCharge > 1.4) kart.boost = 1.0; // Orange boost
    else if (kart.driftCharge > 0.7) kart.boost = 0.6; // Blue boost
    kart.drifting = false;
  }
  if (kart.drifting) kart.driftCharge += dt;

  // Steering and angular yaw rate
  const grip = Math.min(1, Math.abs(kart.speed) / 14);
  const yawRate = (kart.drifting ? (kart.driftDir * 0.72 + input.steer * 0.85) * 1.35 : input.steer) * 1.85 * grip;
  kart.yaw -= yawRate * dt;

  // Forward displacement + drift slide
  const fwd = new THREE.Vector3(Math.sin(kart.yaw), 0, Math.cos(kart.yaw));
  kart.pos.addScaledVector(fwd, kart.speed * dt);
  if (kart.drifting) {
    const side = new THREE.Vector3(Math.cos(kart.yaw), 0, -Math.sin(kart.yaw));
    kart.pos.addScaledVector(side, -kart.driftDir * kart.speed * 0.16 * dt);
  }
}

Complete Standalone Source Code

Here is the full, self-contained implementation. Save this as an index.html file on your computer and open it in any web browser to run your game immediately:

Complete Runnable Game (index.html)
<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>3D Kart Racer Demo</title>
<style>body{margin:0;overflow:hidden;background:#0b1026;}canvas{display:block;width:100vw;height:100vh;}</style>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
</head><body>
<script>
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x8fd4f7);
const camera = new THREE.PerspectiveCamera(60, innerWidth/innerHeight, 0.5, 1000);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(innerWidth, innerHeight);
document.body.appendChild(renderer.domElement);

scene.add(new THREE.HemisphereLight(0xfff0d0, 0x8fb8ff, 1.2));
const sun = new THREE.DirectionalLight(0xfff2cf, 0.9);
sun.position.set(-100, 150, 100);
scene.add(sun);

// Ground
const ground = new THREE.Mesh(new THREE.PlaneGeometry(1000, 1000), new THREE.MeshLambertMaterial({ color: 0x6ac25c }));
ground.rotation.x = -Math.PI / 2;
scene.add(ground);

// Player Kart
const kart = new THREE.Group();
const body = new THREE.Mesh(new THREE.SphereGeometry(1.6, 12, 10), new THREE.MeshToonMaterial({ color: 0x2ee6d6 }));
body.scale.set(0.9, 0.6, 1.4); body.position.y = 1; kart.add(body);
const nose = new THREE.Mesh(new THREE.SphereGeometry(1, 10, 8), new THREE.MeshToonMaterial({ color: 0x2ee6d6 }));
nose.position.set(0, 0.8, 1.8); kart.add(nose);
scene.add(kart);

let speed = 0, yaw = 0;
const keys = {};
window.onkeydown = e => keys[e.key] = true;
window.onkeyup = e => keys[e.key] = false;

function animate() {
  requestAnimationFrame(animate);
  if (keys['w'] || keys['ArrowUp']) speed = Math.min(35, speed + 0.8);
  else if (keys['s'] || keys['ArrowDown']) speed = Math.max(-10, speed - 1.2);
  else speed *= 0.96;

  if (keys['a'] || keys['ArrowLeft']) yaw += 0.04 * (speed / 20);
  if (keys['d'] || keys['ArrowRight']) yaw -= 0.04 * (speed / 20);

  kart.position.x += Math.sin(yaw) * speed * 0.03;
  kart.position.z += Math.cos(yaw) * speed * 0.03;
  kart.rotation.y = yaw;

  // Chase Camera
  const camBack = new THREE.Vector3(Math.sin(yaw), 0, Math.cos(yaw)).multiplyScalar(-12);
  camera.position.lerp(new THREE.Vector3(kart.position.x + camBack.x, kart.position.y + 6, kart.position.z + camBack.z), 0.1);
  camera.lookAt(kart.position.x, kart.position.y + 1.5, kart.position.z);

  renderer.render(scene, camera);
}
animate();
window.onresize = () => {
  camera.aspect = innerWidth / innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(innerWidth, innerHeight);
};
</script>
</body></html>

Ideas to Expand Your Game

Now that you have the core mechanics running, consider adding: