G GAMESRICH
Retro Games · Advanced

How to Build a Physics Hill Climb Game: Mat Rempit

Master procedural wave terrain, vehicle pitch physics, slope tangents, and air balance mechanics.

By GamesRich Engineering 9 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 terrain is a continuous mathematical function: y = terrain(x). Because the terrain is an equation rather than static polygons, we can sample the exact elevation at any horizontal coordinate R.x. To calculate the slope tangent underneath the motorcycle: slope = Math.atan2(terrain(R.x + 12) - terrain(R.x - 12), 24) When grounded, the motorcycle's angle smoothly interpolates toward this slope. When airborne, user arrow keys apply rotational torque. If the vehicle lands at an extreme tilt (|angle| > 1.65 rad), a crash occurs.

Key Game Mechanics Taught:

Step-by-Step Implementation

1

Define Procedural Mountain Waves

Combine multiple sine frequencies to create undulating terrain with large rolling hills and small bumps.

Step 1 Implementation
function getTerrainHeight(x) {
  // Base undulation
  let y = 385 + Math.sin(x / 170) * 24 + Math.sin(x / 67) * 10;
  
  // Specific large hill features at designated track intervals
  if (x > 600 && x < 1050)   y -= Math.sin((x - 600) / 450 * Math.PI) * 150;
  if (x > 1450 && x < 1850) y -= Math.sin((x - 1450) / 400 * Math.PI) * 110;
  if (x > 2250 && x < 2800) y -= Math.sin((x - 2250) / 550 * Math.PI) * 175;
  if (x > 3400 && x < 3950) y -= Math.sin((x - 3400) / 550 * Math.PI) * 130;
  return y;
}
2

Sample Slope and Apply Wheel Physics

Take two samples 12 pixels ahead and behind the motorcycle center. The angle formed between these two points gives the local ground angle.

Step 2 Implementation
const floorY = getTerrainHeight(rider.x);
const slope = Math.atan2(getTerrainHeight(rider.x + 12) - getTerrainHeight(rider.x - 12), 24);

rider.ground = (rider.y + 30 >= floorY);

if (rider.ground) {
  rider.y = floorY - 30;
  rider.vy = Math.min(0, rider.vy);
  // Match vehicle angle to ground slope
  rider.ang += (slope - rider.ang) * Math.min(1, 9 * dt);
  rider.av *= 0.7; // Damp angular velocity

  if (keys.gas)   rider.vx += 265 * dt;
  if (keys.brake) rider.vx -= 300 * dt;
  rider.vx *= Math.pow(0.55, dt); // Surface rolling friction
} else {
  // In mid-air: gravity pulls down, player tilts bike
  rider.vy += 680 * dt;
  rider.av += (keys.back - keys.forward) * 3.7 * dt;
  rider.ang += rider.av;
  rider.av *= 0.985;
}
3

Detect Inversion and Crash Respawns

If the player lands while tilted beyond 95 degrees (approximately 1.65 radians), the rider crashes. Deduct a time penalty and reset position upright.

Step 3 Implementation
if (Math.abs(rider.ang) > 1.65 && rider.ground) {
  rider.time = Math.max(0, rider.time - 5); // Time penalty
  playCrashSound();
  rider.x = Math.max(90, rider.x - 180);
  rider.y = getTerrainHeight(rider.x) - 50;
  rider.vx = 0;
  rider.vy = -80;
  rider.ang = 0; // Reset upright
}
4

Draw the Motorcycle with Canvas Transformations

Using ctx.save(), ctx.translate(x, y), and ctx.rotate(ang), you can draw the wheels, chassis, and rider around the vehicle pivot point with simple primitives.

