G GAMESRICH
Retro Games · Intermediate

How to Build a 2D Platformer in HTML5 Canvas: Hang Tuah

Learn side-scrolling physics, platform collision detection, enemy stomping, and Web Audio sound effects from scratch.

By GamesRich Engineering 8 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 game runs on an HTML5 <canvas> (960x540) driven by requestAnimationFrame. The world width is 3800px. The player possesses position (x, y), velocity (vx, vy), and grounded state. Each frame: 1. Input modifies velocity: vx += (right - left) * accel * dt 2. Gravity pulls downward: vy += gravity * dt 3. AABB collision tests verify if the player landed on any platform top edge 4. Camera position smoothly interpolates toward player.x - 280 5. Parallax background layers and platforms are drawn offset by -cam.

Key Game Mechanics Taught:

Step-by-Step Implementation

1

Initialize HTML Canvas and Responsive Viewport

Create a fixed aspect-ratio canvas container. By giving the canvas a logical coordinate space of 960x540 and setting CSS width to 100% with aspect-ratio: 16/9, the game automatically scales crisply on desktops, laptops, and mobile screens.

Step 1 Implementation
<!-- index.html -->
<div id="gameWrap" style="max-width:960px;margin:auto;position:relative;">
  <canvas id="gameCanvas" width="960" height="540" style="width:100%;display:block;background:#438ecb;"></canvas>
  <div id="uiOverlay" style="position:absolute;top:15px;left:15px;color:#fff;font-family:sans-serif;font-weight:bold;">
    SCORE: <span id="scoreEl">0</span> | LIVES: <span id="livesEl">3</span>
  </div>
</div>
2

Define Physics State & Platform Data

Platforms are defined as an array of rectangles: [x, y, width, height]. The player has position (x, y), dimensions (w, h), velocity (vx, vy), and state flags.

Step 2 Implementation
const canvas = document.querySelector('#gameCanvas');
const ctx = canvas.getContext('2d');
const W = 3800, G = 460; // Level width and ground baseline

const player = {
  x: 100, y: 300, w: 38, h: 56,
  vx: 0, vy: 0, ground: false,
  score: 0, lives: 3
};

const platforms = [
  [0, G, 700, 80],
  [780, G, 500, 80],
  [1360, G, 700, 80],
  [420, 350, 180, 24],
  [880, 330, 150, 24],
  [1120, 270, 140, 24]
];

const coins = [
  { x: 470, y: 300, got: false },
  { x: 915, y: 280, got: false },
  { x: 1165, y: 220, got: false }
];
3

Implement AABB One-Way Platform Collision

To allow the player to jump up through air platforms or land cleanly on solid ground, store the player's previous Y position. Collision occurs only when the player is descending (vy >= 0) and their previous feet were at or above the platform surface.

Step 3 Implementation
function updatePhysics(dt) {
  const oldY = player.y;

  // Horizontal acceleration & friction
  player.vx += (keys.right - keys.left) * 1800 * dt;
  player.vx *= Math.pow(0.0015, dt); // Smooth exponential damping
  player.vx = Math.max(-310, Math.min(310, player.vx));

  // Jump impulse
  if (keys.jump && player.ground) {
    player.vy = -650;
    player.ground = false;
    playTone(520, 0.08);
  }
  keys.jump = 0; // Consume jump press

  // Gravity
  player.vy += 1750 * dt;
  player.x += player.vx * dt;
  player.y += player.vy * dt;

  // Platform collision resolution
  player.ground = false;
  platforms.forEach(([px, py, pw, ph]) => {
    const horizontalOverlap = player.x + player.w > px && player.x < px + pw;
    const wasAbove = oldY + player.h <= py + 8;
    const isNowInside = player.y + player.h >= py;

    if (horizontalOverlap && wasAbove && isNowInside && player.vy >= 0) {
      player.y = py - player.h;
      player.vy = 0;
      player.ground = true;
    }
  });

  // Pit fall check
  if (player.y > 600) {
    player.lives--;
    respawnPlayer();
  }
}
4

Enemy Patrol AI and Mario-Style Stomp Mechanics

Enemies patrol between platform bounds. When the player overlaps an enemy bounding box, test if the player was falling (vy > 120) with previous feet above the enemy's head. If so, eliminate the enemy and bounce the player up! Otherwise, the player takes damage.

Step 4 Implementation
const foes = [
  { x: 620, y: 424, w: 40, h: 36, dir: 1, alive: true }
];

function updateEnemies(dt, oldY) {
  foes.forEach(e => {
    if (!e.alive) return;
    e.x += e.dir * 65 * dt;
    if (e.x < 100 || e.x > 3700) e.dir *= -1;

    // AABB hit check
    if (player.x < e.x + e.w && player.x + player.w > e.x &&
        player.y < e.y + e.h && player.y + player.h > e.y) {
      
      // Stomp check: falling downwards onto enemy crown
      if (player.vy > 120 && oldY + player.h < e.y + 14) {
        e.alive = false;
        player.vy = -400; // Bounce up!
        player.score += 200;
        playTone(180, 0.1);
      } else {
        // Player touched enemy side: lose life
        player.lives--;
        playTone(90, 0.25);
        respawnPlayer();
      }
    }
  });
}
5

