Outrun - Day 9
Advanced LevelClassic 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
};
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!
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 key insight: scale = 1 / z. Objects far away (large Z) have small scale, appearing smaller. Objects close (small Z) have large scale, appearing bigger.
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;
}
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;
}
}
}
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;
}
}
Centrifugal force makes high-speed curves feel dangerous. Players must anticipate and counter-steer or slow down!
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...
}
Segments and sprites are drawn back-to-front (far to near). This ensures closer objects correctly overlap distant ones without needing a Z-buffer.