import { useState, useRef, useEffect, useCallback } from "react"; /* ═══════════════════════════════════════════ PLATFORMER COLOR PALETTE ═══════════════════════════════════════════ */ const C = { pageBg: "#fef9f0", canvasSky: "#dbeefe", canvasGround: "#b8e6a3", canvasGroundDark: "#8dc978", grid: "#c8daf030", gridStrong: "#b0c4de30", text: "#3a2e28", textLight: "#6b5d54", muted: "#9a8e85", hero: "#e85d4a", heroDark: "#c44535", enemy: "#7c5cbf", enemyDark: "#5e3fa0", star: "#f5b731", starGlow: "#f5b73140", vecBlue: "#3b8beb", vecGreen: "#22b573", vecPink: "#e8457a", vecAmber: "#e8993a", vecPurple: "#9b6dff", white: "#ffffff", shadow: "#3a2e2818", cardBg: "#ffffff", cardBorder: "#e8dfd4", tipBg: "#fff8eb", tipBorder: "#f5d88e", codeBg: "#f3ede4", }; /* ═══════════════════════════════════════════ SPRITE DRAWING — cute characters! ═══════════════════════════════════════════ */ function rrect(ctx, x, y, w, h, r) { r = Math.min(r, w / 2, h / 2); ctx.beginPath(); ctx.moveTo(x + r, y); ctx.lineTo(x + w - r, y); ctx.quadraticCurveTo(x + w, y, x + w, y + r); ctx.lineTo(x + w, y + h - r); ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h); ctx.lineTo(x + r, y + h); ctx.quadraticCurveTo(x, y + h, x, y + h - r); ctx.lineTo(x, y + r); ctx.quadraticCurveTo(x, y, x + r, y); ctx.closePath(); } function drawSprite(ctx, x, y, time, opts = {}) { const { color = C.hero, darkColor = C.heroDark, lookAt = null, walking = false, scale = 1, alert = false, happy = false, } = opts; ctx.save(); ctx.translate(x, y); ctx.scale(scale, scale); const bob = walking ? Math.sin(time * 0.012) * 2 : Math.sin(time * 0.003) * 1.5; const squish = walking ? 1 + Math.sin(time * 0.012) * 0.05 : 1; // Shadow ctx.fillStyle = "#00000012"; ctx.beginPath(); ctx.ellipse(0, 16, 12, 4, 0, 0, Math.PI * 2); ctx.fill(); // Legs const legY = 8; const legSwing = walking ? Math.sin(time * 0.012) * 7 : 0; ctx.strokeStyle = darkColor; ctx.lineWidth = 3.5; ctx.lineCap = "round"; // Left ctx.beginPath(); ctx.moveTo(-5, legY + bob); ctx.lineTo(-5 + legSwing, legY + 10); ctx.stroke(); // Right ctx.beginPath(); ctx.moveTo(5, legY + bob); ctx.lineTo(5 - legSwing, legY + 10); ctx.stroke(); // Feet ctx.fillStyle = darkColor; ctx.beginPath(); ctx.arc(-5 + legSwing, legY + 11, 2.5, 0, Math.PI * 2); ctx.fill(); ctx.beginPath(); ctx.arc(5 - legSwing, legY + 11, 2.5, 0, Math.PI * 2); ctx.fill(); // Body ctx.fillStyle = color; const bw = 16 * squish, bh = 22 / squish; rrect(ctx, -bw / 2, -bh / 2 + bob, bw, bh, 8); ctx.fill(); // Body highlight ctx.fillStyle = "#ffffff30"; rrect(ctx, -bw / 2 + 2, -bh / 2 + bob + 2, bw * 0.4, bh * 0.3, 4); ctx.fill(); // Eyes const eyeY = -3 + bob; let px = 0, py = 0; if (lookAt) { const dx = lookAt[0] - x, dy = lookAt[1] - y; const d = Math.hypot(dx, dy) || 1; px = (dx / d) * 2.8; py = Math.min(1.5, Math.max(-1.5, (dy / d) * 2)); } // White ctx.fillStyle = C.white; ctx.beginPath(); ctx.arc(-5, eyeY, 5, 0, Math.PI * 2); ctx.fill(); ctx.beginPath(); ctx.arc(5, eyeY, 5, 0, Math.PI * 2); ctx.fill(); // Pupils ctx.fillStyle = "#2a1f1a"; ctx.beginPath(); ctx.arc(-5 + px, eyeY + py, 2.5, 0, Math.PI * 2); ctx.fill(); ctx.beginPath(); ctx.arc(5 + px, eyeY + py, 2.5, 0, Math.PI * 2); ctx.fill(); // Pupil highlights ctx.fillStyle = "#ffffff"; ctx.beginPath(); ctx.arc(-5 + px + 1, eyeY + py - 1, 0.8, 0, Math.PI * 2); ctx.fill(); ctx.beginPath(); ctx.arc(5 + px + 1, eyeY + py - 1, 0.8, 0, Math.PI * 2); ctx.fill(); // Happy mouth if (happy) { ctx.strokeStyle = "#2a1f1a"; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.arc(0, eyeY + 7, 3, 0.1 * Math.PI, 0.9 * Math.PI); ctx.stroke(); } // Alert "!" if (alert) { ctx.fillStyle = C.star; ctx.font = "bold 18px 'Nunito', sans-serif"; ctx.textAlign = "center"; ctx.fillText("!", 0, -bh / 2 + bob - 8); } ctx.restore(); } function drawStar(ctx, x, y, r, time) { ctx.save(); ctx.translate(x, y); ctx.rotate(time * 0.001); const pulse = 1 + Math.sin(time * 0.005) * 0.1; ctx.scale(pulse, pulse); ctx.fillStyle = C.starGlow; ctx.beginPath(); ctx.arc(0, 0, r * 1.8, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = C.star; ctx.beginPath(); for (let i = 0; i < 5; i++) { const a = (i * 72 - 90) * Math.PI / 180; const a2 = ((i * 72) + 36 - 90) * Math.PI / 180; ctx.lineTo(Math.cos(a) * r, Math.sin(a) * r); ctx.lineTo(Math.cos(a2) * r * 0.45, Math.sin(a2) * r * 0.45); } ctx.closePath(); ctx.fill(); ctx.restore(); } /* ═══════════════════════════════════════════ CANVAS HELPERS ═══════════════════════════════════════════ */ const SC = 40; function toS(gx, gy, w, h) { return [gx * SC + w / 2, -gy * SC + h / 2]; } function toG(px, py, w, h) { return [(px - w / 2) / SC, -(py - h / 2) / SC]; } function snap(v) { return Math.round(v * 2) / 2; } function drawCanvasBg(ctx, w, h, ground = true) { // Sky gradient const grad = ctx.createLinearGradient(0, 0, 0, h); grad.addColorStop(0, "#d4edfc"); grad.addColorStop(0.65, "#eaf4fd"); grad.addColorStop(1, ground ? "#e8f5e0" : "#eef6fd"); ctx.fillStyle = grad; ctx.fillRect(0, 0, w, h); // Subtle grid ctx.strokeStyle = "#90b8d830"; ctx.lineWidth = 0.5; for (let x = (w / 2) % SC; x < w; x += SC) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke(); } for (let y = (h / 2) % SC; y < h; y += SC) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke(); } // Axis ctx.strokeStyle = "#90b8d860"; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(w / 2, 0); ctx.lineTo(w / 2, h); ctx.stroke(); ctx.beginPath(); ctx.moveTo(0, h / 2); ctx.lineTo(w, h / 2); ctx.stroke(); } function drawArrow(ctx, x1, y1, x2, y2, color, lw = 2.5, hs = 10) { const dx = x2 - x1, dy = y2 - y1, len = Math.hypot(dx, dy); if (len < 2) return; const ux = dx / len, uy = dy / len; ctx.strokeStyle = color; ctx.fillStyle = color; ctx.lineWidth = lw; ctx.lineCap = "round"; ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2 - ux * hs * 0.4, y2 - uy * hs * 0.4); ctx.stroke(); ctx.beginPath(); ctx.moveTo(x2, y2); ctx.lineTo(x2 - ux * hs - uy * hs * 0.38, y2 - uy * hs + ux * hs * 0.38); ctx.lineTo(x2 - ux * hs + uy * hs * 0.38, y2 - uy * hs - ux * hs * 0.38); ctx.closePath(); ctx.fill(); } function drawDashed(ctx, x1, y1, x2, y2, color, lw = 1.5) { ctx.strokeStyle = color; ctx.lineWidth = lw; ctx.setLineDash([5, 4]); ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke(); ctx.setLineDash([]); } /* ═══════════════════════════════════════════ ANIMATED CANVAS HOOK — 60fps game loop ═══════════════════════════════════════════ */ function useAnimCanvas(renderFn, stateRef) { const canvasRef = useRef(null); const frameRef = useRef(null); const renderRef = useRef(renderFn); renderRef.current = renderFn; useEffect(() => { const c = canvasRef.current; if (!c) return; const dpr = window.devicePixelRatio || 1; const rect = c.getBoundingClientRect(); c.width = rect.width * dpr; c.height = rect.height * dpr; const loop = (ts) => { const ctx = c.getContext("2d"); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); renderRef.current(ctx, rect.width, rect.height, ts); frameRef.current = requestAnimationFrame(loop); }; frameRef.current = requestAnimationFrame(loop); return () => cancelAnimationFrame(frameRef.current); }, []); return canvasRef; } /* ═══════════════════════════════════════════ DRAG HOOK with refs (for anim loop compat) ═══════════════════════════════════════════ */ function useDragRef(canvasRef, pointsRef, onUpdate) { const dragging = useRef(null); useEffect(() => { const c = canvasRef.current; if (!c) return; const getPos = (e) => { const r = c.getBoundingClientRect(); return [(e.clientX ?? e.touches?.[0]?.clientX ?? 0) - r.left, (e.clientY ?? e.touches?.[0]?.clientY ?? 0) - r.top, r.width, r.height]; }; const down = (e) => { const [cx, cy, w, h] = getPos(e); const pts = pointsRef.current; for (let i = 0; i < pts.length; i++) { const [sx, sy] = toS(pts[i][0], pts[i][1], w, h); if (Math.hypot(cx - sx, cy - sy) < 24) { dragging.current = i; e.preventDefault(); return; } } }; const move = (e) => { if (dragging.current === null) return; e.preventDefault(); const [cx, cy, w, h] = getPos(e); const [gx, gy] = toG(cx, cy, w, h); const pts = [...pointsRef.current]; pts[dragging.current] = [snap(gx), snap(gy)]; pointsRef.current = pts; onUpdate?.(pts); }; const up = () => { dragging.current = null; }; c.addEventListener("pointerdown", down); window.addEventListener("pointermove", move); window.addEventListener("pointerup", up); return () => { c.removeEventListener("pointerdown", down); window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", up); }; }, [canvasRef, pointsRef, onUpdate]); } /* ═══════════════════════════════════════════ TYPOGRAPHY COMPONENTS ═══════════════════════════════════════════ */ function Scrub({ value, onChange, min = -8, max = 8, step = 0.5, color = C.vecAmber }) { const dragRef = useRef(false); const startRef = useRef({ x: 0, v: 0 }); const onDown = useCallback((e) => { e.preventDefault(); dragRef.current = true; startRef.current = { x: e.clientX || 0, v: value }; document.body.style.cursor = "ew-resize"; document.body.style.userSelect = "none"; }, [value]); useEffect(() => { const onMove = (e) => { if (!dragRef.current) return; const dx = (e.clientX || 0) - startRef.current.x; const nv = Math.round((startRef.current.v + dx * step * 0.08) / step) * step; onChange(Math.max(min, Math.min(max, nv))); }; const onUp = () => { dragRef.current = false; document.body.style.cursor = ""; document.body.style.userSelect = ""; }; window.addEventListener("pointermove", onMove); window.addEventListener("pointerup", onUp); return () => { window.removeEventListener("pointermove", onMove); window.removeEventListener("pointerup", onUp); }; }, [onChange, min, max, step]); return ( {value.toFixed(1)} ); } const P = ({ children, style }) => (

{children}

); const Em = ({ children, color = C.vecBlue }) => {children}; const Code = ({ children }) => ( {children} ); const Heading = ({ children, sub }) => (
{sub &&
{sub}
}

{children}

); const GameTip = ({ children }) => (
🎮 GAME DEV TIP {children}
); const MathBox = ({ children }) => (
{children}
); const CanvasWrap = ({ canvasRef, height = 300, hint = "drag me!" }) => (
{hint &&
{hint}
}
); /* ═══════════════════════════════════════════ SECTION 1: WHAT IS A VECTOR ═══════════════════════════════════════════ */ function SectionVector() { const [pt, setPt] = useState([3, 2]); const ptRef = useRef([[3, 2]]); const onUpdate = useCallback((pts) => { setPt(pts[0]); }, []); const canvasRef = useAnimCanvas((ctx, w, h, t) => { drawCanvasBg(ctx, w, h); const p = ptRef.current[0]; const o = toS(0, 0, w, h); const s = toS(p[0], p[1], w, h); drawDashed(ctx, s[0], s[1], s[0], o[1], C.vecBlue + "50"); drawDashed(ctx, s[0], s[1], o[0], s[1], C.vecBlue + "50"); // X label ctx.fillStyle = C.vecBlue + "90"; ctx.font = "bold 12px 'IBM Plex Mono', monospace"; ctx.fillText(`x: ${p[0].toFixed(1)}`, (o[0] + s[0]) / 2 - 16, o[1] + 18); ctx.fillText(`y: ${p[1].toFixed(1)}`, o[0] - 40, (o[1] + s[1]) / 2 + 4); drawArrow(ctx, o[0], o[1], s[0], s[1], C.vecBlue, 3, 12); drawSprite(ctx, s[0], s[1] - 20, t, { color: C.hero, darkColor: C.heroDark, lookAt: [s[0] + p[0] * 10, s[1] - p[1] * 10], happy: true }); }, ptRef); useDragRef(canvasRef, ptRef, onUpdate); const mag = Math.hypot(pt[0], pt[1]); return ( <> What Even Is a Vector?

See that little red friend? They're standing at the tip of a vector — an arrow that says "go this way, this far." It has two parts: an x part (horizontal) and a y part (vertical). That's it. Two numbers.

Drag the character around. Or scrub these values: x = { setPt([v, pt[1]]); ptRef.current = [[v, pt[1]]]; }} color={C.vecBlue} />,{" "} y = { setPt([pt[0], v]); ptRef.current = [[pt[0], v]]; }} color={C.vecBlue} />. Watch their eyes track the direction!

vec = ({pt[0].toFixed(1)}, {pt[1].toFixed(1)}) │ length = {mag.toFixed(2)} A character's velocity is a vector. If velocity = (3, 2), it moves 3 right and 2 up every frame. Sixty vectors per second at 60fps. Every force, every movement, every direction in a game — vectors. ); } /* ═══════════════════════════════════════════ SECTION 2: SUBTRACTION — MOVE TO TARGET ═══════════════════════════════════════════ */ function SectionSubtraction() { const [pts, setPts] = useState([[-3, -1], [4, 2]]); const ptRef = useRef([[-3, -1], [4, 2]]); // Walking animation state const walkRef = useRef({ pos: [-3, -1], vel: [0, 0], walking: false, arrived: false }); const onUpdate = useCallback((p) => { setPts([...p]); // Reset walk when user drags — snap character back to start, ready for another walk walkRef.current = { pos: [p[0][0], p[0][1]], vel: [0, 0], walking: false, arrived: false }; }, []); const canvasRef = useAnimCanvas((ctx, w, h, t) => { drawCanvasBg(ctx, w, h); const pts = ptRef.current; const dir = [pts[1][0] - pts[0][0], pts[1][1] - pts[0][1]]; const dist = Math.hypot(dir[0], dir[1]); // Animated walking position const wk = walkRef.current; if (wk.walking) { const dx = pts[1][0] - wk.pos[0], dy = pts[1][1] - wk.pos[1]; const d = Math.hypot(dx, dy); if (d > 0.15) { // Use stored velocity — NOT normalized! Speed depends on initial distance. wk.pos[0] += wk.vel[0]; wk.pos[1] += wk.vel[1]; } else { wk.walking = false; wk.arrived = true; wk.arrivedAt = t; } } else if (wk.arrived) { // Auto-reset after 1.2 seconds so user can try again if (t - wk.arrivedAt > 1200) { wk.arrived = false; wk.pos = [pts[0][0], pts[0][1]]; } } else { wk.pos = [pts[0][0], pts[0][1]]; } const a = toS(wk.pos[0], wk.pos[1], w, h); const b = toS(pts[1][0], pts[1][1], w, h); const startS = toS(pts[0][0], pts[0][1], w, h); // Direction arrow (from current pos to target) if (!wk.arrived) { ctx.strokeStyle = C.vecGreen + "20"; ctx.lineWidth = 30; ctx.lineCap = "round"; ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); ctx.stroke(); ctx.lineCap = "butt"; drawArrow(ctx, a[0], a[1], b[0], b[1], C.vecGreen, 2.5, 11); } // Trail when walking if (wk.walking) { drawDashed(ctx, startS[0], startS[1], a[0], a[1], C.vecGreen + "40"); } // Target star drawStar(ctx, b[0], b[1] - 4, 12, t); // Character drawSprite(ctx, a[0], a[1] - 20, t, { color: C.hero, darkColor: C.heroDark, lookAt: [b[0], b[1]], walking: wk.walking, happy: wk.arrived, }); // Distance label if (!wk.arrived) { ctx.fillStyle = C.vecGreen; ctx.font = "bold 13px 'IBM Plex Mono', monospace"; ctx.fillText(`d = ${dist.toFixed(1)}`, (a[0] + b[0]) / 2 + 14, (a[1] + b[1]) / 2 - 10); } // Speed indicator when walking if (wk.walking) { const spd = Math.hypot(wk.vel[0], wk.vel[1]); const barW = Math.min(120, spd * 800); ctx.fillStyle = spd > 0.07 ? C.hero + "80" : C.vecBlue + "80"; ctx.fillStyle = "#00000030"; rrect(ctx, 12, 12, 124, 22, 6); ctx.fill(); ctx.fillStyle = spd > 0.07 ? C.hero : C.vecBlue; rrect(ctx, 14, 14, barW, 18, 5); ctx.fill(); ctx.fillStyle = "#fff"; ctx.font = "bold 11px 'IBM Plex Mono', monospace"; ctx.fillText(`speed: ${(spd * 100).toFixed(1)}`, 18, 28); } }, ptRef); useDragRef(canvasRef, ptRef, onUpdate); const dir = [pts[1][0] - pts[0][0], pts[1][1] - pts[0][1]]; return ( <> "Go There" — Your First Game Mechanic

Day one at the studio. "When the player clicks, the character walks there." Sounds easy, right? But how does the character know which direction to move?

The trick: subtract the character's position from the target. The result is a vector pointing straight from the character to the star.

Drag the character and the ⭐ target. Then hit walk: {" "} { const p = ptRef.current; const dx = p[1][0] - p[0][0], dy = p[1][1] - p[0][1]; walkRef.current = { pos: [...p[0]], vel: [dx * 0.012, dy * 0.012], walking: true, arrived: false }; }} style={{ display: "inline-block", cursor: "pointer", padding: "4px 14px", borderRadius: 8, background: C.vecGreen, color: "#fff", fontFamily: "'Nunito', sans-serif", fontSize: 14, fontWeight: 700 }}> ▶ Walk! {" "}Try moving the target close, then far. Notice anything?

