Double jump, wall slide & wall jump, a parallax camera, an ASCII-string level, patrol-and-stomp enemies, spikes, coins, a finish flag and a speedrun timer , one file. Save it as platformer2.htmlplatformer2.html, open it, and play. Stuck on a step? Compare your file against this.
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Sky Climb - Platformer</title>
<style>
body { display:flex; flex-direction:column; align-items:center; gap:8px;
background:#0d1117; color:#e8eaf2; font-family:system-ui, sans-serif; padding:16px; }
canvas { background:#10162a; border:2px solid #1e2733; border-radius:8px; }
</style>
</head>
<body>
<h2>๐๏ธ Sky Climb</h2>
<canvas id="game" width="800" height="480"></canvas>
<div>โ โ move ยท โ / Space jump (double + wall jump) ยท R restart</div>
<script>
const canvas = document.getElementById("game");
const ctx = canvas.getContext("2d");
const TILE = 40;
const levelIndex = 0;
// # ground, = platform, ^ spike, o coin, E enemy, F flag
const LEVEL = [
" ",
" F ",
" === ",
" o o ",
" == == === ",
" o o ",
" === ^^ E == ",
" === ",
" E ^^ ",
" == o == ",
" === === ",
"########################################",
];
const LEVEL_W = LEVEL[0].length * TILE;
let player, keys, platforms, coins, spikes, enemies, flag;
let jumpsLeft, onWall, wallKick, score, dead, won, msg, startTime;
function makeEnemy(x, y) { return { x:x, y:y+10, w:28, h:28, vx:1.2, x0:x, range:70, alive:true }; }
function parse(level) {
platforms = []; coins = []; spikes = []; enemies = []; flag = null;
for (let r = 0; r < level.length; r++)
for (let c = 0; c < level[r].length; c++) {
const ch = level[r][c];
const x = c*TILE, y = r*TILE;
if (ch === "#" || ch === "=") platforms.push({ x:x, y:y, w:TILE, h:TILE });
if (ch === "o") coins.push({ x:x+11, y:y+11, got:false });
if (ch === "^") spikes.push({ x:x, y:y+20 });
if (ch === "E") enemies.push(makeEnemy(x, y));
if (ch === "F") flag = { x:x, y:y };
}
}
function start() {
parse(LEVEL);
player = { x: 60, y: 360, vx:0, vy:0, w:30, h:40, onGround:false };
keys = {};
jumpsLeft = 2; onWall = 0; wallKick = 0; score = 0;
dead = false; won = false; msg = "";
startTime = performance.now();
}
function overlap(a, b) {
return a.x < b.x + (b.w||40) && a.x + a.w > b.x &&
a.y < b.y + (b.h||40) && a.y + a.h > b.y;
}
function respawn() { player.x = 60; player.y = 360; player.vx = 0; player.vy = 0; }
document.addEventListener("keyup", e => keys[e.key] = false);
document.addEventListener("keydown", e => {
keys[e.key] = true;
if (e.key === "r" || e.key === "R") { start(); return; }
if (e.key === "ArrowUp" || e.key === " ") {
if (won || dead) return;
if (onWall !== 0 && !player.onGround) { // wall jump
player.vy = -10; player.vx = -onWall * 6; wallKick = 12; jumpsLeft = 1;
} else if (jumpsLeft > 0) { // ground / double jump
player.vy = (jumpsLeft === 2) ? -11 : -9;
jumpsLeft--; player.onGround = false;
}
}
});
canvas.addEventListener("click", () => { if (won || dead) start(); });
function update() {
if (won || dead) return;
// horizontal move (wallKick keeps the kick for a few frames)
if (wallKick > 0) wallKick--;
else {
player.vx = 0;
if (keys["ArrowLeft"]) player.vx = -4;
if (keys["ArrowRight"]) player.vx = 4;
}
player.x += player.vx;
if (player.x < 0) player.x = 0;
onWall = 0;
for (const p of platforms) {
if (overlap(player, p)) {
if (player.vx > 0) { player.x = p.x - player.w; onWall = 1; }
else if (player.vx < 0) { player.x = p.x + p.w; onWall = -1; }
}
}
// gravity + vertical
player.vy += 0.5;
player.y += player.vy;
player.onGround = false;
for (const p of platforms) {
if (overlap(player, p)) {
if (player.vy > 0) { player.y = p.y - player.h; player.vy = 0; player.onGround = true; jumpsLeft = 2; }
else if (player.vy < 0) { player.y = p.y + p.h; player.vy = 0; }
}
}
// wall slide
if (onWall !== 0 && !player.onGround && player.vy > 1.5) player.vy = 1.5;
// fell off the bottom
if (player.y > 480 + 80) respawn();
// enemies patrol + stomp
for (const en of enemies) {
if (!en.alive) continue;
en.x += en.vx;
if (en.x > en.x0 + en.range || en.x < en.x0 - en.range) en.vx = -en.vx;
if (overlap(player, en)) {
if (player.vy > 0 && (player.y + player.h - en.y) < 20) { en.alive = false; player.vy = -8; score += 5; }
else respawn();
}
}
// spikes
for (const s of spikes) if (overlap(player, { x:s.x, y:s.y, w:TILE, h:20 })) respawn();
// coins
for (const c of coins) if (!c.got && overlap(player, { x:c.x, y:c.y, w:18, h:18 })) { c.got = true; score += 1; }
// flag
if (flag && overlap(player, { x:flag.x, y:flag.y, w:TILE, h:TILE })) {
won = true;
const secs = ((performance.now() - startTime) / 1000).toFixed(1);
const key = "plat2-best-" + levelIndex;
const best = Number(localStorage.getItem(key) || 9999);
if (Number(secs) < best) localStorage.setItem(key, secs);
msg = "Level clear in " + secs + "s (best " + Math.min(Number(secs), best) + "s)";
}
}
function drawClouds(off) {
ctx.fillStyle = "rgba(255,255,255,.08)";
for (let i = 0; i < 6; i++) {
const x = ((i*260 + off) % (LEVEL_W)) ;
ctx.beginPath(); ctx.arc(x, 70 + (i%2)*30, 26, 0, Math.PI*2); ctx.fill();
}
}
function drawHills(off) {
ctx.fillStyle = "rgba(80,120,90,.25)";
for (let i = 0; i < 8; i++) {
const x = i*200 + off;
ctx.beginPath(); ctx.arc(x, 480, 120, Math.PI, 0); ctx.fill();
}
}
function draw() {
ctx.clearRect(0, 0, 800, 480);
const camX = Math.max(0, Math.min(LEVEL_W - 800, player.x - 400));
drawClouds(-camX * 0.1);
drawHills(-camX * 0.3);
ctx.save();
ctx.translate(-camX, 0);
// platforms
ctx.fillStyle = "#3b4a63";
for (const p of platforms) ctx.fillRect(p.x, p.y, p.w, p.h);
// spikes
ctx.fillStyle = "#ff5470";
for (const s of spikes) {
ctx.beginPath();
ctx.moveTo(s.x, s.y+20); ctx.lineTo(s.x+20, s.y); ctx.lineTo(s.x+40, s.y+20);
ctx.closePath(); ctx.fill();
}
// coins
ctx.fillStyle = "#ffd166";
for (const c of coins) if (!c.got) { ctx.beginPath(); ctx.arc(c.x+9, c.y+9, 8, 0, Math.PI*2); ctx.fill(); }
// enemies
ctx.fillStyle = "#a855f7";
for (const en of enemies) if (en.alive) ctx.fillRect(en.x, en.y, en.w, en.h);
// flag
if (flag) { ctx.fillStyle = "#4ade80"; ctx.fillRect(flag.x+6, flag.y, 6, 40); ctx.fillRect(flag.x+12, flag.y, 18, 12); }
// player
ctx.fillStyle = "#60a5fa";
ctx.fillRect(player.x, player.y, player.w, player.h);
ctx.restore();
// HUD
ctx.fillStyle = "#e8eaf2";
ctx.font = "16px system-ui"; ctx.textAlign = "left";
ctx.fillText("Score " + score, 12, 26);
const t = won ? msg : ((performance.now() - startTime)/1000).toFixed(1) + "s";
ctx.textAlign = "right"; ctx.fillText(t, 788, 26);
if (won) {
ctx.fillStyle = "rgba(0,0,0,.6)"; ctx.fillRect(0,0,800,480);
ctx.fillStyle = "#fff"; ctx.textAlign = "center";
ctx.font = "bold 36px system-ui"; ctx.fillText("You Win! ๐", 400, 220);
ctx.font = "18px system-ui"; ctx.fillText(msg, 400, 258);
ctx.fillText("press R to play again", 400, 288);
}
}
function loop() { update(); draw(); requestAnimationFrame(loop); }
start();
requestAnimationFrame(loop);
</script>
</body>
</html><!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Sky Climb - Platformer</title>
<style>
body { display:flex; flex-direction:column; align-items:center; gap:8px;
background:#0d1117; color:#e8eaf2; font-family:system-ui, sans-serif; padding:16px; }
canvas { background:#10162a; border:2px solid #1e2733; border-radius:8px; }
</style>
</head>
<body>
<h2>๐๏ธ Sky Climb</h2>
<canvas id="game" width="800" height="480"></canvas>
<div>โ โ move ยท โ / Space jump (double + wall jump) ยท R restart</div>
<script>
const canvas = document.getElementById("game");
const ctx = canvas.getContext("2d");
const TILE = 40;
const levelIndex = 0;
// # ground, = platform, ^ spike, o coin, E enemy, F flag
const LEVEL = [
" ",
" F ",
" === ",
" o o ",
" == == === ",
" o o ",
" === ^^ E == ",
" === ",
" E ^^ ",
" == o == ",
" === === ",
"########################################",
];
const LEVEL_W = LEVEL[0].length * TILE;
let player, keys, platforms, coins, spikes, enemies, flag;
let jumpsLeft, onWall, wallKick, score, dead, won, msg, startTime;
function makeEnemy(x, y) { return { x:x, y:y+10, w:28, h:28, vx:1.2, x0:x, range:70, alive:true }; }
function parse(level) {
platforms = []; coins = []; spikes = []; enemies = []; flag = null;
for (let r = 0; r < level.length; r++)
for (let c = 0; c < level[r].length; c++) {
const ch = level[r][c];
const x = c*TILE, y = r*TILE;
if (ch === "#" || ch === "=") platforms.push({ x:x, y:y, w:TILE, h:TILE });
if (ch === "o") coins.push({ x:x+11, y:y+11, got:false });
if (ch === "^") spikes.push({ x:x, y:y+20 });
if (ch === "E") enemies.push(makeEnemy(x, y));
if (ch === "F") flag = { x:x, y:y };
}
}
function start() {
parse(LEVEL);
player = { x: 60, y: 360, vx:0, vy:0, w:30, h:40, onGround:false };
keys = {};
jumpsLeft = 2; onWall = 0; wallKick = 0; score = 0;
dead = false; won = false; msg = "";
startTime = performance.now();
}
function overlap(a, b) {
return a.x < b.x + (b.w||40) && a.x + a.w > b.x &&
a.y < b.y + (b.h||40) && a.y + a.h > b.y;
}
function respawn() { player.x = 60; player.y = 360; player.vx = 0; player.vy = 0; }
document.addEventListener("keyup", e => keys[e.key] = false);
document.addEventListener("keydown", e => {
keys[e.key] = true;
if (e.key === "r" || e.key === "R") { start(); return; }
if (e.key === "ArrowUp" || e.key === " ") {
if (won || dead) return;
if (onWall !== 0 && !player.onGround) { // wall jump
player.vy = -10; player.vx = -onWall * 6; wallKick = 12; jumpsLeft = 1;
} else if (jumpsLeft > 0) { // ground / double jump
player.vy = (jumpsLeft === 2) ? -11 : -9;
jumpsLeft--; player.onGround = false;
}
}
});
canvas.addEventListener("click", () => { if (won || dead) start(); });
function update() {
if (won || dead) return;
// horizontal move (wallKick keeps the kick for a few frames)
if (wallKick > 0) wallKick--;
else {
player.vx = 0;
if (keys["ArrowLeft"]) player.vx = -4;
if (keys["ArrowRight"]) player.vx = 4;
}
player.x += player.vx;
if (player.x < 0) player.x = 0;
onWall = 0;
for (const p of platforms) {
if (overlap(player, p)) {
if (player.vx > 0) { player.x = p.x - player.w; onWall = 1; }
else if (player.vx < 0) { player.x = p.x + p.w; onWall = -1; }
}
}
// gravity + vertical
player.vy += 0.5;
player.y += player.vy;
player.onGround = false;
for (const p of platforms) {
if (overlap(player, p)) {
if (player.vy > 0) { player.y = p.y - player.h; player.vy = 0; player.onGround = true; jumpsLeft = 2; }
else if (player.vy < 0) { player.y = p.y + p.h; player.vy = 0; }
}
}
// wall slide
if (onWall !== 0 && !player.onGround && player.vy > 1.5) player.vy = 1.5;
// fell off the bottom
if (player.y > 480 + 80) respawn();
// enemies patrol + stomp
for (const en of enemies) {
if (!en.alive) continue;
en.x += en.vx;
if (en.x > en.x0 + en.range || en.x < en.x0 - en.range) en.vx = -en.vx;
if (overlap(player, en)) {
if (player.vy > 0 && (player.y + player.h - en.y) < 20) { en.alive = false; player.vy = -8; score += 5; }
else respawn();
}
}
// spikes
for (const s of spikes) if (overlap(player, { x:s.x, y:s.y, w:TILE, h:20 })) respawn();
// coins
for (const c of coins) if (!c.got && overlap(player, { x:c.x, y:c.y, w:18, h:18 })) { c.got = true; score += 1; }
// flag
if (flag && overlap(player, { x:flag.x, y:flag.y, w:TILE, h:TILE })) {
won = true;
const secs = ((performance.now() - startTime) / 1000).toFixed(1);
const key = "plat2-best-" + levelIndex;
const best = Number(localStorage.getItem(key) || 9999);
if (Number(secs) < best) localStorage.setItem(key, secs);
msg = "Level clear in " + secs + "s (best " + Math.min(Number(secs), best) + "s)";
}
}
function drawClouds(off) {
ctx.fillStyle = "rgba(255,255,255,.08)";
for (let i = 0; i < 6; i++) {
const x = ((i*260 + off) % (LEVEL_W)) ;
ctx.beginPath(); ctx.arc(x, 70 + (i%2)*30, 26, 0, Math.PI*2); ctx.fill();
}
}
function drawHills(off) {
ctx.fillStyle = "rgba(80,120,90,.25)";
for (let i = 0; i < 8; i++) {
const x = i*200 + off;
ctx.beginPath(); ctx.arc(x, 480, 120, Math.PI, 0); ctx.fill();
}
}
function draw() {
ctx.clearRect(0, 0, 800, 480);
const camX = Math.max(0, Math.min(LEVEL_W - 800, player.x - 400));
drawClouds(-camX * 0.1);
drawHills(-camX * 0.3);
ctx.save();
ctx.translate(-camX, 0);
// platforms
ctx.fillStyle = "#3b4a63";
for (const p of platforms) ctx.fillRect(p.x, p.y, p.w, p.h);
// spikes
ctx.fillStyle = "#ff5470";
for (const s of spikes) {
ctx.beginPath();
ctx.moveTo(s.x, s.y+20); ctx.lineTo(s.x+20, s.y); ctx.lineTo(s.x+40, s.y+20);
ctx.closePath(); ctx.fill();
}
// coins
ctx.fillStyle = "#ffd166";
for (const c of coins) if (!c.got) { ctx.beginPath(); ctx.arc(c.x+9, c.y+9, 8, 0, Math.PI*2); ctx.fill(); }
// enemies
ctx.fillStyle = "#a855f7";
for (const en of enemies) if (en.alive) ctx.fillRect(en.x, en.y, en.w, en.h);
// flag
if (flag) { ctx.fillStyle = "#4ade80"; ctx.fillRect(flag.x+6, flag.y, 6, 40); ctx.fillRect(flag.x+12, flag.y, 18, 12); }
// player
ctx.fillStyle = "#60a5fa";
ctx.fillRect(player.x, player.y, player.w, player.h);
ctx.restore();
// HUD
ctx.fillStyle = "#e8eaf2";
ctx.font = "16px system-ui"; ctx.textAlign = "left";
ctx.fillText("Score " + score, 12, 26);
const t = won ? msg : ((performance.now() - startTime)/1000).toFixed(1) + "s";
ctx.textAlign = "right"; ctx.fillText(t, 788, 26);
if (won) {
ctx.fillStyle = "rgba(0,0,0,.6)"; ctx.fillRect(0,0,800,480);
ctx.fillStyle = "#fff"; ctx.textAlign = "center";
ctx.font = "bold 36px system-ui"; ctx.fillText("You Win! ๐", 400, 220);
ctx.font = "18px system-ui"; ctx.fillText(msg, 400, 258);
ctx.fillText("press R to play again", 400, 288);
}
}
function loop() { update(); draw(); requestAnimationFrame(loop); }
start();
requestAnimationFrame(loop);
</script>
</body>
</html>