How to Make an HTML Game (Step-by-Step)
From blank page to playable prototype — browser-only, no engines required.
This guide walks you through building a simple HTML game using the
<canvas> element and vanilla JavaScript. You’ll set up a loop,
capture input, move a player, handle collisions, add enemies, and ship it.
Everything is beginner-friendly and copy-paste ready.
Prefer to start with a working template? Grab our tiny starter kit: Download the HTML Game Starter (zip).
Step 0 — The 1-File “Hello Game”
Want instant gratification? Create a file named hello-game.html with this content and open it in a browser.
You’ll see a moving square you can steer with arrow keys.
<!doctype html>
<meta charset="utf-8">
<title>Hello Game</title>
<canvas id="game" width="640" height="360" style="background:#0b0f1a;display:block;margin:auto;border-radius:12px"></canvas>
<script>
const c = document.getElementById('game');
const ctx = c.getContext('2d');
const player = { x: 80, y: 260, w: 24, h: 24, vx: 0, vy: 0, speed: 160 };
const keys = {};
addEventListener('keydown', e => keys[e.key] = true);
addEventListener('keyup', e => keys[e.key] = false);
let last = performance.now();
function loop(t){
const dt = Math.min(0.033, (t - last) / 1000); last = t;
// input
const ax = (keys['ArrowRight']?1:0) - (keys['ArrowLeft']?1:0);
const ay = (keys['ArrowDown']?1:0) - (keys['ArrowUp']?1:0);
player.vx = ax * player.speed;
player.vy = ay * player.speed;
// update
player.x += player.vx * dt;
player.y += player.vy * dt;
// render
ctx.clearRect(0,0,c.width,c.height);
ctx.fillStyle = '#1e2a4d'; ctx.fillRect(0, 320, 640, 40); // ground
ctx.fillStyle = '#6cf'; ctx.fillRect(player.x, player.y, player.w, player.h);
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
</script>
That’s the core loop: read input → update state → draw frame → repeat. The rest of this guide expands this into a proper mini-game.
Step 1 — Project Structure
Create a simple folder layout:
your-game/
index.html
styles.css
src/
main.js
input.js
player.js
utils.js
If you don’t want to scaffold by hand, download our pre-made structure: HTML Game Starter (zip).
Step 2 — Canvas & Setup
In index.html, create your canvas and include the main script (ES module):
<!doctype html>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>My HTML Game</title>
<link rel="stylesheet" href="styles.css">
<canvas id="game" width="960" height="540"></canvas>
<script type="module" src="./src/main.js"></script>
Make the canvas crisp on high-DPI screens by scaling to devicePixelRatio:
// src/main.js
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const DPR = Math.max(1, Math.min(window.devicePixelRatio || 1, 2));
function resize(){
const w = canvas.clientWidth * DPR;
const h = canvas.clientHeight * DPR;
canvas.width = Math.round(w);
canvas.height = Math.round(h);
}
resize();
addEventListener('resize', resize);
Step 3 — The Game Loop
// src/main.js (continued)
let last = performance.now();
function update(dt){ /* move things, collide, score */ }
function render(){
ctx.clearRect(0,0,canvas.width,canvas.height);
// draw background, world, player, UI, etc.
}
function loop(t){
const dt = Math.min(0.033, (t - last)/1000); last = t;
update(dt);
render();
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
The loop calls update then render ~60 times per second. We cap dt
so physics stays stable during hiccups.
Step 4 — Input (Keyboard & Touch)
Keyboard: keep a set of pressed keys.
// src/input.js
export class Input {
constructor(){
this.keys = new Set();
this.axis = { x:0, y:0 };
this.bindKeys();
}
bindKeys(){
const map = {
'ArrowLeft':'left','a':'left','A':'left',
'ArrowRight':'right','d':'right','D':'right',
'ArrowUp':'up','w':'up','W':'up',
'ArrowDown':'down','s':'down','S':'down',
' ':'space','Spacebar':'space'
};
addEventListener('keydown', e => { const k=map[e.key]; if(k) this.keys.add(k); });
addEventListener('keyup', e => { const k=map[e.key]; if(k) this.keys.delete(k); });
}
get left(){ return this.keys.has('left'); }
get right(){ return this.keys.has('right'); }
get up(){ return this.keys.has('up'); }
get down(){ return this.keys.has('down'); }
get dash(){ return this.keys.has('space'); }
}
Use it in main.js:
import { Input } from './input.js';
const input = new Input();
// later in update(dt):
const ax = (input.right?1:0) - (input.left?1:0);
Touch joystick (optional)
The starter kit includes a minimal touch stick bound to the canvas. It maps swipes to axis.x/axis.y.
Step 5 — Player, Physics & Collisions
Create a simple rectangle player with gravity and a jump. AABB keeps them from phasing through platforms.
// src/utils.js
export const clamp = (v,min,max) => Math.max(min, Math.min(max, v));
export const aabb = (ax,ay,aw,ah,bx,by,bw,bh) =>
ax < bx + bw && ax + aw > bx && ay < by + bh && ay + ah > by;
// src/player.js
import { clamp, aabb } from './utils.js';
export class Player {
constructor(x,y){
this.x=x; this.y=y; this.w=22; this.h=22;
this.vx=0; this.vy=0; this.speed=180; this.grounded=false;
}
update(dt, input, world){
const ax = (input.left?-1:0) + (input.right?1:0);
this.vx = ax * this.speed;
this.vy += 900 * dt; // gravity
if(input.up && this.grounded){ this.vy = -360; this.grounded=false; }
// integrate
let nx = this.x + this.vx*dt, ny = this.y + this.vy*dt;
// collide against world rects
this.grounded=false;
for(const r of world){
if(aabb(nx,this.y,this.w,this.h,r.x,r.y,r.w,r.h)){
if(this.vx>0) nx = r.x - this.w; else if(this.vx<0) nx = r.x + r.w;
this.vx=0;
}
if(aabb(this.x,ny,this.w,this.h,r.x,r.y,r.w,r.h)){
if(this.vy>0){ ny = r.y - this.h; this.grounded=true; }
else if(this.vy<0){ ny = r.y + r.h; }
this.vy=0;
}
}
this.x = clamp(nx, 0, 2000-this.w);
this.y = clamp(ny, 0, 2000-this.h);
}
render(ctx){ ctx.fillStyle='#6cf'; ctx.fillRect(this.x,this.y,this.w,this.h); }
}
Step 6 — Enemies & Game Over
Add a super-simple enemy that patrols and ends the run on contact.
class Enemy {
constructor(x,y){ this.x=x; this.y=y; this.w=20; this.h=20; this.vx=80; }
update(dt){ this.x += this.vx*dt; }
render(ctx){ ctx.fillStyle='#f66'; ctx.fillRect(this.x,this.y,this.w,this.h); }
}
Collision check (player vs enemy):
if (aabb(player.x,player.y,player.w,player.h, enemy.x,enemy.y,enemy.w,enemy.h)) {
gameOver = true; // switch scene/UI
}
Step 7 — Sprites & Sound
Sprites
const heroImg = new Image();
heroImg.src = './assets/hero.png';
// render:
ctx.drawImage(heroImg, sx,sy,sw,sh, player.x, player.y, player.w, player.h);
// For basic use, omit s* and draw the whole image:
ctx.drawImage(heroImg, player.x, player.y, player.w, player.h);
Sound (Web Audio)
let audioCtx;
function ensureAudio(){ audioCtx ||= new (window.AudioContext||window.webkitAudioContext)(); }
function beep(freq=440, dur=0.1){
if(!audioCtx) return; const o = audioCtx.createOscillator(); const g = audioCtx.createGain();
g.gain.value = 0.03; o.type='square'; o.frequency.value=freq; o.connect(g).connect(audioCtx.destination);
o.start(); o.stop(audioCtx.currentTime + dur);
}
// call ensureAudio() after a user click (e.g., "Start" button)
Step 8 — UI, Score, Pause, Reset
Add lightweight UI in your HTML and wire buttons to state.
<div class="hud">
<span id="score">Score: 0</span>
<button id="btnPause">Pause</button>
<button id="btnReset">Reset</button>
</div>
let paused = false, score = 0;
document.getElementById('btnPause').onclick = () => paused = !paused;
document.getElementById('btnReset').onclick = () => location.reload();
function update(dt){
if(paused) return;
score += dt; document.getElementById('score').textContent = 'Score: ' + Math.floor(score*10);
// ... rest of update
}
Step 9 — Polish & Next Steps
- Particles: spawn tiny fading squares when jumping or dashing.
- Camera Shake: add a small random offset on hit for juice.
- Tilemaps: design levels in Tiled, export CSV/JSON, draw by iterating tiles.
- Save Data: store best scores in
localStorage. - Daily Seed: use a fixed random seed each day for comparable runs.
Deploy — Put It Online (Free)
- GitHub Pages: push your folder to a repo → Settings → Pages → deploy from /.
- Netlify: drag & drop the folder onto app.netlify.com → instant link.
- Vercel: import the repo → deploy → connect a custom domain later.
- itch.io: create a new “HTML” project → upload the zipped folder → set index.html as launch file.
FAQ — Common Pitfalls
The canvas looks blurry on my phone.
Scale the backing store by devicePixelRatio (see Step 2). Keep CSS size the same, only change the real canvas.width/height.
No sound plays!
Browsers require user interaction before audio. Create/resume the AudioContext after a click/tap (e.g., a “Start” button).
My player “sticks” on platform corners.
Resolve collisions axis-by-axis (X then Y), and clamp positions after correction. Avoid moving both axes before checking.
Performance dips on mobile.
Minimize per-frame allocations, use simple shapes first, and limit overdraw. Aim for 60 FPS; test on a real phone early.