Step 4 Implementation
function drawRider(rider, camX) {
  ctx.save();
  ctx.translate(rider.x - camX, rider.y);
  ctx.rotate(rider.ang);

  // Wheels
  ctx.fillStyle = '#171a2e';
  [-25, 25].forEach(offset => {
    ctx.beginPath();
    ctx.arc(offset, 20, 17, 0, Math.PI * 2);
    ctx.fill();
    ctx.strokeStyle = '#d9d7c8'; ctx.lineWidth = 4; ctx.stroke();
  });

  // Chassis frame
  ctx.strokeStyle = '#e74d35'; ctx.lineWidth = 9;
  ctx.beginPath();
  ctx.moveTo(-24, 18); ctx.lineTo(-5, -3); ctx.lineTo(22, 17);
  ctx.stroke();

  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>Hill Climb Racing Demo</title>
<style>
  body { margin:0; background:#0f172a; display:flex; justify-content:center; align-items:center; height:100vh; }
  canvas { background:#ff9a3e; }
</style>
</head>
<body>
<canvas id="c" width="960" height="540"></canvas>
<script>
const c = document.querySelector('#c'), x = c.getContext('2d');
const K = { gas:0, brake:0, forward:0, back:0 };
const R = { x:90, y:300, vx:0, vy:0, ang:0, av:0, ground:0 };
let cam = 0, last = performance.now();

function terrain(q) {
  let y = 380 + Math.sin(q/160)*25 + Math.sin(q/60)*10;
  if (q > 500 && q < 900) y -= Math.sin((q-500)/400*Math.PI)*140;
  if (q > 1200 && q < 1700) y -= Math.sin((q-1200)/500*Math.PI)*160;
  return y;
}

window.onkeydown = e => {
  if (['ArrowRight','d'].includes(e.key)) K.gas = 1;
  if (['ArrowLeft','a'].includes(e.key)) K.brake = 1;
  if (['ArrowUp','w'].includes(e.key)) K.forward = 1;
  if (['ArrowDown','s'].includes(e.key)) K.back = 1;
};
window.onkeyup = e => {
  if (['ArrowRight','d'].includes(e.key)) K.gas = 0;
  if (['ArrowLeft','a'].includes(e.key)) K.brake = 0;
  if (['ArrowUp','w'].includes(e.key)) K.forward = 0;
  if (['ArrowDown','s'].includes(e.key)) K.back = 0;
};

function loop(t) {
  const dt = Math.min((t - last)/1000, 0.03);
  last = t;
  const floor = terrain(R.x);
  const slope = Math.atan2(terrain(R.x+12) - terrain(R.x-12), 24);
  R.ground = R.y + 26 >= floor;
  if (R.ground) {
    R.y = floor - 26;
    R.vy = Math.min(0, R.vy);
    R.ang += (slope - R.ang) * Math.min(1, 9 * dt);
    R.av *= 0.7;
    if (K.gas) R.vx += 260 * dt;
    if (K.brake) R.vx -= 280 * dt;
    R.vx *= Math.pow(0.55, dt);
  } else {
    R.vy += 680 * dt;
    R.av += (K.back - K.forward) * 3.5 * dt;
    R.ang += R.av;
    R.av *= 0.985;
  }
  R.vx = Math.max(-80, Math.min(360, R.vx));
  R.x += R.vx * dt; R.y += R.vy * dt;
  if (Math.abs(R.ang) > 1.65 && R.ground) {
    R.x = Math.max(90, R.x - 150); R.y = terrain(R.x) - 50; R.vx = R.vy = R.ang = 0;
  }
  cam += (Math.max(0, R.x - 260) - cam) * 0.09;

  // Render
  x.fillStyle = '#ff9a3e'; x.fillRect(0,0,960,540);
  x.beginPath(); x.moveTo(-10, terrain(cam));
  for (let s = 0; s <= 980; s += 8) x.lineTo(s, terrain(cam + s));
  x.lineTo(970, 540); x.lineTo(-10, 540); x.closePath();
  x.fillStyle = '#9a542f'; x.fill();

  x.save();
  x.translate(R.x - cam, R.y);
  x.rotate(R.ang);
  x.fillStyle = '#111';
  [-22, 22].forEach(p => { x.beginPath(); x.arc(p, 18, 14, 0, 7); x.fill(); });
  x.strokeStyle = '#e74d35'; x.lineWidth = 7;
  x.beginPath(); x.moveTo(-20,16); x.lineTo(0,-2); x.lineTo(20,16); x.stroke();
  x.restore();

  requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
</script>
</body>
</html>

Ideas to Expand Your Game

Now that you have the core mechanics running, consider adding: