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:
- 3D spline tracks using THREE.CatmullRomCurve3 and Frenet-Serret frame tangent/right vectors
- Dynamic ribbon geometry generation with vertex-colored road, curbs, and runoff strips
- Cel-shaded cartoon toon shader using custom stepped ramp textures and inverted-hull ink lines
- Longitudinal and lateral kart physics: acceleration, rolling resistance, offroad grass penalty, and drift state machine
- Rubber-banding AI rivals with spline lookahead steering and collision shoving physics
- Third-person chase camera with dynamic speed FOV expansion and smooth position lerp
Step-by-Step Implementation
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.
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);
}
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.
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 }));
}
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.
// 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;
}
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.
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:
<!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:
- Local High Scores: Use
localStorage.setItem()andlocalStorage.getItem()to persist personal bests. - Visual Polish: Add screen shake on impacts by displacing
ctx.translate(shakeX, shakeY). - Mobile Touch Controls: Bind on-screen directional buttons using
onpointerdownandonpointerup.