Architecture & Core Concept
The pseudo-3D effect is created without any WebGL 3D meshes. The road from the player's bumper to the horizon is split into 34 horizontal bands (slices). For each slice i: - Progress t = i / 34 - Vertical coordinate y = horizon + t^2 * 335 (quadratic perspective squish) - Road width w = 18 + t^2 * 510 - Road center displaces horizontally based on track curve math: center = 480 + (curve(pos + z) * (1 - t) + curve(pos) * t) * 230 * (1 - t) Traffic cars calculate their Z distance (dz = car.pos - player.pos), scale accordingly, and sort by distance.
Key Game Mechanics Taught:
- Mathematical road slice projection: mapping track position to trapezoids
- Continuous curve accumulator function for smooth road bends
- Alternating verge grass and asphalt stripe generation
- Traffic vehicle scaling and depth-sorting (painters algorithm)
- Centrifugal road pull and car drift mechanics
Step-by-Step Implementation
Project Road Slices onto 2D Canvas
Divide the lower half of the screen into 34 horizontal trapezoids. Squaring the interpolation factor t creates realistic perspective foreshortening.
function drawRoad(playerPos) {
const horizon = 205;
const bands = 34;
for (let i = 0; i < bands; i++) {
const t1 = i / bands;
const t2 = (i + 1) / bands;
const y1 = horizon + t1 * t1 * 335;
const y2 = horizon + t2 * t2 * 335;
const w1 = 18 + t1 * t1 * 510;
const w2 = 18 + t2 * t2 * 510;
const center1 = 480 + getTrackCurve(playerPos, t1);
const center2 = 480 + getTrackCurve(playerPos, t2);
// Alternating asphalt stripes
const isLightStripe = Math.floor((playerPos / 55) + i) % 2 === 0;
ctx.fillStyle = isLightStripe ? '#60636b' : '#565960';
ctx.beginPath();
ctx.moveTo(center1 - w1, y1);
ctx.lineTo(center1 + w1, y1);
ctx.lineTo(center2 + w2, y2);
ctx.lineTo(center2 - w2, y2);
ctx.fill();
}
}
Centrifugal Force on Curves
When rounding a corner, the car experience centrifugal drift. The player must steer against the curve to avoid sliding off onto the grass verge.
function updateCarPhysics(dt) {
if (keys.gas) car.speed += 155 * dt;
else car.speed -= 34 * dt;
if (keys.brake) car.speed -= 260 * dt;
car.speed = Math.max(0, Math.min(330, car.speed));
// Steering adjusted by speed
const steer = (keys.right - keys.left) * (1.15 - car.speed / 700);
car.x += steer * dt;
// Centrifugal curve pull
const currentCurve = getCurve(car.pos);
car.x -= currentCurve * car.speed * dt / 6200;
// Off-road deceleration penalty
if (Math.abs(car.x) > 1.0) {
car.speed -= 135 * dt; // Grass friction slows car
car.damage += 4 * dt;
}
car.pos += car.speed * dt;
}
Traffic AI & Painters Algorithm Rendering
Traffic cars drive at steady speeds along designated lanes. Sort traffic by descending distance from the player (painter's algorithm) so distant cars are drawn first and closer cars overlap them correctly.
function drawTraffic(traffic, playerPos) {
traffic
.slice()
.sort((a, b) => b.pos - a.pos) // Furthest cars first
.forEach(car => {
const dz = car.pos - playerPos;
if (dz <= 0 || dz > 2500) return; // Behind player or beyond horizon
const d = 1 - dz / 2500;
const y = 205 + d * d * 300;
const half = 18 + d * d * 490;
const center = 480 + getTrackCurve(playerPos, 1 - d);
const screenX = center + car.lane * half * 0.72;
const scale = 0.18 + d * 0.85;
ctx.save();
ctx.translate(screenX, y);
ctx.scale(scale, scale);
drawCarSprite(car.color);
ctx.restore();
});
}
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>Pseudo-3D Racer Demo</title>
<style>body{margin:0;background:#000;display:flex;justify-content:center;align-items:center;height:100vh;}canvas{background:#55a9e8;}</style>
</head><body><canvas id="c" width="960" height="540"></canvas>
<script>
const c = document.querySelector('#c'), g = c.getContext('2d');
const K = { left:0, right:0, gas:0, brake:0 };
const R = { pos:0, x:0, speed:0 };
let last = performance.now();
const curve = p => Math.sin(p / 700) * 0.7 + Math.sin(p / 1700) * 0.4;
window.onkeydown = e => {
if(['ArrowLeft','a'].includes(e.key)) K.left = 1;
if(['ArrowRight','d'].includes(e.key)) K.right = 1;
if(['ArrowUp','w'].includes(e.key)) K.gas = 1;
if(['ArrowDown','s'].includes(e.key)) K.brake = 1;
};
window.onkeyup = e => {
if(['ArrowLeft','a'].includes(e.key)) K.left = 0;
if(['ArrowRight','d'].includes(e.key)) K.right = 0;
if(['ArrowUp','w'].includes(e.key)) K.gas = 0;
if(['ArrowDown','s'].includes(e.key)) K.brake = 0;
};
function loop(t) {
const dt = Math.min((t-last)/1000, 0.03); last = t;
if(K.gas) R.speed += 150 * dt; else R.speed -= 40 * dt;
if(K.brake) R.speed -= 250 * dt;
R.speed = Math.max(0, Math.min(300, R.speed));
R.x += (K.right - K.left) * dt * 1.2;
R.x -= curve(R.pos) * R.speed * dt / 5000;
R.pos += R.speed * dt;
g.fillStyle = '#55a9e8'; g.fillRect(0,0,960,205);
g.fillStyle = '#258f3e'; g.fillRect(0,205,960,335);
const bands = 30;
for(let i=0; i<bands; i++) {
const t1 = i / bands, t2 = (i+1) / bands;
const y1 = 205 + t1*t1*335, y2 = 205 + t2*t2*335;
const w1 = 20 + t1*t1*500, w2 = 20 + t2*t2*500;
const cur1 = 480 + curve(R.pos)*(1-t1)*180;
const cur2 = 480 + curve(R.pos)*(1-t2)*180;
const stripe = Math.floor((R.pos/50) + i) % 2;
g.fillStyle = stripe ? '#e34a42' : '#fff';
g.beginPath();
g.moveTo(cur1 - w1 - 20, y1); g.lineTo(cur1 - w1, y1);
g.lineTo(cur2 - w2, y2); g.lineTo(cur2 - w2 - 20, y2); g.fill();
g.beginPath();
g.moveTo(cur1 + w1, y1); g.lineTo(cur1 + w1 + 20, y1);
g.lineTo(cur2 + w2 + 20, y2); g.lineTo(cur2 + w2, y2); g.fill();
g.fillStyle = stripe ? '#565960' : '#60636b';
g.beginPath();
g.moveTo(cur1 - w1, y1); g.lineTo(cur1 + w1, y1);
g.lineTo(cur2 + w2, y2); g.lineTo(cur2 - w2, y2); g.fill();
}
// Draw Player Car
g.save();
g.translate(480 + R.x * 240, 465);
g.fillStyle = '#d83c3c'; g.fillRect(-40, -30, 80, 50);
g.fillStyle = '#111'; g.fillRect(-45, 5, 18, 25); g.fillRect(27, 5, 18, 25);
g.restore();
g.fillStyle = '#fff'; g.font = 'bold 18px sans-serif';
g.fillText('SPEED: ' + Math.floor(R.speed) + ' KM/H', 20, 30);
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
</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.