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:
- Delta-time physics integration (gravity & horizontal friction)
- Axis-Aligned Bounding Box (AABB) platform landing math
- Smooth camera tracking across a multi-screen level width
- Enemy patrol state machine & Mario-style stomp detection
- Zero-asset 8-bit sound synthesis using Web Audio API
Step-by-Step Implementation
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.
<!-- 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>
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.
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 }
];
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.
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();
}
}
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.
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();
}
}
});
}
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.
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:
<!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:
- 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.