How to make an HTML5 game in one file
A complete, copy-and-paste HTML game in a single file, with a canvas, a game loop, keyboard and touch controls, pause, restart, and a saved best score. Then make it yours.
Why one file?
A single-file HTML game keeps its HTML, CSS, JavaScript, images, and sound in one .html document. It opens by double-clicking, works offline, is easy to share, and anyone can read how it works. It is also exactly what Upload Arcade hosts: one self-contained file up to 2 MB. You need no engine, build step, or install, just a text editor and a browser.
The starter template
Copy the code below into a new file named star-catcher.html and open it in any modern browser. It is a complete game: catch the falling stars with the paddle using the arrow keys, A and D, or by dragging. Miss three and the run ends. P pauses, R restarts, and the buttons do the same for touch players.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Star Catcher</title>
<style>
html, body { margin: 0; height: 100%; overflow: hidden; touch-action: none;
background: #0a0f1e; color: #eaf1ff; font: 16px system-ui, sans-serif; }
canvas { display: block; width: 100%; height: 100%; }
#hud { position: fixed; top: 10px; left: 12px; right: 12px; display: flex;
justify-content: space-between; align-items: center; gap: 8px; }
button { font: inherit; color: inherit; background: #1b2540;
border: 1px solid #33406a; border-radius: 8px; padding: 6px 12px; }
</style>
</head>
<body>
<div id="hud">
<span>Score <b id="score">0</b> · Lives <b id="lives">3</b> · Best <b id="best">0</b></span>
<span>
<button id="pause" type="button">Pause</button>
<button id="restart" type="button">Restart</button>
</span>
</div>
<canvas id="game"></canvas>
<script>
const canvas = document.getElementById("game");
const ctx = canvas.getContext("2d");
const scoreEl = document.getElementById("score");
const livesEl = document.getElementById("lives");
const bestEl = document.getElementById("best");
const pauseBtn = document.getElementById("pause");
// Score bridge: the host page keeps the best score for you and sends
// it back. Opened as a plain local file, these messages do nothing.
window.addEventListener("message", (event) => {
const data = event.data;
if (data && data.type === "upload-arcade:best" && typeof data.best === "number") {
bestEl.textContent = data.best;
}
});
window.parent.postMessage({ type: "upload-arcade:hello" }, "*");
// Sharp drawing on any screen: size the canvas to its CSS box.
let w = 0, h = 0;
function resize() {
const dpr = window.devicePixelRatio || 1;
w = canvas.clientWidth;
h = canvas.clientHeight;
canvas.width = Math.round(w * dpr);
canvas.height = Math.round(h * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
window.addEventListener("resize", resize);
resize();
// Game state.
let paddleX = w / 2, pointerX = null, keys = new Set();
let stars = [], score = 0, lives = 3, spawnIn = 0;
let paused = false, over = false;
function restart() {
stars = []; score = 0; lives = 3; spawnIn = 0;
paused = false; over = false; paddleX = w / 2;
scoreEl.textContent = "0"; livesEl.textContent = "3";
pauseBtn.textContent = "Pause";
}
function togglePause() {
if (over) return;
paused = !paused;
pauseBtn.textContent = paused ? "Resume" : "Pause";
}
// Keyboard and touch/mouse input.
const keyName = (e) => (e.key.length === 1 ? e.key.toLowerCase() : e.key);
window.addEventListener("keydown", (e) => {
const key = keyName(e);
if (key === "p") togglePause();
else if (key === "r") restart();
else keys.add(key);
});
window.addEventListener("keyup", (e) => keys.delete(keyName(e)));
canvas.addEventListener("pointerdown", (e) => {
if (over) restart();
pointerX = e.clientX;
});
canvas.addEventListener("pointermove", (e) => {
if (pointerX !== null) pointerX = e.clientX;
});
canvas.addEventListener("pointerup", () => { pointerX = null; });
canvas.addEventListener("pointercancel", () => { pointerX = null; });
pauseBtn.addEventListener("click", togglePause);
document.getElementById("restart").addEventListener("click", restart);
function update(dt) {
if (keys.has("ArrowLeft") || keys.has("a")) paddleX -= 520 * dt;
if (keys.has("ArrowRight") || keys.has("d")) paddleX += 520 * dt;
if (pointerX !== null) paddleX += (pointerX - paddleX) * Math.min(1, dt * 14);
paddleX = Math.max(40, Math.min(w - 40, paddleX));
spawnIn -= dt;
if (spawnIn <= 0) {
stars.push({ x: 20 + Math.random() * (w - 40), y: -10, vy: 150 + score * 6 });
spawnIn = Math.max(0.35, 1.1 - score * 0.02);
}
const paddleY = h - 40;
stars = stars.filter((star) => {
const before = star.y;
star.y += star.vy * dt;
const crossed = before < paddleY && star.y >= paddleY;
if (crossed && Math.abs(star.x - paddleX) < 48) {
score += 1;
scoreEl.textContent = score;
return false;
}
if (star.y > h + 10) {
lives -= 1;
livesEl.textContent = lives;
return false;
}
return true;
});
if (lives <= 0) {
over = true;
// Report the finished run; the host keeps the best one.
window.parent.postMessage({ type: "upload-arcade:score", score }, "*");
}
}
function draw() {
ctx.clearRect(0, 0, w, h);
ctx.fillStyle = "#ffb43a";
for (const star of stars) {
ctx.beginPath();
ctx.arc(star.x, star.y, 8, 0, Math.PI * 2);
ctx.fill();
}
ctx.fillStyle = "#38e0d4";
ctx.fillRect(paddleX - 40, h - 40, 80, 10);
if (paused || over) {
ctx.fillStyle = "#eaf1ff";
ctx.textAlign = "center";
ctx.font = "bold 24px system-ui, sans-serif";
ctx.fillText(over ? "Game over: tap or press R" : "Paused", w / 2, h / 2);
}
}
// The game loop: cap the time step so a background tab cannot jump.
let last = performance.now();
function frame(now) {
const dt = Math.min(0.033, (now - last) / 1000);
last = now;
if (!paused && !over) update(dt);
draw();
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
</script>
</body>
</html>
How the template works
Three pieces make almost every small browser game. First, a canvas sized to its on-screen box and multiplied by devicePixelRatio, so drawing stays sharp on phones and high-density screens. Second, a game loop driven by requestAnimationFrame that measures the time since the last frame and caps it, so movement runs at the same speed on every screen and does not jump after a background tab wakes up. Third, input: keydown and keyup track held keys, while pointer events cover mouse, touch, and pen with one set of handlers. Game state lives in a few plain variables that restart() puts back to their starting values.
Make it yours
Change the numbers first: the spawn interval, star speed, and paddle width set the difficulty curve. Then swap the shapes for your own art. Draw it with canvas calls, or embed small PNG or SVG images as data: URIs so the file stays self-contained. Add sound with the Web Audio API (an oscillator and a gain node make convincing beeps without any audio files), and start audio only after the first tap or key press, because browsers block sound until the player interacts. Keep a visible way to pause, restart, and mute.
Keep it upload-ready
Uploaded games run in a sandbox with no network and no lasting storage, so avoid fetch, external scripts, fonts, or images, and do not rely on localStorage. To keep a best score, post a message to the host instead: the template sends {type: "upload-arcade:score", score} when a run ends, and the host replies with {type: "upload-arcade:best", best} after the game announces itself with {type: "upload-arcade:hello"}. Opened as a plain local file, those messages simply do nothing. When the game feels right, drop the file into the scanner on the upload page. It reports PASS, WARN, or FAIL for each rule and shows a safe preview before you submit.