Velocity & Delta Time
Frame-rate independent motion
Chapter goal
Multiply movement by dt (seconds since last frame) so 200px/s feels the same at 30fps or 60fps.
How to do it
- 1
Measure dt
const dt = (now - last) / 1000; last = now. dt is usually ~0.016 at 60fps.
- 2
Integrate velocity
position += velocity * dt. Express speed as pixels per second, not pixels per frame.
- 3
Clamp huge dt
If the tab was backgrounded, dt can be huge. Cap it (e.g. 0.05) so the player doesn't tunnel through walls.
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 MAX_SPEED = 220;22const ACCEL = 900;23const DRAG = 3;24const DT_CAP = 50 / 1000;2526const keys = Object.create(null);27addEventListener('keydown', e => { keys[e.code]=true; if(e.code.startsWith('Arrow')||e.code==='Space') e.preventDefault(); });28addEventListener('keyup', e => { keys[e.code]=false; });2930const player = { x: W/2, y: H/2, r: 14, vx: 0, vy: 0 };31let last = performance.now();32let fps = 60;3334function loop(now) {35 let dt = (now - last) / 1000;36 last = now;37 if (dt > DT_CAP) dt = DT_CAP;38 fps = fps * 0.9 + (1/Math.max(dt, 0.0001)) * 0.1;3940 let ax = 0, ay = 0;41 if (keys['ArrowLeft']||keys['KeyA']) ax -= 1;42 if (keys['ArrowRight']||keys['KeyD']) ax += 1;43 if (keys['ArrowUp']||keys['KeyW']) ay -= 1;44 if (keys['ArrowDown']||keys['KeyS']) ay += 1;45 if (ax || ay) {46 const len = Math.hypot(ax, ay) || 1;47 ax /= len; ay /= len;48 player.vx += ax * ACCEL * dt;49 player.vy += ay * ACCEL * dt;50 }5152 // exponential drag53 const drag = Math.exp(-DRAG * dt);54 player.vx *= drag;55 player.vy *= drag;5657 const sp = Math.hypot(player.vx, player.vy);58 if (sp > MAX_SPEED) {59 player.vx = player.vx / sp * MAX_SPEED;60 player.vy = player.vy / sp * MAX_SPEED;61 }6263 player.x += player.vx * dt;64 player.y += player.vy * dt;6566 if (player.x < player.r) { player.x = player.r; player.vx *= -0.4; }67 if (player.x > W - player.r) { player.x = W - player.r; player.vx *= -0.4; }68 if (player.y < player.r) { player.y = player.r; player.vy *= -0.4; }69 if (player.y > H - player.r) { player.y = H - player.r; player.vy *= -0.4; }7071 ctx.fillStyle = '#0b1018';72 ctx.fillRect(0, 0, W, H);7374 // velocity vector75 ctx.strokeStyle = 'rgba(125,211,252,0.5)';76 ctx.beginPath();77 ctx.moveTo(player.x, player.y);78 ctx.lineTo(player.x + player.vx * 0.12, player.y + player.vy * 0.12);79 ctx.stroke();8081 ctx.fillStyle = '#5eead4';82 ctx.beginPath();83 ctx.arc(player.x, player.y, player.r, 0, Math.PI*2);84 ctx.fill();8586 hud.textContent = 'Chapter 5 · ' + fps.toFixed(0) + ' fps · dt=' + (dt*1000).toFixed(1) + 'ms · speed=' + sp.toFixed(0) + 'px/s';87 requestAnimationFrame(loop);88}89requestAnimationFrame(loop);9091})();
WASD / Arrows — motion is px/second, not per-frame. · Sliders update the play window after a short pause.