target ({pts[1][0].toFixed(1)}, {pts[1][1].toFixed(1)}) character ({pts[0][0].toFixed(1)}, {pts[0][1].toFixed(1)}) = direction ({dir[0].toFixed(1)}, {dir[1].toFixed(1)}) 5 ? C.hero : C.vecBlue, fontWeight: 700 }}>speed ∝ {Math.hypot(dir[0], dir[1]).toFixed(1)}

That green arrow is the answer! But did you notice? When the target is far, the character zooms. When it's close, it crawls. That's because we're using the raw subtraction vector as velocity — and its length depends on the distance. We need a way to keep the direction but make the speed consistent...

This is THE operation. Click-to-move, AI pathfinding, homing missiles, camera follow — all start with target − position. But the speed bug you just saw? That's why the next chapter exists. ); } /* ═══════════════════════════════════════════ SECTION 3: NORMALIZE + SPEED ═══════════════════════════════════════════ */ function SectionNormalize() { const [pt, setPt] = useState([4, 3]); const [speed, setSpeed] = useState(3); const ptRef = useRef([[4, 3]]); const onUpdate = useCallback((p) => setPt(p[0]), []); const speedRef = useRef(3); speedRef.current = speed; const canvasRef = useAnimCanvas((ctx, w, h, t) => { drawCanvasBg(ctx, w, h); const p = ptRef.current[0]; const spd = speedRef.current; const mag = Math.hypot(p[0], p[1]) || 0.001; const norm = [p[0] / mag, p[1] / mag]; const moved = [norm[0] * spd, norm[1] * spd]; const o = toS(0, 0, w, h); const ps = toS(p[0], p[1], w, h); const ns = toS(norm[0], norm[1], w, h); const ms = toS(moved[0], moved[1], w, h); // Speed circle (pulsing) const pulse = 1 + Math.sin(t * 0.003) * 0.01; ctx.strokeStyle = C.vecAmber + "30"; ctx.lineWidth = 2; ctx.setLineDash([6, 4]); ctx.beginPath(); ctx.arc(o[0], o[1], SC * spd * pulse, 0, Math.PI * 2); ctx.stroke(); ctx.setLineDash([]); // Unit circle ctx.strokeStyle = C.vecGreen + "25"; ctx.lineWidth = 1.5; ctx.setLineDash([3, 3]); ctx.beginPath(); ctx.arc(o[0], o[1], SC, 0, Math.PI * 2); ctx.stroke(); ctx.setLineDash([]); // Original faint drawArrow(ctx, o[0], o[1], ps[0], ps[1], C.vecBlue + "35", 2); // Normalized drawArrow(ctx, o[0], o[1], ns[0], ns[1], C.vecGreen, 2.5); // Speed vector drawArrow(ctx, o[0], o[1], ms[0], ms[1], C.vecAmber, 3, 12); // Character at speed tip drawSprite(ctx, ms[0], ms[1] - 20, t, { color: C.hero, darkColor: C.heroDark, lookAt: [ms[0] + norm[0] * 50, ms[1] - norm[1] * 50], happy: true }); // Labels ctx.font = "bold 11px 'IBM Plex Mono', monospace"; ctx.fillStyle = C.vecGreen; ctx.fillText("len=1", ns[0] + 8, ns[1] - 6); ctx.fillStyle = C.vecAmber; ctx.fillText(`×${spd.toFixed(1)}`, ms[0] + 20, ms[1] + 4); }, ptRef); useDragRef(canvasRef, ptRef, onUpdate); const mag = Math.hypot(pt[0], pt[1]) || 0.001; const norm = [pt[0] / mag, pt[1] / mag]; return ( <> Normalization — One Direction, Any Speed

