TECH DETAILS

Outrun - Day 9

Advanced Level

Table of Contents

01 / Pseudo-3D Road Rendering

Classic Outrun uses "pseudo-3D" - not true 3D, but 2D segments projected to simulate depth. The road is divided into segments, each rendered as a trapezoid that gets smaller toward the horizon.

// Road parameters const roadWidth = 2000; // Width of road at base const segmentLength = 200; // Length of each segment const drawDistance = 100; // How many segments to render const cameraHeight = 1000; // Camera height above road const cameraDepth = 0.84; // Field of view factor // Each segment stores its world position and projected screen position const segment = { p1: { world: { x: 0, y: 0, z: 0 }, screen: {} }, // Near edge p2: { world: { x: 0, y: 0, z: 200 }, screen: {} }, // Far edge curve: 0, // Curve intensity color: {} // Road, grass, rumble colors };
Why Pseudo-3D?

True 3D engines didn't exist in 1986 when Outrun was made. This technique creates convincing depth on limited hardware - and runs great in a browser!

02 / Perspective Projection

The magic happens in the projection function. It converts 3D world coordinates to 2D screen coordinates using perspective division.

function project(p, cameraX, cameraY, cameraZ) { const width = canvas.width / 2; const height = canvas.height / 2; // Transform world coords to camera space p.camera.x = p.world.x - cameraX; p.camera.y = p.world.y - cameraY; p.camera.z = p.world.z - cameraZ; // The key: divide by Z to create perspective const scale = cameraDepth / p.camera.z; // Store scale for sprite sizing later p.screen.scale = scale; // Project to screen coordinates p.screen.x = Math.round(width + (scale * p.camera.x * width)); p.screen.y = Math.round(height - (scale * p.camera.y * height)); p.screen.w = Math.round(scale * roadWidth * width); }

The Perspective Formula

The key insight: scale = 1 / z. Objects far away (large Z) have small scale, appearing smaller. Objects close (small Z) have large scale, appearing bigger.

03 / Curves and Hills

Curves are created by accumulating horizontal offset as we draw segments from far to near. Hills use Y-offset in world coordinates.

function addCurve(numSegments, curveIntensity, hillY) { for (let i = 0; i < numSegments; i++) { addSegment(curveIntensity, hillY); } } function addHill(numSegments, curve, maxHeight) { for (let i = 0; i < numSegments; i++) { // Sine wave creates smooth hill shape const y = maxHeight * Math.sin(i / numSegments * Math.PI); addSegment(curve, y); } } // In render loop: accumulate curve offset let x = 0; let dx = -(baseSegment.curve * basePercent); for (let n = 0; n < drawDistance; n++) { const segment = segments[index]; // Apply accumulated curve offset when projecting project(segment.p1, player.x * roadWidth - x, ...); project(segment.p2, player.x * roadWidth - x - dx, ...); // Accumulate for next segment x += dx; dx += segment.curve; }

04 / Traffic and Collision

Traffic cars are spawned with Z positions along the track and offsets from center. Collision checks if player and car are on the same segment.

const cars = []; function spawnTraffic() { const colors = ['#3498db', '#9b59b6', '#1abc9c']; for (let i = 0; i < 20; i++) { cars.push({ z: Math.random() * trackLength, // Position on track offset: Math.random() * 1.6 - 0.8, // Lane position (-1 to 1) speed: maxSpeed * (0.3 + Math.random() * 0.4), color: colors[Math.floor(Math.random() * colors.length)] }); } } // Collision detection for (const car of cars) { const carSegment = findSegment(car.z); if (carSegment.index === playerSegment.index) { // Same segment - check horizontal overlap if (Math.abs(player.x - car.offset) < 0.5) { // Collision! Slow down player player.speed = car.speed * 0.5; score -= 100; } } }

05 / Centrifugal Force

On curves, the player is pushed outward based on speed and curve intensity. This creates the classic Outrun "drift" feeling.

const player = { x: 0, // -1 to 1 (road position) speed: 0, maxSpeed: 300, centrifugal: 0.3 // Force multiplier }; function update(dt) { const segment = findSegment(player.z); const speedPercent = player.speed / player.maxSpeed; // Manual steering const dx = dt * 2 * speedPercent; if (keys.left) player.x -= dx; if (keys.right) player.x += dx; // Centrifugal force: curve pushes you outward // Faster speed + sharper curve = stronger push player.x -= dx * speedPercent * segment.curve * player.centrifugal; // Clamp to road bounds (with off-road margin) player.x = Math.max(-2, Math.min(2, player.x)); // Off-road penalty if (Math.abs(player.x) > 1) { player.speed -= offRoadDecel * dt; } }
The Feel of Speed

Centrifugal force makes high-speed curves feel dangerous. Players must anticipate and counter-steer or slow down!

06 / Sprite Scaling

Traffic cars and scenery scale based on their segment's projection scale. This creates the illusion of cars approaching and receding.

function drawTrafficCar(car, segment) { // Get the projection scale from the segment const spriteScale = segment.p1.screen.scale; // Base size multiplied by scale and a constant const baseWidth = 80; const baseHeight = 50; const destW = baseWidth * spriteScale * 8; const destH = baseHeight * spriteScale * 8; // Position on screen using segment's screen coords const destX = segment.p1.screen.x + (car.offset * segment.p1.screen.w) - destW / 2; const destY = segment.p1.screen.y - destH; // Don't draw if above horizon if (destY < canvas.height / 2) return; // Draw scaled car rectangle ctx.fillStyle = car.color; ctx.fillRect(destX, destY, destW, destH); } // The player car is always drawn at fixed screen position // because it's always at the camera's location function drawCar() { const x = canvas.width / 2 + player.x * canvas.width / 4; const y = canvas.height - 80; // Draw at fixed size... }

Z-Ordering

Segments and sprites are drawn back-to-front (far to near). This ensures closer objects correctly overlap distant ones without needing a Z-buffer.