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!" }) => (
);
/* ═══════════════════════════════════════════
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
);
}