Normalizing means dividing a vector by its length, squishing it to exactly length 1 — a unit vector. Same direction, no distance information. Then multiply by any speed you want.

The green arrow = normalized (always length 1, on the small circle). The amber arrow = normalized × speed. Scrub the speed: setSpeed(v)} min={0.5} max={6} step={0.5} color={C.vecAmber} /> and drag the blue direction.

normalize({pt[0].toFixed(1)}, {pt[1].toFixed(1)}) = ({norm[0].toFixed(3)}, {norm[1].toFixed(3)})
velocity = direction × {speed.toFixed(1)} = ({(norm[0] * speed).toFixed(2)}, {(norm[1] * speed).toFixed(2)})

That's the full recipe: velocity = normalize(target − me) × speed. Three operations. A walking character.

Without normalizing, diagonal movement is ~41% faster (√2 ≈ 1.414). Old games had this bug — you could speedrun by running diagonally. Normalize fixes it. ); } /* ═══════════════════════════════════════════ SECTION 4: DOT PRODUCT — ENEMY VISION ═══════════════════════════════════════════ */ function SectionDot() { const [fov, setFov] = useState(90); const fovRef = useRef(90); fovRef.current = fov; const ptRef = useRef([[2, 3]]); const [pt, setPt] = useState([2, 3]); const onUpdate = useCallback((p) => setPt(p[0]), []); const alertRef = useRef(0); const canvasRef = useAnimCanvas((ctx, w, h, t) => { drawCanvasBg(ctx, w, h); const playerG = ptRef.current[0]; const fovVal = fovRef.current; // Enemy patrols back and forth const enemyX = Math.sin(t * 0.0008) * 3; const enemyG = [enemyX, -1]; const facingG = [Math.cos(t * 0.0008) > 0 ? 1 : -1, 0.3]; // faces walk direction const eS = toS(enemyG[0], enemyG[1], w, h); const pS = toS(playerG[0], playerG[1], w, h); // Direction from enemy to player const toPlayer = [playerG[0] - enemyG[0], playerG[1] - enemyG[1]]; const tpMag = Math.hypot(toPlayer[0], toPlayer[1]) || 1; const fMag = Math.hypot(facingG[0], facingG[1]) || 1; const dotVal = (facingG[0] * toPlayer[0] + facingG[1] * toPlayer[1]) / (fMag * tpMag); const angle = Math.acos(Math.max(-1, Math.min(1, dotVal))) * 180 / Math.PI; const inFov = angle <= fovVal / 2 && tpMag < 6; if (inFov) alertRef.current = Math.min(1, alertRef.current + 0.05); else alertRef.current = Math.max(0, alertRef.current - 0.03); // FOV cone const fAngle = Math.atan2(-facingG[1], facingG[0]); const halfFov = (fovVal / 2) * Math.PI / 180; const coneR = 6 * SC; ctx.fillStyle = inFov ? `rgba(232,69,122,${0.08 + alertRef.current * 0.1})` : "rgba(124,92,191,0.06)"; ctx.beginPath(); ctx.moveTo(eS[0], eS[1]); ctx.arc(eS[0], eS[1], coneR, fAngle - halfFov, fAngle + halfFov); ctx.closePath(); ctx.fill(); ctx.strokeStyle = inFov ? C.vecPink + "60" : C.enemy + "30"; ctx.lineWidth = 1.5; ctx.stroke(); // Direction arrow from enemy to player (faint) drawDashed(ctx, eS[0], eS[1], pS[0], pS[1], inFov ? C.vecPink + "50" : C.muted + "30"); // Facing arrow const fEnd = toS(enemyG[0] + facingG[0] * 2 / fMag, enemyG[1] + facingG[1] * 2 / fMag, w, h); drawArrow(ctx, eS[0], eS[1], fEnd[0], fEnd[1], C.enemy + "80", 2, 8); // Enemy sprite drawSprite(ctx, eS[0], eS[1] - 20, t, { color: inFov ? C.vecPink : C.enemy, darkColor: inFov ? "#c43565" : C.enemyDark, lookAt: inFov ? [pS[0], pS[1]] : [fEnd[0], fEnd[1]], walking: true, alert: alertRef.current > 0.5, }); // Player sprite drawSprite(ctx, pS[0], pS[1] - 20, t, { color: C.hero, darkColor: C.heroDark, lookAt: [eS[0], eS[1]], }); // Angle label ctx.fillStyle = C.text; ctx.font = "bold 13px 'IBM Plex Mono', monospace"; ctx.fillText(`angle: ${angle.toFixed(0)}°`, eS[0] + 30, eS[1] - 40); ctx.fillStyle = inFov ? C.vecPink : C.vecGreen; ctx.fillText(inFov ? "SPOTTED!" : "hidden", eS[0] + 30, eS[1] - 24); }, ptRef); useDragRef(canvasRef, ptRef, onUpdate); return ( <> The Dot Product — "Can It See Me?"

The purple enemy patrols back and forth. It has a field of vision — a cone in front of its face. Drag your character around. Can you sneak behind it?

The dot product answers "what's the angle between two directions?" If the angle from the enemy's facing direction to the player is less than half the FOV, you're spotted!

Adjust the vision cone: FOV = °

dot(facing, toPlayer) = |F|·|P|·cos(angle) → if angle < FOV/2 → detected! In practice you skip expensive acos(). For a 90° FOV, check if dot(normalized_facing, normalized_toPlayer) > 0.707 (that's cos(45°)). Way faster. ); } /* ═══════════════════════════════════════════ SECTION 5: REFLECTION — BOUNCING BALL ═══════════════════════════════════════════ */ function SectionReflection() { const ballRef = useRef({ x: 0, y: 2, vx: 3.5, vy: 2.5, trail: [] }); const canvasRef = useAnimCanvas((ctx, w, h, t) => { drawCanvasBg(ctx, w, h, false); const b = ballRef.current; const dt = 0.035; const bounds = { l: -6.5, r: 6.5, t: 5, b: -4.5 }; // Physics step b.x += b.vx * dt; b.y += b.vy * dt; let bounced = false; if (b.x > bounds.r) { b.x = bounds.r; b.vx *= -1; bounced = true; } if (b.x < bounds.l) { b.x = bounds.l; b.vx *= -1; bounced = true; } if (b.y > bounds.t) { b.y = bounds.t; b.vy *= -1; bounced = true; } if (b.y < bounds.b) { b.y = bounds.b; b.vy *= -1; bounced = true; } b.trail.push({ x: b.x, y: b.y, t }); if (b.trail.length > 80) b.trail.shift(); // Draw walls const corners = [ toS(bounds.l, bounds.t, w, h), toS(bounds.r, bounds.t, w, h), toS(bounds.r, bounds.b, w, h), toS(bounds.l, bounds.b, w, h), ]; ctx.strokeStyle = C.textLight + "50"; ctx.lineWidth = 3; ctx.lineJoin = "round"; ctx.beginPath(); ctx.moveTo(corners[0][0], corners[0][1]); for (const c of corners.slice(1)) ctx.lineTo(c[0], c[1]); ctx.closePath(); ctx.stroke(); // Trail for (let i = 1; i < b.trail.length; i++) { const a = b.trail[i - 1], c = b.trail[i]; const [x1, y1] = toS(a.x, a.y, w, h); const [x2, y2] = toS(c.x, c.y, w, h); const alpha = (i / b.trail.length); ctx.strokeStyle = `rgba(59,139,235,${alpha * 0.5})`; ctx.lineWidth = 3 * alpha + 1; ctx.lineCap = "round"; ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke(); } // Ball const bs = toS(b.x, b.y, w, h); // Glow ctx.fillStyle = C.vecBlue + "20"; ctx.beginPath(); ctx.arc(bs[0], bs[1], 18, 0, Math.PI * 2); ctx.fill(); // Ball body ctx.fillStyle = C.vecBlue; ctx.beginPath(); ctx.arc(bs[0], bs[1], 10, 0, Math.PI * 2); ctx.fill(); // Highlight ctx.fillStyle = "#ffffff50"; ctx.beginPath(); ctx.arc(bs[0] - 3, bs[1] - 3, 4, 0, Math.PI * 2); ctx.fill(); // Impact particles if (bounced) { for (let i = 0; i < 5; i++) { const px = bs[0] + (Math.random() - 0.5) * 30; const py = bs[1] + (Math.random() - 0.5) * 30; ctx.fillStyle = C.star; ctx.beginPath(); ctx.arc(px, py, 2 + Math.random() * 2, 0, Math.PI * 2); ctx.fill(); } } // Velocity arrow const vEnd = toS(b.x + b.vx * 0.8, b.y + b.vy * 0.8, w, h); drawArrow(ctx, bs[0], bs[1], vEnd[0], vEnd[1], C.vecGreen, 2.5, 9); // Formula overlay ctx.fillStyle = C.text; ctx.font = "bold 12px 'IBM Plex Mono', monospace"; ctx.fillText("r = d − 2(d·n̂)n̂", 12, 22); }, null); return ( <> Reflection — Bouncing Off Walls

Watch the ball! Every time it hits a wall, its velocity gets reflected. The formula: r = d − 2(d·n̂)n̂ — take the incoming direction, figure out how much goes into the wall (dot product!), and flip just that component.

The green arrow is the current velocity. Watch how it flips perfectly at each bounce — that's the reflection formula working in real-time.

Notice something? The ball never speeds up or slows down. Reflection preserves magnitude — it only changes direction. The dot product with the normal tells you exactly how much of the velocity to reverse.

Pong, Breakout, billiards, bullet ricochets, even light bouncing off surfaces for shading — all this exact formula. The "normal" is just a vector perpendicular to the surface, pointing outward. ); } /* ═══════════════════════════════════════════ SECTION 6: LERP — multiple examples ═══════════════════════════════════════════ */ function lerpVal(a, b, t) { return a + t * (b - a); } function lerpColor(r1, g1, b1, r2, g2, b2, t) { return [Math.round(lerpVal(r1, r2, t)), Math.round(lerpVal(g1, g2, t)), Math.round(lerpVal(b1, b2, t))]; } function SectionLerp() { const [t, setT] = useState(0.35); const [anim, setAnim] = useState(false); const animRef = useRef(null); const tRef = useRef(0.35); tRef.current = t; useEffect(() => { if (!anim) { if (animRef.current) cancelAnimationFrame(animRef.current); return; } let start = null; const dur = 2500; const tick = (ts) => { if (!start) start = ts; const e = (ts - start) % (dur * 2); const v = e < dur ? e / dur : 2 - e / dur; setT(v); tRef.current = v; animRef.current = requestAnimationFrame(tick); }; animRef.current = requestAnimationFrame(tick); return () => cancelAnimationFrame(animRef.current); }, [anim]); // Color A = hero red, Color B = blue const colA = [232, 93, 74]; // hero red const colB = [59, 139, 235]; // vecBlue const [cr, cg, cb] = lerpColor(...colA, ...colB, t); const lerpedColor = `rgb(${cr},${cg},${cb})`; // Size lerp const sizeA = 16, sizeB = 64; const lerpedSize = lerpVal(sizeA, sizeB, t); // Health bar lerp const hpA = 100, hpB = 23; const lerpedHp = lerpVal(hpA, hpB, t); const canvasRef = useAnimCanvas((ctx, w, h, time) => { drawCanvasBg(ctx, w, h, false); const tv = tRef.current; const colNow = lerpColor(...colA, ...colB, tv); const rgbNow = `rgb(${colNow[0]},${colNow[1]},${colNow[2]})`; const sizeNow = lerpVal(sizeA, sizeB, tv); const hpNow = lerpVal(hpA, hpB, tv); const pad = 50; const rowH = h / 4; // === ROW 1: POSITION === const y1 = rowH * 0.6; const ax = pad + 30, bx = w - pad - 30; const lx = lerpVal(ax, bx, tv); // Track ctx.strokeStyle = "#b0c4de50"; ctx.lineWidth = 28; ctx.lineCap = "round"; ctx.beginPath(); ctx.moveTo(ax, y1); ctx.lineTo(bx, y1); ctx.stroke(); ctx.lineCap = "butt"; // Progress ctx.strokeStyle = C.vecAmber + "30"; ctx.lineWidth = 28; ctx.lineCap = "round"; ctx.beginPath(); ctx.moveTo(ax, y1); ctx.lineTo(lx, y1); ctx.stroke(); ctx.lineCap = "butt"; // End markers ctx.fillStyle = C.hero; ctx.beginPath(); ctx.arc(ax, y1, 6, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = C.vecBlue; ctx.beginPath(); ctx.arc(bx, y1, 6, 0, Math.PI * 2); ctx.fill(); // Character drawSprite(ctx, lx, y1 - 20, time, { color: C.hero, darkColor: C.heroDark, lookAt: [bx, y1], walking: Math.abs(tv % 1) > 0.02 && Math.abs(tv % 1) < 0.98, happy: tv > 0.95 }); // Label ctx.fillStyle = C.textLight; ctx.font = "bold 12px 'IBM Plex Mono', monospace"; ctx.fillText("POSITION", 12, y1 - 32); // === ROW 2: COLOR === const y2 = rowH * 1.7; // Gradient bar for (let i = 0; i < w - pad * 2; i++) { const frac = i / (w - pad * 2); const [r, g, b] = lerpColor(...colA, ...colB, frac); ctx.fillStyle = `rgb(${r},${g},${b})`; ctx.fillRect(pad + i, y2 - 14, 1, 28); } // Border rrect(ctx, pad, y2 - 14, w - pad * 2, 28, 8); ctx.strokeStyle = "#00000015"; ctx.lineWidth = 2; ctx.stroke(); // Current position indicator const cx = lerpVal(pad, w - pad, tv); ctx.fillStyle = "#fff"; ctx.strokeStyle = "#00000030"; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(cx, y2, 12, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); ctx.fillStyle = rgbNow; ctx.beginPath(); ctx.arc(cx, y2, 9, 0, Math.PI * 2); ctx.fill(); // End color swatches ctx.fillStyle = `rgb(${colA[0]},${colA[1]},${colA[2]})`; ctx.beginPath(); ctx.arc(pad - 14, y2, 8, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = `rgb(${colB[0]},${colB[1]},${colB[2]})`; ctx.beginPath(); ctx.arc(w - pad + 14, y2, 8, 0, Math.PI * 2); ctx.fill(); // Label ctx.fillStyle = C.textLight; ctx.font = "bold 12px 'IBM Plex Mono', monospace"; ctx.fillText("COLOR", 12, y2 - 26); // === ROW 3: SIZE === const y3 = rowH * 2.7; // Size track ctx.strokeStyle = "#b0c4de30"; ctx.lineWidth = 1.5; ctx.setLineDash([4, 4]); ctx.beginPath(); ctx.moveTo(pad + 30, y3); ctx.lineTo(w - pad - 30, y3); ctx.stroke(); ctx.setLineDash([]); // Small circle (A) ctx.fillStyle = C.hero + "30"; ctx.strokeStyle = C.hero + "60"; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(pad + 30, y3, sizeA / 2, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); // Big circle (B) ctx.fillStyle = C.vecBlue + "15"; ctx.strokeStyle = C.vecBlue + "40"; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(w - pad - 30, y3, sizeB / 2, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); // Lerped circle const sx = lerpVal(pad + 30, w - pad - 30, tv); ctx.fillStyle = rgbNow; ctx.beginPath(); ctx.arc(sx, y3, sizeNow / 2, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = "#ffffff40"; ctx.beginPath(); ctx.arc(sx - sizeNow * 0.12, y3 - sizeNow * 0.12, sizeNow * 0.18, 0, Math.PI * 2); ctx.fill(); // Label ctx.fillStyle = C.textLight; ctx.font = "bold 12px 'IBM Plex Mono', monospace"; ctx.fillText("SIZE", 12, y3 - 38); // === ROW 4: HEALTH BAR === const y4 = rowH * 3.5; const barW = w - pad * 2 - 20; const barH = 22; // Background ctx.fillStyle = "#e8dfd4"; rrect(ctx, pad + 10, y4 - barH / 2, barW, barH, 6); ctx.fill(); // Fill const hpFrac = hpNow / 100; const hpCol = hpFrac > 0.5 ? C.vecGreen : hpFrac > 0.25 ? C.vecAmber : C.hero; ctx.fillStyle = hpCol; if (barW * hpFrac > 4) { rrect(ctx, pad + 10, y4 - barH / 2, barW * hpFrac, barH, 6); ctx.fill(); } // HP text ctx.fillStyle = "#fff"; ctx.font = "bold 11px 'IBM Plex Mono', monospace"; ctx.textAlign = "center"; ctx.fillText(`${Math.round(hpNow)} HP`, pad + 10 + barW / 2, y4 + 4); ctx.textAlign = "left"; // Label ctx.fillStyle = C.textLight; ctx.font = "bold 12px 'IBM Plex Mono', monospace"; ctx.fillText("HEALTH BAR", 12, y4 - 18); // t indicator line across all rows const tLineX = lerpVal(pad + 30, w - pad - 30, tv); ctx.strokeStyle = C.vecAmber + "25"; ctx.lineWidth = 1.5; ctx.setLineDash([3, 4]); ctx.beginPath(); ctx.moveTo(tLineX, 10); ctx.lineTo(tLineX, h - 10); ctx.stroke(); ctx.setLineDash([]); }, null); return ( <> Lerp — The Smoothness Secret

Linear interpolation blends between two values. At t=0 you get value A. At t=1 you get value B. At t=0.5 you're halfway. The key insight: it works on anything — numbers, positions, colors, sizes. Same formula.

lerp(A, B, t) = A + t × (B − A)

One slider controls all four rows below. Scrub it — watch position, color, size, and a health bar all respond to the same t value: {" "}t = { setT(v); tRef.current = v; setAnim(false); }} min={0} max={1} step={0.01} color={C.vecAmber} /> {" "} setAnim(!anim)} style={{ display: "inline-block", cursor: "pointer", padding: "4px 14px", borderRadius: 8, background: anim ? C.vecPink : C.vecAmber, color: "#fff", fontFamily: "'Nunito', sans-serif", fontSize: 14, fontWeight: 700 }}> {anim ? "■ Stop" : "▶ Animate"}

position: {lerpVal(0, 100, t).toFixed(0)}% color: rgb({cr},{cg},{cb}) size: {lerpedSize.toFixed(0)}px hp: 50 ? C.vecGreen : lerpedHp > 25 ? C.vecAmber : C.hero }}>{Math.round(lerpedHp)}/100

