How to Make a Platformer Game
How to Vibe Code a Crisp 2D Platformer Game
Stay in your creative flow. Direct AI models to tune tight jump physics, moving platforms, patrolling enemies, and collectible coinsโand ship a multi-level 2D platformer in your browser in under 20 minutes.
Core Stack
HTML5 Canvas + JS
Physics Feel
Coyote Time + Stomp
AI Partner
Claude / Gemini
Target Time
15โ20 Mins
The Vibe Coding Platformer Philosophy
A platformer lives or dies by how good it feels to jump. In traditional development, beginners spend days fighting sticky wall collisions and external tilemap importers before tuning their hero's jump arc. Vibe coding lets you operate as the Level & Movement Director: you ask for variable jump height, 6-frame coyote time, moving platforms, and Mario-style enemy head stomps while AI generates the physics solver and ASCII level loader in one file.
Old-School Platformer Stress
- Getting stuck on the side edges of platforms because X and Y collisions weren't separated.
- Stiff, robotic jump arcs with zero air control or ledge forgiveness.
- Wrestling external level editors and broken JSON paths before building Level 1.
- Player sliding off moving platforms instead of riding along with them.
The Vibe Coder Way
- Design levels right in code using readable ASCII strings or clean platform arrays.
- Prompt for pro game feel: "Add coyote time, jump buffering, and squash-and-stretch on landing."
- Stomp enemies from above for a high bounce, collect all coins, and unlock the exit portal.
- Instant playable HTML5 Canvas that you can upload to itch.io or GameJolt in 60 seconds.
The 4-Step Blueprint
From a single jumping hero to multi-stage levels with enemies, hazards, and exit goals.
Nail the Jump & AABB Physics
Create a single index.html file. Start with a responsive hero character with horizontal acceleration, friction, and crisp gravity on an HTML5 Canvas.
Design Your Levels & Moving Platforms
Lay out static ledges, horizontally/vertically moving platforms, spike hazards, bottomless pits, coin arcs, and a glowing level exit portal.
Add Patrolling Enemies & Hero Upgrades
Populate ledges with patrolling creatures. Touching an enemy from the side deals damage/knockback, while landing on their head defeats them with a bounce and XP/score boost.
Playtest & Publish to itch.io
String 3 levels together culminating in a boss or trophy screen, test every jump gap, and upload your index.html directly to itch.io or GameJolt.
Vibe Prompt Configurator
Choose your platformer world style, movement upgrades, and level mechanics. Copy the generated prompt into Claude, Cursor, or Gemini.
Essential Elements of a Platformer Game
Whether 2D side-scrolling or 3D, great platformer level design combines these core elements to challenge and delight players:
-
Static & Moving Platforms
Combine solid stepping stones with horizontally and vertically moving platforms that test timing, momentum, and spatial awareness.
-
Obstacles, Spikes & Pits
Place spike traps, lava hazards, and bottomless pits strategically to create exciting risk-reward platforming sequences.
-
Patrolling Enemies & Bosses
Populate levels with creatures that patrol ledges, fire projectiles, or knock the player backโculminating in memorable boss encounters.
-
Coins, XP & Hero Leveling
Collecting coins and defeating enemies rewards XP and points that upgrade the hero's capabilities (like double-jump speed or extra hearts).
-
Clear Level Exit & Stage Goals
Give every stage a rewarding finish lineโsuch as unlocking a portal by collecting coins or reaching the top flagpole to advance to the next level.
-
Tight Jump Physics & Camera Follow
Responsive air control, coyote time, and a smooth side-scrolling camera ensure the player always feels in complete command of every jump.
The "Golden" Single-File 2D Platformer Template
Save this as index.html and open it in any browser to play a multi-stage 2D Platformer with Moving Platforms, Enemy Stomps, Coins, Spikes, and Exit Portals.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vibe Platformer: Sky Isles Quest</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; user-select: none; }
body {
background: #080d1a;
color: #f8fafc;
font-family: system-ui, -apple-system, sans-serif;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 12px;
}
.hud {
width: 880px;
max-width: 100%;
display: flex;
justify-content: space-between;
background: #131c31;
border: 1px solid #283655;
padding: 8px 16px;
border-radius: 10px;
margin-bottom: 8px;
font-size: 14px;
font-weight: 700;
}
canvas {
border: 2px solid #38bdf8;
border-radius: 14px;
box-shadow: 0 20px 50px rgba(0,0,0,0.65);
max-width: 100%;
height: auto;
background: linear-gradient(180deg, #0f172a 0%, #1e293b 100%);
}
</style>
</head>
<body>
<div class="hud">
<span id="hud-left">๐ Stage 1/2 | โค๏ธ Lives: 3 | ๐ช Coins: 0/5</span>
<span>Move: <kbd>A</kbd>/<kbd>D</kbd> or <kbd>Arrows</kbd> โข Jump / Double-Jump: <kbd>W</kbd>/<kbd>Space</kbd></span>
<span id="hud-right">โญ XP Score: 0</span>
</div>
<canvas id="game" width="880" height="460"></canvas>
<script>
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const hudLeft = document.getElementById('hud-left');
const hudRight = document.getElementById('hud-right');
const keys = {};
const LEVELS = [
{
platforms: [
{ x: 0, y: 400, w: 320, h: 60 },
{ x: 370, y: 340, w: 140, h: 20, vx: 2, minX: 350, maxX: 520 },
{ x: 580, y: 280, w: 190, h: 22 },
{ x: 830, y: 360, w: 260, h: 100 },
{ x: 1150, y: 290, w: 150, h: 20, vx: 2.4, minX: 1120, maxX: 1320 },
{ x: 1400, y: 230, w: 280, h: 230 }
],
spikes: [
{ x: 220, y: 384, w: 45, h: 16 },
{ x: 930, y: 344, w: 60, h: 16 }
],
enemies: [
{ x: 610, y: 248, w: 30, h: 32, vx: 1.6, minX: 585, maxX: 735, alive: true },
{ x: 860, y: 328, w: 30, h: 32, vx: 2.0, minX: 840, maxX: 1050, alive: true }
],
coins: [
{ x: 160, y: 350, taken: false },
{ x: 440, y: 290, taken: false },
{ x: 670, y: 235, taken: false },
{ x: 960, y: 300, taken: false },
{ x: 1240, y: 235, taken: false }
],
goal: { x: 1580, y: 155, w: 44, h: 75 }
},
{
platforms: [
{ x: 0, y: 400, w: 230, h: 60 },
{ x: 280, y: 330, w: 130, h: 20, vx: 2.6, minX: 260, maxX: 460 },
{ x: 540, y: 260, w: 130, h: 20, vx: -2.6, minX: 500, maxX: 690 },
{ x: 760, y: 350, w: 280, h: 110 },
{ x: 1100, y: 270, w: 160, h: 22 },
{ x: 1330, y: 200, w: 300, h: 260 }
],
spikes: [
{ x: 850, y: 334, w: 60, h: 16 },
{ x: 1400, y: 184, w: 45, h: 16 }
],
enemies: [
{ x: 780, y: 318, w: 30, h: 32, vx: 2.2, minX: 765, maxX: 1005, alive: true },
{ x: 1120, y: 238, w: 30, h: 32, vx: 1.8, minX: 1105, maxX: 1225, alive: true },
{ x: 1470, y: 168, w: 30, h: 32, vx: 2.4, minX: 1455, maxX: 1590, alive: true }
],
coins: [
{ x: 350, y: 275, taken: false },
{ x: 600, y: 205, taken: false },
{ x: 880, y: 285, taken: false },
{ x: 1180, y: 220, taken: false },
{ x: 1510, y: 145, taken: false }
],
goal: { x: 1560, y: 125, w: 44, h: 75 }
}
];
let levelIdx = 0;
let lives = 3;
let score = 0;
let camX = 0;
let stage = null;
let wonGame = false;
let particles = [];
const player = {
x: 60, y: 300, w: 30, h: 40,
vx: 0, vy: 0,
grounded: false,
coyote: 0,
jumps: 0,
riding: null
};
function loadLevel(idx) {
levelIdx = idx;
stage = JSON.parse(JSON.stringify(LEVELS[idx]));
player.x = 60;
player.y = 310;
player.vx = 0;
player.vy = 0;
camX = 0;
updateHud();
}
function updateHud() {
const collected = stage.coins.filter(c => c.taken).length;
hudLeft.textContent = `๐ Stage ${levelIdx + 1}/${LEVELS.length} | โค๏ธ Lives: ${lives} | ๐ช Coins: ${collected}/${stage.coins.length}`;
hudRight.textContent = `โญ XP Score: ${score}`;
}
function burst(x, y, color) {
for (let i = 0; i < 10; i++) {
particles.push({
x, y,
vx: (Math.random() - 0.5) * 7,
vy: (Math.random() - 0.7) * 7,
color, life: 22
});
}
}
function loseLife() {
lives--;
burst(player.x, player.y, '#f43f5e');
if (lives <= 0) {
lives = 3;
score = 0;
loadLevel(0);
} else {
player.x = 60;
player.y = 300;
player.vx = 0;
player.vy = 0;
updateHud();
}
}
window.addEventListener('keydown', e => {
keys[e.code] = true;
if (wonGame && e.code === 'Space') {
wonGame = false;
lives = 3;
score = 0;
loadLevel(0);
return;
}
if (e.code === 'Space' || e.code === 'KeyW' || e.code === 'ArrowUp') {
e.preventDefault();
if (player.coyote > 0 || player.jumps < 2) {
player.vy = -12.2;
player.jumps++;
player.coyote = 0;
burst(player.x + player.w / 2, player.y + player.h, '#38bdf8');
}
}
});
window.addEventListener('keyup', e => { keys[e.code] = false; });
function overlap(a, b) {
return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y;
}
function update() {
if (wonGame) return;
// Move platforms
stage.platforms.forEach(pl => {
if (pl.vx) {
pl.x += pl.vx;
if (pl.x < pl.minX || pl.x + pl.w > pl.maxX) pl.vx *= -1;
}
});
if (player.riding && player.riding.vx) {
player.x += player.riding.vx;
}
// Horizontal movement
if (keys['KeyA'] || keys['ArrowLeft']) player.vx = -4.6;
else if (keys['KeyD'] || keys['ArrowRight']) player.vx = 4.6;
else player.vx *= 0.78;
player.x += player.vx;
stage.platforms.forEach(pl => {
if (overlap(player, pl)) {
if (player.vx > 0) player.x = pl.x - player.w;
else if (player.vx < 0) player.x = pl.x + pl.w;
}
});
// Vertical movement & gravity
const prevBottom = player.y + player.h;
player.vy = Math.min(13, player.vy + 0.6);
player.y += player.vy;
player.grounded = false;
player.riding = null;
stage.platforms.forEach(pl => {
if (overlap(player, pl)) {
if (player.vy >= 0 && prevBottom <= pl.y + 14) {
player.y = pl.y - player.h;
player.vy = 0;
player.grounded = true;
player.coyote = 6;
player.jumps = 1;
player.riding = pl;
} else if (player.vy < 0) {
player.y = pl.y + pl.h;
player.vy = 0;
}
}
});
if (!player.grounded && player.coyote > 0) player.coyote--;
// Bottomless pit check
if (player.y > canvas.height + 60) {
loseLife();
return;
}
// Spikes check
for (const sp of stage.spikes) {
if (overlap(player, sp)) {
loseLife();
return;
}
}
// Enemies patrol & stomp vs hurt
stage.enemies.forEach(en => {
if (!en.alive) return;
en.x += en.vx;
if (en.x < en.minX || en.x + en.w > en.maxX) en.vx *= -1;
if (overlap(player, en)) {
if (player.vy > 0 && prevBottom <= en.y + 16) {
en.alive = false;
player.vy = -10.5;
score += 150;
burst(en.x + 15, en.y + 15, '#a855f7');
updateHud();
} else {
loseLife();
}
}
});
// Coins
stage.coins.forEach(c => {
if (!c.taken && Math.hypot((player.x + 15) - c.x, (player.y + 20) - c.y) < 26) {
c.taken = true;
score += 100;
burst(c.x, c.y, '#facc15');
updateHud();
}
});
// Exit Goal Portal
if (overlap(player, stage.goal)) {
score += 300;
if (levelIdx + 1 < LEVELS.length) {
loadLevel(levelIdx + 1);
} else {
wonGame = true;
}
}
camX += ((player.x - 260) - camX) * 0.12;
camX = Math.max(0, Math.min(820, camX));
particles.forEach(p => {
p.x += p.vx;
p.y += p.vy;
p.life--;
});
particles = particles.filter(p => p.life > 0);
}
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.save();
ctx.translate(-camX, 0);
// Platforms
stage.platforms.forEach(pl => {
ctx.fillStyle = pl.vx ? '#0284c7' : '#334155';
ctx.beginPath();
ctx.roundRect(pl.x, pl.y, pl.w, pl.h, 6);
ctx.fill();
ctx.fillStyle = pl.vx ? '#38bdf8' : '#10b981';
ctx.fillRect(pl.x, pl.y, pl.w, 6);
});
// Spikes
stage.spikes.forEach(sp => {
ctx.fillStyle = '#f43f5e';
for (let x = sp.x; x < sp.x + sp.w; x += 15) {
ctx.beginPath();
ctx.moveTo(x, sp.y + sp.h);
ctx.lineTo(x + 7.5, sp.y);
ctx.lineTo(x + 15, sp.y + sp.h);
ctx.fill();
}
});
// Coins
stage.coins.forEach(c => {
if (c.taken) return;
ctx.fillStyle = '#facc15';
ctx.beginPath();
ctx.arc(c.x, c.y, 11, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = '#fef08a';
ctx.lineWidth = 2;
ctx.stroke();
});
// Exit Portal
const g = stage.goal;
ctx.fillStyle = '#a855f7';
ctx.beginPath();
ctx.roundRect(g.x, g.y, g.w, g.h, 20);
ctx.fill();
ctx.strokeStyle = '#f0abfc';
ctx.lineWidth = 3;
ctx.stroke();
// Enemies
stage.enemies.forEach(en => {
if (!en.alive) return;
ctx.fillStyle = '#fb7185';
ctx.beginPath();
ctx.roundRect(en.x, en.y, en.w, en.h, 8);
ctx.fill();
ctx.fillStyle = '#090d16';
ctx.fillRect(en.x + 6, en.y + 8, 5, 6);
ctx.fillRect(en.x + 19, en.y + 8, 5, 6);
});
// Hero
ctx.fillStyle = '#38bdf8';
ctx.beginPath();
ctx.roundRect(player.x, player.y, player.w, player.h, 8);
ctx.fill();
ctx.fillStyle = '#f8fafc';
ctx.fillRect(player.x + (player.vx >= 0 ? 16 : 6), player.y + 8, 8, 8);
particles.forEach(p => {
ctx.fillStyle = p.color;
ctx.fillRect(p.x, p.y, 5, 5);
});
ctx.restore();
if (wonGame) {
ctx.fillStyle = 'rgba(8, 13, 26, 0.85)';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#10b981';
ctx.font = 'bold 36px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('ALL LEVELS COMPLETED! ๐', canvas.width / 2, 210);
ctx.fillStyle = '#facc15';
ctx.font = '22px sans-serif';
ctx.fillText(`Total XP Score: ${score}`, canvas.width / 2, 255);
ctx.fillStyle = '#f8fafc';
ctx.font = '18px sans-serif';
ctx.fillText('Press SPACE to Play Again', canvas.width / 2, 300);
}
requestAnimationFrame(() => {
update();
draw();
});
}
loadLevel(0);
draw();
</script>
</body>
</html>
How to Debug Without Losing Your Vibe
Platformer physics can get tripped up by corner collisions and moving ledges. Use these three vibe fixes:
Separate X & Y Collision Passes
To stop your hero from getting stuck on the side of a floor tile, prompt the AI: "Move player.x and resolve horizontal platform collisions first; then apply gravity to player.y and resolve vertical collisions."
Stick to Moving Platforms
So the hero rides moving platforms instead of having them slide out from underneath, tell the AI: "Store a reference to the platform the player landed on and add platform.vx to player.x every frame."
Accurate Enemy Stomp Detection
Ensure head-stomps never accidentally count as player damage by prompting: "Count an enemy overlap as a Stomp whenever player.vy > 0 and the player's previous frame bottom was above enemy.y + 16."
The Vibe Prompt Vault
Copy-paste prompts to add a Mario Maker Level Editor, Wall-Jumps & Air-Dashes, or a Final Boss Fight.
๐ ๏ธ Prompt: Add a "Mario Maker" Live Level Editor Mode
"Extend my HTML5 Canvas platformer in index.html with an 'Edit / Play' toggle button at the top. In Edit Mode, let me click on the canvas to place or erase Platforms, Moving Ledges, Spikes, Patrolling Enemies, Coins, and the Exit Portal, and save custom levels to localStorage."
๐จ Prompt: Add Celeste-Style Wall-Slide, Wall-Jump & 8-Way Air Dash
"Upgrade my platformer hero's movement kit with two advanced mechanics: 1) Holding toward a vertical wall while airborne slows fall speed into a Wall-Slide and pressing Jump kicks off into a diagonal Wall-Jump. 2) Pressing Shift triggers an 8-directional Air Dash with afterimage ghosts that refreshes upon touching the ground."
๐ Prompt: Add a Stage 3 Boss Battle with Health Bar & Attack Phases
"Add a final Boss Arena stage to my 2D platformer featuring a giant King Slime boss with a top screen health bar (5 HP). Have the boss leap across the arena and fire radial shockwaves on landing, becoming vulnerable to head-stomps for 2 seconds after every 3rd jump."