Car Game 3D - Day 18
Three.js + Racing PhysicsThe game uses Three.js for all 3D rendering. We set up a scene with fog for depth perception and proper lighting for the neon aesthetic.
// Create the 3D scene
scene = new THREE.Scene();
scene.background = new THREE.Color(0x0a0a1a);
scene.fog = new THREE.Fog(0x0a0a1a, 100, 400);
// Perspective camera for racing view
camera = new THREE.PerspectiveCamera(
70, // Field of view
1, // Aspect ratio (updated on resize)
0.1, // Near clipping plane
1000 // Far clipping plane
);
// WebGL renderer with shadows
renderer = new THREE.WebGLRenderer({
canvas,
antialias: true
});
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
The scene uses ambient light for base illumination plus a directional "moon" light for subtle shadows. Point lights are added throughout the track for the neon glow effect.
// Ambient light - purple tint for night feel
const ambient = new THREE.AmbientLight(
0x404080, // Purple-tinted
0.4 // Low intensity
);
scene.add(ambient);
// Moonlight for subtle shadows
const moon = new THREE.DirectionalLight(
0x8888ff, 0.3
);
moon.position.set(50, 100, 50);
scene.add(moon);
Fog helps hide distant objects and gives depth to the scene while also improving performance by allowing Three.js to skip rendering objects beyond the fog distance.
The track is procedurally generated as an oval with added variation using sine waves. Each segment creates road geometry with perpendicular edge calculations.
// Generate track points around an oval
for (let i = 0; i <= TRACK_SEGMENTS; i++) {
const angle = (i / TRACK_SEGMENTS) * Math.PI * 2;
// Oval radii
const radiusX = TRACK_RADIUS * 1.5;
const radiusZ = TRACK_RADIUS;
// Add variation with sine wave
const wobble = Math.sin(angle * 4) * 10;
const x = Math.cos(angle) * (radiusX + wobble);
const z = Math.sin(angle) * (radiusZ + wobble);
trackPoints.push({ x, z, angle });
}
Each segment is a quad created from two triangles. We calculate perpendicular vectors to extend the road width in both directions.
// Calculate perpendicular direction for road width
const dx = p2.x - p1.x;
const dz = p2.z - p1.z;
const len = Math.sqrt(dx * dx + dz * dz);
const perpX = -dz / len; // Rotate 90 degrees
const perpZ = dx / len;
// Create road quad (6 vertices, 2 triangles)
const vertices = new Float32Array([
// Triangle 1
p1.x + perpX * width, 0.1, p1.z + perpZ * width,
p1.x - perpX * width, 0.1, p1.z - perpZ * width,
p2.x + perpX * width, 0.1, p2.z + perpZ * width,
// Triangle 2
p1.x - perpX * width, 0.1, p1.z - perpZ * width,
p2.x - perpX * width, 0.1, p2.z - perpZ * width,
p2.x + perpX * width, 0.1, p2.z + perpZ * width,
]);
Perpendicular Vector Calculation
The car uses a simplified arcade physics model with acceleration, friction, and speed-dependent steering. This creates a responsive but believable driving feel.
const carState = {
x: 0,
z: TRACK_RADIUS - 10,
rotation: 0,
speed: 0,
maxSpeed: 2.5,
acceleration: 0.05,
braking: 0.08,
deceleration: 0.015,
turnSpeed: 0.045,
friction: 0.985,
driftFactor: 0
};
function updateCar() {
// Acceleration / Braking
if (keys.up) carState.speed += carState.acceleration;
if (keys.down) carState.speed -= carState.braking;
// Speed limits
carState.speed = Math.max(
-carState.maxSpeed / 2,
Math.min(carState.maxSpeed, carState.speed)
);
// Friction when not accelerating
if (!keys.up && !keys.down) {
carState.speed *= carState.friction;
}
// Speed-dependent steering
if (Math.abs(carState.speed) > 0.05) {
const turnFactor = carState.speed > 0 ? 1 : -1;
const speedFactor = Math.min(
Math.abs(carState.speed) / carState.maxSpeed,
1
);
if (keys.left) {
carState.rotation += carState.turnSpeed * turnFactor * speedFactor;
}
if (keys.right) {
carState.rotation -= carState.turnSpeed * turnFactor * speedFactor;
}
}
// Apply velocity to position
carState.x += Math.sin(carState.rotation) * carState.speed;
carState.z += Math.cos(carState.rotation) * carState.speed;
}
This physics model prioritizes fun over realism. Real car physics involve complex tire friction models, weight transfer, and suspension. For an arcade game, simple friction and speed-clamping work great!
The race uses a checkpoint system to ensure players complete the full track. The player must pass through checkpoints in order to advance.
function checkCheckpoints() {
checkpoints.forEach((cp, index) => {
// Distance from car to checkpoint
const dx = carState.x - cp.x;
const dz = carState.z - cp.z;
const dist = Math.sqrt(dx * dx + dz * dz);
if (dist < TRACK_WIDTH) {
// Must hit checkpoints in order
const expectedNext = (lastCheckpoint + 1) % checkpoints.length;
if (index === expectedNext) {
lastCheckpoint = index;
// Check for lap completion
if (index === 0) {
if (currentLap < totalLaps) {
currentLap++;
} else {
finishRace();
}
}
}
}
});
}
Lap Completion Flow
// Load best time from localStorage
let bestTime = localStorage.getItem('carGame3dBestTime');
// Save new record
if (!bestTime || finalTime < parseFloat(bestTime)) {
bestTime = finalTime;
localStorage.setItem('carGame3dBestTime', finalTime);
}
The camera smoothly follows behind the car, creating the classic third-person racing view. Interpolation prevents jarring camera movements.
// Calculate ideal camera position
const camDist = 25;
const camHeight = 12;
const targetCamX = carState.x - Math.sin(carState.rotation) * camDist;
const targetCamZ = carState.z - Math.cos(carState.rotation) * camDist;
// Smooth interpolation (lerp)
camera.position.x += (targetCamX - camera.position.x) * 0.05;
camera.position.z += (targetCamZ - camera.position.z) * 0.05;
camera.position.y = camHeight;
// Always look at the car
camera.lookAt(carState.x, 2, carState.z);
When turning, the car tilts slightly to enhance the feeling of speed and momentum.
// Calculate target tilt based on input and speed
const targetTilt = (keys.left ? 0.1 : keys.right ? -0.1 : 0)
* Math.abs(carState.speed);
// Smoothly interpolate towards target
carGroup.rotation.z += (targetTilt - carGroup.rotation.z) * 0.1;
Linear interpolation (lerp) with a small factor like 0.05 creates smooth transitions. The formula current += (target - current) * factor is used everywhere in game development!
The game's cyberpunk aesthetic is achieved through careful use of emissive materials, point lights, and glow effects.
// Create glowing track edges
function createEdgeLine(p1, p2, perpX, perpZ, offset, color) {
const lineMat = new THREE.LineBasicMaterial({ color });
// Add glow posts at intervals
if (Math.random() > 0.9) {
const postMat = new THREE.MeshBasicMaterial({ color });
const post = new THREE.Mesh(postGeo, postMat);
// Point light for glow effect
const light = new THREE.PointLight(color, 0.5, 15);
light.position.copy(post.position);
scene.add(light);
}
}
// Neon underglow lights on car
const underglowL = new THREE.PointLight(
0x00ffff, // Cyan
0.5, // Intensity
5 // Distance
);
underglowL.position.set(-1.5, 0.2, 0);
carGroup.add(underglowL);
const underglowF = new THREE.PointLight(
0xff00ff, // Magenta
0.5, 5
);
underglowF.position.set(0, 0.2, 2);
carGroup.add(underglowF);
Animated particles floating in the scene add depth and atmosphere to the cyberpunk world.
// Create floating particles
for (let i = 0; i < 50; i++) {
const particle = new THREE.Mesh(sphereGeo, particleMat);
particle.position.set(
(Math.random() - 0.5) * 400,
10 + Math.random() * 30,
(Math.random() - 0.5) * 400
);
particle.userData = {
speed: 0.5 + Math.random(),
offset: Math.random() * Math.PI * 2
};
}
// Animate in render loop
particle.position.y += Math.sin(
Date.now() * 0.001 * particle.userData.speed
+ particle.userData.offset
) * 0.02;
Point lights are expensive! The game uses them sparingly with low range values. For more complex scenes, consider baked lighting or light probes.