See? One formula, four completely different effects. The health bar transitions from green to amber to red as it drains — that's lerping both the width and the color simultaneously. In a game you'd drive t with time: t = elapsed / duration.

An even more common pattern: lerp with a fixed factor each frame — camera = lerp(camera, player, 0.1). This gives you "exponential easing" — fast at first, gently slowing. That's the "juice" behind every smooth camera, health bar animation, and UI transition in polished games.

Lerp is the single most-used utility function in game development. Camera smoothing, color fading, damage numbers, UI transitions, smooth movement, even music crossfades. Once you see it, you'll notice it everywhere. ); } /* ═══════════════════════════════════════════ SECTION 7: PLAYGROUND ═══════════════════════════════════════════ */ function SectionPlayground() { const stateRef = useRef({ char: [0, 0], target: null, trail: [], walking: false, }); const canvasRef = useAnimCanvas((ctx, w, h, t) => { drawCanvasBg(ctx, w, h); const s = stateRef.current; const bounds = { l: -6.5, r: 6.5, t: 4.5, b: -4 }; // Walk toward target if (s.target) { const dx = s.target[0] - s.char[0], dy = s.target[1] - s.char[1]; const d = Math.hypot(dx, dy); if (d > 0.12) { s.char[0] += (dx / d) * 0.05; s.char[1] += (dy / d) * 0.05; s.char[0] = Math.max(bounds.l, Math.min(bounds.r, s.char[0])); s.char[1] = Math.max(bounds.b, Math.min(bounds.t, s.char[1])); s.trail.push([...s.char]); if (s.trail.length > 100) s.trail.shift(); s.walking = true; } else { s.walking = false; s.target = null; } } // Walls const corners = [ toS(bounds.l, bounds.t, w, h), toS(bounds.r, bounds.t, w, h), toS(bounds.r, bounds.b, w, h), toS(bounds.l, bounds.b, w, h), ]; ctx.strokeStyle = C.muted + "40"; ctx.lineWidth = 3; ctx.lineJoin = "round"; ctx.beginPath(); corners.forEach((c, i) => i === 0 ? ctx.moveTo(c[0], c[1]) : ctx.lineTo(c[0], c[1])); ctx.closePath(); ctx.stroke(); // Trail for (let i = 1; i < s.trail.length; i++) { const [x1, y1] = toS(s.trail[i - 1][0], s.trail[i - 1][1], w, h); const [x2, y2] = toS(s.trail[i][0], s.trail[i][1], w, h); const alpha = i / s.trail.length; ctx.strokeStyle = `rgba(34,181,115,${alpha * 0.4})`; ctx.lineWidth = 2 + alpha * 2; ctx.lineCap = "round"; ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke(); } // Target star if (s.target) { const ts = toS(s.target[0], s.target[1], w, h); drawStar(ctx, ts[0], ts[1] - 4, 11, t); // Direction line const cs = toS(s.char[0], s.char[1], w, h); drawDashed(ctx, cs[0], cs[1], ts[0], ts[1], C.vecGreen + "40"); } // Character const cs = toS(s.char[0], s.char[1], w, h); drawSprite(ctx, cs[0], cs[1] - 20, t, { color: C.hero, darkColor: C.heroDark, lookAt: s.target ? toS(s.target[0], s.target[1], w, h) : null, walking: s.walking, happy: !s.walking && !s.target, }); // Instructions if (!s.target && s.trail.length === 0) { ctx.fillStyle = C.textLight + "80"; ctx.font = "16px 'Nunito', sans-serif"; ctx.textAlign = "center"; ctx.fillText("Click anywhere!", w / 2, h / 2 + 60); ctx.textAlign = "left"; } }, null); useEffect(() => { const c = canvasRef.current; if (!c) return; const onClick = (e) => { const r = c.getBoundingClientRect(); const [gx, gy] = toG(e.clientX - r.left, e.clientY - r.top, r.width, r.height); stateRef.current.target = [gx, gy]; }; c.addEventListener("pointerdown", onClick); return () => c.removeEventListener("pointerdown", onClick); }, []); return ( <> Put It All Together

