G GAMESRICH
Retro Games · Advanced

How to Build a Pseudo-3D Retro Racer: Proton Saga Racing

Recreate 80s arcade racing with mathematical road projection, curved horizons, and traffic AI.

By GamesRich Engineering 10 min read Zero External Libraries

Play the Live Game First

Experience the mechanics firsthand to understand what you are building.

PLAY LIVE GAME ↗

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:

Step-by-Step Implementation

1

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.

Step 1 Implementation
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();
  }
}
2

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.

Step 2 Implementation
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;
}
3

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.

Step 3 Implementation
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:

Complete Runnable Game (index.html)
<!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: