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.
How to do it
- 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
Follow with smoothing
camera.x += (targetX - camera.x) * Math.min(1, lerp * dt). Snappy but not instant feels premium.
- 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.
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.
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 };1516const canvas = document.getElementById('c');17const ctx = canvas.getContext('2d');18const W = canvas.width, H = canvas.height;19const hud = document.getElementById('hud');2021const LERP = 5;22const LOOKAHEAD = 40;23const WORLD_W = 1600;24const MOVE = 240;25const JUMP = 640;26const WORLD_H = H;27const GRAVITY = 2000;2829const 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; });3738const player = { x: 80, y: 100, w: 26, h: 34, vx: 0, vy: 0, grounded: false };39const camera = { x: 0, y: 0 };4041const 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}5051function 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}5859let 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; }7576 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));8384 ctx.fillStyle = '#0b1018'; ctx.fillRect(0,0,W,H);8586 // parallax stars87 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 }9495 ctx.save();96 ctx.translate(-camera.x, -camera.y);9798 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 }103104 ctx.fillStyle = '#5eead4';105 ctx.fillRect(player.x, player.y, player.w, player.h);106 ctx.restore();107108 // progress bar109 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);113114 hud.textContent = 'Chapter 8 · cam ' + camera.x.toFixed(0) + ',' + camera.y.toFixed(0) + ' · world ' + WORLD_W + 'px';115 requestAnimationFrame(loop);116}117requestAnimationFrame(loop);118119})();
Run and jump across a wide level — camera follows. · Sliders update the play window after a short pause.