Let's Build a Game Engine in HTML
Chapter 9
9 / 9

The Full Mini Engine

Compose loop, input, physics, collision, camera into one playable gem hunt

Chapter goal

Ship a tiny complete game: collect gems, avoid hazards, win when you grab them all. This is the architecture you can expand forever.

engine object
systems composition
collectibles
win state

How to do it

  1. 1

    One engine shell

    Engine owns canvas, state, systems. Each frame: input → physics → collisions → camera → render → UI.

  2. 2

    Game-specific data

    Gems and spikes are just entities with tags. On overlap: gems remove + score; spikes reset player.

  3. 3

    Keep extending

    From here add tilemaps, audio, particles, enemies AI — same loop. You now own a pure HTML game engine.

Live lab

Solution code updates with sliders. Tinker is your sandbox. Play runs whichever document you launch.

Live settings

Every slider injects into the engine constants for this chapter.

Type-along practice
Photo layout
The Full Mini Engine
1 / 150 lines

The chapter engine script is prewritten below. Type it line by line — each line explains why it belongs there. Matching a line advances automatically; Enter re-checks if you are stuck.

Prewritten engine scriptmistakes 0
1(function () {
2 "use strict";
3 // Single boot — if this document is re-parsed, abandon prior work.
4 if (window.__engineBooted) return;
5 window.__engineBooted = true;
6 let alive = true;
7 window.addEventListener("pagehide", function () { alive = false; }, { once: true });
8 const raf = window.requestAnimationFrame.bind(window);
9 window.requestAnimationFrame = function (cb) {
10 return raf(function (ts) {
11 if (!alive) return;
12 try { cb(ts); } catch (err) { console.error(err); alive = false; }
13 });
14 };
15
16const canvas = document.getElementById('c');
17const ctx = canvas.getContext('2d');
18const W = canvas.width, H = canvas.height;
19const hud = document.getElementById('hud');
20
21const GRAVITY = 2100;
22const JUMP = 660;
23const MOVE = 250;
24const GEM_COUNT = 6;
25const LERP = 6;
26const WORLD_W = 1800;
27
28const keys = Object.create(null);
29let jumpPressed = false;
30addEventListener('keydown', e => {
31 if (!keys[e.code] && (e.code==='Space'||e.code==='ArrowUp'||e.code==='KeyW')) jumpPressed = true;
32 keys[e.code]=true;
33 if (e.code.startsWith('Arrow')||e.code==='Space') e.preventDefault();
34});
35addEventListener('keyup', e => { keys[e.code]=false; });
36
37const engine = {
38 player: { x: 60, y: 80, w: 26, h: 34, vx: 0, vy: 0, grounded: false },
39 camera: { x: 0, y: 0 },
40 solids: [{ x: 0, y: H-22, w: WORLD_W, h: 22 }],
41 gems: [],
42 spikes: [],
43 score: 0,
44 won: false,
45 t: 0,
46};
47
48for (let i = 0; i < 16; i++) {
49 engine.solids.push({
50 x: 140 + i * 100,
51 y: H - 80 - (i % 4) * 42,
52 w: 72,
53 h: 14,
54 });
55}
56for (let i = 0; i < GEM_COUNT; i++) {
57 const s = engine.solids[1 + (i * 2) % (engine.solids.length-1)];
58 engine.gems.push({ x: s.x + s.w/2 - 7, y: s.y - 28, w: 14, h: 14, taken: false });
59}
60for (let i = 0; i < 5; i++) {
61 engine.spikes.push({ x: 300 + i * 280, y: H - 22 - 16, w: 28, h: 16 });
62}
63
64function aabb(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;}
65function resolve(p,s){
66 const ox=Math.min(p.x+p.w-s.x,s.x+s.w-p.x);
67 const oy=Math.min(p.y+p.h-s.y,s.y+s.h-p.y);
68 if(ox<oy){ if(p.x+p.w/2<s.x+s.w/2)p.x-=ox; else p.x+=ox; p.vx=0; }
69 else { if(p.y+p.h/2<s.y+s.h/2){ p.y-=oy; p.vy=0; p.grounded=true; } else { p.y+=oy; p.vy=0; } }
70}
71function resetPlayer(){
72 engine.player.x=60; engine.player.y=80; engine.player.vx=0; engine.player.vy=0;
73}
74
75let last = performance.now();
76function loop(now){
77 const dt = Math.min((now-last)/1000, 0.05); last = now;
78 engine.t += dt;
79 const p = engine.player;
80
81 if (!engine.won) {
82 let input = 0;
83 if (keys['ArrowLeft']||keys['KeyA']) input -= 1;
84 if (keys['ArrowRight']||keys['KeyD']) input += 1;
85 p.vx = input * MOVE;
86 if (jumpPressed && p.grounded) { p.vy = -JUMP; p.grounded = false; }
87 jumpPressed = false;
88 p.vy += GRAVITY * dt;
89 p.grounded = false;
90 p.x += p.vx * dt;
91 for (const s of engine.solids) if (aabb(p,s)) resolve(p,s);
92 p.y += p.vy * dt;
93 for (const s of engine.solids) if (aabb(p,s)) resolve(p,s);
94
95 for (const g of engine.gems) {
96 if (!g.taken && aabb(p, g)) { g.taken = true; engine.score++; }
97 }
98 for (const sp of engine.spikes) if (aabb(p, sp)) resetPlayer();
99 if (p.y > H + 80) resetPlayer();
100 if (engine.score >= GEM_COUNT) engine.won = true;
101 }
102
103 const targetX = p.x + p.w/2 - W/2;
104 engine.camera.x += (targetX - engine.camera.x) * Math.min(1, LERP * dt);
105 engine.camera.x = Math.max(0, Math.min(WORLD_W - W, engine.camera.x));
106
107 // render
108 ctx.fillStyle = '#0b1018'; ctx.fillRect(0,0,W,H);
109 ctx.fillStyle = 'rgba(125,211,252,0.08)';
110 for (let i=0;i<12;i++){
111 const x = ((i*160 - engine.camera.x * 0.25) % (W+40) + W+40) % (W+40) - 20;
112 ctx.beginPath(); ctx.arc(x, 50 + (i%3)*30, 18, 0, Math.PI*2); ctx.fill();
113 }
114
115 ctx.save();
116 ctx.translate(-engine.camera.x, 0);
117
118 ctx.fillStyle = '#1e2a3a';
119 for (const s of engine.solids) ctx.fillRect(s.x,s.y,s.w,s.h);
120
121 for (const sp of engine.spikes) {
122 ctx.fillStyle = '#f87171';
123 ctx.beginPath();
124 ctx.moveTo(sp.x, sp.y+sp.h);
125 ctx.lineTo(sp.x+sp.w/2, sp.y);
126 ctx.lineTo(sp.x+sp.w, sp.y+sp.h);
127 ctx.closePath(); ctx.fill();
128 }
129
130 for (const g of engine.gems) {
131 if (g.taken) continue;
132 const bob = Math.sin(engine.t * 4 + g.x) * 3;
133 ctx.fillStyle = '#fbbf24';
134 ctx.save();
135 ctx.translate(g.x + 7, g.y + 7 + bob);
136 ctx.rotate(Math.PI/4);
137 ctx.fillRect(-6, -6, 12, 12);
138 ctx.restore();
139 }
140
141 ctx.fillStyle = engine.won ? '#34d399' : '#5eead4';
142 ctx.fillRect(p.x, p.y, p.w, p.h);
143 ctx.restore();
144
145 // UI
146 ctx.fillStyle = 'rgba(7,9,13,0.55)';
147 ctx.fillRect(12, 10, 150, 28);
148 ctx.fillStyle = '#e8edf5';
149 ctx.font = '12px ui-monospace,monospace';
150 ctx.textAlign = 'left';
151 ctx.fillText('Gems ' + engine.score + ' / ' + GEM_COUNT, 22, 28);
152
153 if (engine.won) {
154 ctx.fillStyle = 'rgba(7,9,13,0.72)';
155 ctx.fillRect(0,0,W,H);
156 ctx.fillStyle = '#5eead4';
157 ctx.font = '600 28px system-ui,sans-serif';
158 ctx.textAlign = 'center';
159 ctx.fillText('Engine Complete', W/2, H/2 - 8);
160 ctx.fillStyle = '#8b97ab';
161 ctx.font = '13px system-ui,sans-serif';
162 ctx.fillText('You built a pure HTML game engine.', W/2, H/2 + 20);
163 }
164
165 hud.textContent = 'Chapter 9 · mini engine · ' + (engine.won ? 'YOU WIN' : 'hunt gems');
166 requestAnimationFrame(loop);
167}
168requestAnimationFrame(loop);
169
170})();
Your lineline 1
(function () {

Collect all gems. Arrows/WASD + Space/W to jump. Spikes reset you. · Sliders update the play window after a short pause.

Chapter solution
Reference
Live-bound HTML
Play window
Live