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

Camera & Scrolling World

Separate world space from screen space

Chapter goal

Build a larger level than the canvas and a camera that follows the player so drawing uses (world - camera) offsets.

world coordinates
camera lerp
parallax layers
draw offset

How to do it

  1. 1

    Think in two spaces

    Entities live in world pixels. The camera is a point (or top-left) in that world. Screen draw: sx = entity.x - camera.x.

  2. 2

    Follow with smoothing

    camera.x += (targetX - camera.x) * Math.min(1, lerp * dt). Snappy but not instant feels premium.

  3. 3

    Parallax decoration

    Background layers scroll at fractions of camera speed for depth without 3D.

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
Camera & Scrolling World
1 / 103 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 LERP = 5;
22const LOOKAHEAD = 40;
23const WORLD_W = 1600;
24const MOVE = 240;
25const JUMP = 640;
26const WORLD_H = H;
27const GRAVITY = 2000;
28
29const keys = Object.create(null);
30let jumpPressed = false;
31addEventListener('keydown', e => {
32 if (!keys[e.code] && (e.code==='Space'||e.code==='ArrowUp'||e.code==='KeyW')) jumpPressed = true;
33 keys[e.code]=true;
34 if (e.code.startsWith('Arrow')||e.code==='Space') e.preventDefault();
35});
36addEventListener('keyup', e => { keys[e.code]=false; });
37
38const player = { x: 80, y: 100, w: 26, h: 34, vx: 0, vy: 0, grounded: false };
39const camera = { x: 0, y: 0 };
40
41const solids = [{ x: 0, y: WORLD_H-22, w: WORLD_W, h: 22 }];
42for (let i = 0; i < 18; i++) {
43 solids.push({
44 x: 120 + i * 85,
45 y: WORLD_H - 70 - (i % 5) * 36 - (i % 2) * 20,
46 w: 70 + (i % 3) * 12,
47 h: 14,
48 });
49}
50
51function 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;}
52function resolve(p,s){
53 const ox=Math.min(p.x+p.w-s.x,s.x+s.w-p.x);
54 const oy=Math.min(p.y+p.h-s.y,s.y+s.h-p.y);
55 if(ox<oy){ if(p.x+p.w/2<s.x+s.w/2)p.x-=ox; else p.x+=ox; p.vx=0; }
56 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; } }
57}
58
59let last = performance.now();
60function loop(now){
61 const dt = Math.min((now-last)/1000, 0.05); last = now;
62 let input = 0;
63 if (keys['ArrowLeft']||keys['KeyA']) input -= 1;
64 if (keys['ArrowRight']||keys['KeyD']) input += 1;
65 player.vx = input * MOVE;
66 if (jumpPressed && player.grounded) { player.vy = -JUMP; player.grounded = false; }
67 jumpPressed = false;
68 player.vy += GRAVITY * dt;
69 player.grounded = false;
70 player.x += player.vx * dt;
71 for (const s of solids) if (aabb(player,s)) resolve(player,s);
72 player.y += player.vy * dt;
73 for (const s of solids) if (aabb(player,s)) resolve(player,s);
74 if (player.y > WORLD_H + 100) { player.x = 80; player.y = 100; player.vx=0; player.vy=0; }
75
76 const facing = Math.sign(player.vx) || 1;
77 const targetX = player.x + player.w/2 - W/2 + facing * LOOKAHEAD;
78 const targetY = player.y + player.h/2 - H/2;
79 camera.x += (targetX - camera.x) * Math.min(1, LERP * dt);
80 camera.y += (targetY - camera.y) * Math.min(1, LERP * dt);
81 camera.x = Math.max(0, Math.min(WORLD_W - W, camera.x));
82 camera.y = Math.max(0, Math.min(WORLD_H - H, camera.y));
83
84 ctx.fillStyle = '#0b1018'; ctx.fillRect(0,0,W,H);
85
86 // parallax stars
87 ctx.fillStyle = 'rgba(232,237,245,0.35)';
88 for (let i=0;i<40;i++){
89 const sx = ((i*97) % WORLD_W) - camera.x * 0.3;
90 const sy = ((i*53) % H);
91 const x = ((sx % W) + W) % W;
92 ctx.fillRect(x, sy, 2, 2);
93 }
94
95 ctx.save();
96 ctx.translate(-camera.x, -camera.y);
97
98 ctx.fillStyle = '#1e2a3a';
99 for (const s of solids) {
100 if (s.x + s.w < camera.x - 20 || s.x > camera.x + W + 20) continue;
101 ctx.fillRect(s.x, s.y, s.w, s.h);
102 }
103
104 ctx.fillStyle = '#5eead4';
105 ctx.fillRect(player.x, player.y, player.w, player.h);
106 ctx.restore();
107
108 // progress bar
109 ctx.fillStyle = 'rgba(94,234,212,0.15)';
110 ctx.fillRect(16, H-12, W-32, 4);
111 ctx.fillStyle = '#5eead4';
112 ctx.fillRect(16, H-12, (W-32) * (player.x / WORLD_W), 4);
113
114 hud.textContent = 'Chapter 8 · cam ' + camera.x.toFixed(0) + ',' + camera.y.toFixed(0) + ' · world ' + WORLD_W + 'px';
115 requestAnimationFrame(loop);
116}
117requestAnimationFrame(loop);
118
119})();
Your lineline 1
(function () {

Run and jump across a wide level — camera follows. · Sliders update the play window after a short pause.

Chapter solution
Reference
Live-bound HTML
Play window
Live