One canvas, zero libraries, ~600 lines. Everything below — the physics, the origami, the sounds, even the paper grain — is procedural. Here is every trick, with the real code.
Paper World is a desk you can play on. Five kinds of paper toys — a dart plane, an origami crane, a hopper frog, a shuriken star and a crumpled wad — live under springy 2D physics. You drag a toy (it chases your pointer on a spring, so it wobbles like real paper), fling it (release velocity is sampled from your last 90 ms of pointer movement), or tap it for a squash-and-stretch boing. Joy is the KPI: every collision squashes, every landing thuds, confetti flutters like real offcuts.
Design constraints that shaped everything:
#d9bf94#faf3e6#33261a#ef5b3f#2fa9a0#ffc93c#5aa9d6Each toy color is a 3-tone ramp — base / shade / deep — because folded paper reads through value steps between facets, not gradients. Warm ink (#33261a, never black) keeps outlines and shadows in the same temperature as the kraft board.
Caprasimo for display — chunky, rounded, die-cut energy — and Nunito (700–800) for UI. The headline letters are individual <span>s, each with a palette color, a random ±4° rotation, and a layered text-shadow (a hard ink offset + a soft drop) so they read as stacked paper cutouts:
text-shadow: 0 1px 0 rgba(255,255,255,.35), /* top paper edge highlight */ 3px 4px 0 var(--ink), /* hard die-cut backing layer */ 7px 9px 12px rgba(60,35,5,.30); /* soft cast shadow on the desk */
A fixed overlay div with mix-blend-mode:multiply tiles an inline data-URI SVG using feTurbulence. That one rule makes every flat color read as fibrous paper:
<filter> <feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2"/> <feColorMatrix type="saturate" values="0"/> <feComponentTransfer><feFuncA type="linear" slope="0.09"/></feComponentTransfer> </filter>
The torn shelf edge along the bottom is a single CSS clip-path: polygon(...) with ~24 jittered points — cheaper and crisper than any PNG mask.
Each toy is a single rigid body: position, velocity, angle, angular velocity, plus a scalar squash spring. Integration is plain semi-implicit Euler with a clamped dt (max 33 ms) so background tabs can't explode the simulation.
The trick that makes paper feel alive is a damped spring on a scale factor sq, kicked on every impact and rendered as a volume-preserving scale along the impact axis:
// kicked on impact: this.sqv -= impactStrength const k = 210, damp = 11; this.sqv += (1 - this.sq) * k * dt; // pull back toward 1 this.sqv *= Math.exp(-damp * dt); // frame-rate-safe damping this.sq += this.sqv * dt; // render: squash along axis, stretch across it (volume preserved) ctx.rotate(b.sqAxis); ctx.scale(2 - b.sq, b.sq); ctx.rotate(-b.sqAxis);
Math.exp(-damp·dt) instead of a fixed multiplier keeps the wobble identical at 30, 60 or 144 fps.A grabbed toy is never glued to the cursor. It chases it with a stiff spring and heavy damping, and banks into the motion — this lag is what makes the paper feel light:
b.vx += (targetX - b.x) * 326 * dt; // spring toward pointer b.vx *= Math.exp(-14 * dt); // heavy damping = no orbiting // bank into lateral velocity, eased: b.a += (clamp(b.vx * 0.0009, -.6, .6) - b.a) * Math.min(1, dt * 8);
On release, velocity is measured across the last ≤90 ms of pointer samples — not the last frame, which is far too noisy:
hist.push({x, y, t: performance.now()}); while(hist.length > 2 && now - hist[0].t > 90) hist.shift(); // on pointerup: const dt = Math.max(0.016, (last.t - first.t) / 1000); vx = (last.x - first.x) / dt; vy = (last.y - first.y) / dt; // then cap at 2600 px/s
A press under 260 ms that moved under 14 px is a tap instead → upward impulse + big squash kick + a synthesized boing.
The plane gets one extra rule that sells the whole fantasy — when airborne and fast, gravity is cut to 22% and its nose eases toward its velocity vector:
if(type === 'plane' && !grounded && speed > 260){ g *= 0.22; // lift const target = Math.atan2(vy, vx); // nose follows flight path a += shortestAngle(target - a) * Math.min(1, dt * 9); }
Toys collide as soft circles (radius ≈ 62% of their draw size): O(n²) pairs, positional separation plus a restitution impulse, and — crucially — both bodies get a squash kick along the contact normal. With a 26-body cap, n² is nothing.
Every toy is a handful of flat-shaded triangles/quads in local coordinates, drawn through two helpers (poly and edge). The folding illusion comes entirely from adjacent facets taking different steps of the 3-tone ramp, exactly like real origami catching light. The crane is 7 polygons:
poly([[-38,-8],[-12,4],[-14,14]], c.shade); // tail poly([[-16,-2],[18,-4],[26,14],[-8,16]], c.base); // body poly([[18,-4],[30,-34],[26,14]], c.shade); // neck poly([[30,-34],[42,-30],[31,-24]], c.deep); // beak poly([[-6,2],[10,-40],[20,0]], c.base); // wing (lit) poly([[-6,2],[10,-40],[2,4]], c.shade); // wing (fold)
The crumpled ball generates a random 9-vertex hull per instance, fans alternating shaded facets from the center, and strokes a few crease lines — so no two crumples are identical.
Each body casts a squashed ellipse on the desk line whose size and opacity are functions of height — the single cheapest depth cue in 2D:
const h = clamp((GROUND - b.y) / 420, 0, 1); // 0 = on desk, 1 = high ctx.scale(1, 0.28); ctx.fillStyle = `rgba(74,48,16,${0.30 * (1 - h*0.78)})`;
devicePixelRatio is capped at 2 and the canvas context uses a single setTransform — no per-frame DPR math.
Confetti pieces are tiny two-tone rects with heavy drag, a sinusoidal sway added directly to position, and a flip: the piece's vertical scale flips sign on a sine wave, so each rectangle appears to tumble over its long axis like a real paper offcut:
p.x += p.vx*dt + Math.sin(p.t*7 + p.phase) * 36 * dt; // sway ctx.scale(1, Math.sin(p.t*9 + p.phase) > -0.3 ? 1 : -1); // tumble flip
Population is capped at 420 pieces; old ones are spliced out first, so the frame budget never blows.
No audio files. One shared white-noise buffer is routed through a bandpass filter with an exponential decay envelope — the center frequency alone decides the material story:
| Sound | Recipe |
|---|---|
| fwip (flick) | noise → bandpass 1.6–2.4 kHz, 90 ms decay |
| thud (landing) | noise @ 300–500 Hz + triangle osc pitch-dropping 200→70 Hz |
| boing (tap) | sine chirp 180→600 Hz over 120 ms + a whisper of noise |
function paperNoise(freq, dur, gain){ const src = AC.createBufferSource(); src.buffer = getNoise(); const bp = AC.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.value = freq; const g = AC.createGain(); g.gain.setValueAtTime(gain, t); g.gain.exponentialRampToValueAtTime(0.001, t + dur); src.connect(bp).connect(g).connect(AC.destination); }
The AudioContext is created only after a user gesture, behind a visible paper-tab toggle — sound is opt-in, as it should be.
The fortune teller (cootie catcher) section is a single SVG drawn from eight triangles — four flaps, each split into a light/shade pair on the same 3-tone ramp as the toys. The "chomp" is nothing but alternating non-uniform scales stepped on a timer, reusing the site's squash grammar:
const steps = [[1.16,.74],[.74,1.16],[1.16,.74],[.74,1.16],[1,1]]; steps.forEach((s,i) => setTimeout(() => svg.style.transform = `scale(${s[0]},${s[1]})`, 140*i));
Each chomp fires a short bandpassed noise burst (a page-turn "fwip"), and the fortune slip slides up on a back-out ease. Twelve fortunes, never the same one twice in a row; with prefers-reduced-motion the chomp is skipped and the slip simply fades in. The desk also answers the keyboard: 1–5 spawn toys, C bursts confetti, T tidies up — ignored while the hero is off-screen.
.2,1.6,.35,1) staggered 45 ms apart; the shelf slides up; toys rain in staggered 180 ms apart with a scale-in "born" ease.cubic-bezier(.2,2,.4,1)), the flick-counter stamp does a rubber-band bump on every fling, the cursor swaps grab/grabbing.prefers-reduced-motion kills entrance animations, dust, idle wobble and shrinks confetti bursts — physics stays, because it's user-driven.opacity:0 keyframes with both fill.The whole site is one HTML file (plus this guide). To build your own paper world:
Libraries used: none. Fonts from Google Fonts (Caprasimo, Nunito, JetBrains Mono). No AI-generated assets — every pixel and every sample is procedural.