Click anywhere in the arena. Watch your character walk there using everything you've learned:

target − position (subtraction) → normalize() (unit direction) → × speed (scalar) → position += velocity (addition) → stop when distance < 0.12 (magnitude)

Five concepts. One walking character. That fading green trail is every position calculated by the exact math we covered. You could ship a game with just this.

); } /* ═══════════════════════════════════════════ MAIN APP ═══════════════════════════════════════════ */ export default function App() { return (
{/* HERO */}
AN EXPLORABLE EXPLANATION

Vector Math{" "} for Game Devs

Everything you need to make things move, bounce, and sneak — explained with characters you can drag around.

drag gold numbers drag characters click to move

Every game you've ever loved is doing vector math, all the time, as fast as it can. When a character jumps, that's a vector. When a bullet ricochets, that's a reflection. When the camera smoothly follows you, that's lerp.

The beautiful thing? You only need a handful of operations. Let's learn them by playing with them.

Keep Building

You now have the core toolkit. Vectors, subtraction, normalization, dot products, reflection, and lerp — this covers movement, collision, AI vision, smooth cameras, and physics. Everything else in game math (matrices, transforms, quaternions) builds on these same ideas.

Build a Pong clone (addition + reflection). Add enemy AI (dot product). Polish it with lerp. Go make something move.

built as an explorable explanation · drag everything · break nothing
); }