Synthesize Procedural Sound with Web Audio API

Zero audio assets are needed! The Web Audio API generates crisp retro bleeps, jump boings, and coin chimes using oscillator nodes ramped down with gain envelopes.

Step 5 Implementation
let audioCtx;
function playTone(freq = 440, duration = 0.08, type = 'square') {
  audioCtx ||= new (window.AudioContext || window.webkitAudioContext)();
  const osc = audioCtx.createOscillator();
  const gain = audioCtx.createGain();
  osc.type = type;
  osc.frequency.setValueAtTime(freq, audioCtx.currentTime);
  gain.gain.setValueAtTime(0.04, audioCtx.currentTime);
  gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + duration);
  osc.connect(gain).connect(audioCtx.destination);
  osc.start();
  osc.stop(audioCtx.currentTime + duration);
}

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>Hang Tuah: Minimal Standalone Platformer</title>
<style>
  body { margin:0; background:#111; display:flex; justify-content:center; align-items:center; height:100vh; font-family:sans-serif; }
  canvas { background:#438ecb; box-shadow:0 10px 30px rgba(0,0,0,0.5); }
</style>
</head>
<body>
<canvas id="c" width="960" height="540"></canvas>
<script>
const c = document.querySelector('#c'), x = c.getContext('2d');
const K = { left:0, right:0, jump:0 };
const P = { x:100, y:300, w:36, h:54, vx:0, vy:0, ground:false, score:0, lives:3 };
let cam = 0, last = performance.now();
const plats = [[0,460,700,80],[780,460,500,80],[1360,460,700,80],[420,350,180,24],[880,320,150,24]];
const coins = [{x:470,y:300,got:0},{x:915,y:270,got:0}];
const foes = [{x:620,y:424,w:40,h:36,dir:1,alive:1}];

window.onkeydown = e => {
  if (['ArrowLeft','a','A'].includes(e.key)) K.left = 1;
  if (['ArrowRight','d','D'].includes(e.key)) K.right = 1;
  if (['ArrowUp','w','W',' '].includes(e.key)) K.jump = 1;
};
window.onkeyup = e => {
  if (['ArrowLeft','a','A'].includes(e.key)) K.left = 0;
  if (['ArrowRight','d','D'].includes(e.key)) K.right = 0;
};

function loop(t) {
  const dt = Math.min((t - last) / 1000, 0.03);
  last = t;
  const oldY = P.y;
  P.vx += (K.right - K.left) * 1800 * dt;
  P.vx *= Math.pow(0.002, dt);
  if (K.jump && P.ground) { P.vy = -650; P.ground = false; }
  K.jump = 0;
  P.vy += 1750 * dt;
  P.x += P.vx * dt; P.y += P.vy * dt;
  P.ground = false;
  plats.forEach(p => {
    if (P.x + P.w > p[0] && P.x < p[0] + p[2] && oldY + P.h <= p[1] + 8 && P.y + P.h >= p[1] && P.vy >= 0) {
      P.y = p[1] - P.h; P.vy = 0; P.ground = true;
    }
  });
  if (P.y > 600) { P.x = 100; P.y = 300; P.vx = P.vy = 0; }
  coins.forEach(q => {
    if (!q.got && Math.hypot(P.x + 18 - q.x, P.y + 27 - q.y) < 36) { q.got = 1; P.score += 100; }
  });
  foes.forEach(e => {
    if (!e.alive) return;
    e.x += e.dir * 60 * dt;
    if (e.x < 300 || e.x > 680) e.dir *= -1;
    if (P.x < e.x + e.w && P.x + P.w > e.x && P.y < e.y + e.h && P.y + P.h > e.y) {
      if (P.vy > 100 && oldY + P.h < e.y + 16) { e.alive = 0; P.vy = -400; P.score += 200; }
      else { P.x = 100; P.y = 300; P.vx = P.vy = 0; }
    }
  });
  cam += (Math.max(0, P.x - 280) - cam) * 0.08;

  // Draw scene
  x.fillStyle = '#438ecb'; x.fillRect(0,0,960,540);
  plats.forEach(p => {
    x.fillStyle = '#8b4d2e'; x.fillRect(p[0] - cam, p[1], p[2], p[3]);
    x.fillStyle = '#51a44d'; x.fillRect(p[0] - cam, p[1], p[2], 12);
  });
  coins.forEach(q => {
    if (q.got) return;
    x.fillStyle = '#ffca28'; x.beginPath(); x.arc(q.x - cam, q.y, 10, 0, 7); x.fill();
  });
  foes.forEach(e => {
    if (!e.alive) return;
    x.fillStyle = '#b91c1c'; x.fillRect(e.x - cam, e.y, e.w, e.h);
  });
  x.fillStyle = '#ed7335'; x.fillRect(P.x - cam, P.y, P.w, P.h);
  x.fillStyle = '#fff'; x.font = 'bold 16px sans-serif';
  x.fillText('SCORE: ' + P.score, 20, 30);
  requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
</script>
</body>
</html>

Ideas to Expand Your Game

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