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:
- Mathematical terrain height curves combining sine harmonics
- Ground slope normal calculation via numeric differentiation
- Vehicle angular velocity and air balance torque
- Throttle acceleration, braking, and tire grip simulation
- Rotational transform rendering with canvas ctx.rotate()
Step-by-Step Implementation
Define Procedural Mountain Waves
Combine multiple sine frequencies to create undulating terrain with large rolling hills and small bumps.
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;
}
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.
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;
}
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.
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
}
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.
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:
<!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:
- 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.