DEV Community

Cover image for ASCII Donut Math Animation — 30 Days Web Challenge Day 2
Muhammad Abdu ar Rahman
Muhammad Abdu ar Rahman

Posted on • Originally published at abduarrahman.com

ASCII Donut Math Animation — 30 Days Web Challenge Day 2

Try it live at 30days.abduarrahman.com — and the source code is on GitHub.


The Origin

Every programmer has seen donut.c — the legendary C code that renders a spinning 3D torus using ASCII characters. It's a rite of passage.

For Day 2 of the 30 Days Web Challenge, I wanted to bring this classic to the browser — not just as a static render, but as an interactive element with glitch effects and sound. The donut sits on the landing page as the "0" in "30 Days", and clicking it reveals hidden easter eggs.


What I Built

A real-time ASCII torus renderer with:

  • Mathematical torus rendering — the classic sin/cos projection with z-buffer depth sorting and luminance mapping
  • Smooth rotation — two rotation angles (A and B) increment each frame for continuous spinning
  • Glitch mode — clicking the donut adds random rotation offsets and luminance scrambling
  • Neon glow styling — cyan text with textShadow for that retro terminal feel
  • Interactive easter egg — click the donut 5 times on the landing page to trigger an explosion with particles

How It Works

The Torus Rendering Algorithm

The core is the classic donut math — project a 3D torus onto a 2D character grid using two rotation angles:

const render = () => {
  const b = new Int8Array(width * height).fill(-1); // output buffer
  const z = new Float32Array(width * height);         // z-buffer

  const sA = Math.sin(A), cA = Math.cos(A);
  const sB = Math.sin(B), cB = Math.cos(B);

  for (let j = 0; j < 6.283185; j += 0.07) {   // theta: around the tube
    const st = Math.sin(j), ct = Math.cos(j);
    for (let i = 0; i < 6.283185; i += 0.02) {  // phi: around the ring
      const sp = Math.sin(i), cp = Math.cos(i);
      const h = ct + 2;
      const D = 1 / (sp * h * sA + st * cA + 5);  // perspective
      const t = sp * h * cA - st * sA;

      const x = ~~(cx + kx * D * (cp * h * cB - t * sB));
      const y = ~~(cy + ky * D * (cp * h * sB + t * cB));

      const o = x + width * y;
      const N = ~~(8 * ((st * sA - sp * ct * cA) * cB
        - sp * ct * sA - st * cA - cp * ct * sB));

      if (y > 0 && y < height && x > 0 && x < width && D > z[o]) {
        z[o] = D;
        b[o] = N > 0 ? N : -1;
      }
    }
  }

  // Render to <pre> element using luminance characters
  if (preRef.current) {
    let s = "";
    for (let k = 0; k < width * height; k++) {
      if (k > 0 && k % width === 0) s += "\n";
      s += b[k] >= 0 ? lum[b[k]] : " ";
    }
    preRef.current.textContent = s;
  }

  A += 0.015;
  B += 0.008;
  frameId = requestAnimationFrame(render);
};
Enter fullscreen mode Exit fullscreen mode

The luminance string .,-~:;=!*#$@ maps brightness values to ASCII characters — from dim (dot) to bright (@).

Glitch Mode

When the donut is clicked on the landing page, random offsets are injected into the rotation calculations, and 15% of luminance values get randomly scrambled:

// Glitch: add random rotation offset
const glitchA = glitching ? (Math.random() - 0.5) * 0.5 : 0;
const glitchB = glitching ? (Math.random() - 0.5) * 0.3 : 0;

// During glitch, randomly scramble luminance
if (glitching && Math.random() < 0.15) {
  b[o] = ~~(Math.random() * 12);
} else {
  b[o] = N > 0 ? N : -1;
}
Enter fullscreen mode Exit fullscreen mode

The visual styling switches from calm cyan to chaotic rainbow with red/orange glow:

style={{
  color: glitching ? `hsl(${Math.random() * 360}, 100%, 70%)` : "#00AFFF",
  textShadow: glitching
    ? "0 0 8px #ff0066, 0 0 20px #ff6600"
    : "0 0 6px #00AFFF, 0 0 20px #0077ff",
}}
Enter fullscreen mode Exit fullscreen mode

The 5-Click Easter Egg

The donut is interactive — each click triggers a 1-second glitch with a synthesized sound. After 5 clicks, the donut explodes into 30 colored particles:

const handleDonutClick = useCallback(() => {
  if (isGlitching || isExploding || showDonutPopup) return;
  const newCount = donutClicks + 1;

  if (newCount >= 5) {
    // BOOM!
    setDonutClicks(0);
    setIsExploding(true);
    playExplosionSound();

    const colors = ["#00AFFF", "#00E676", "#ff0066", "#ff6600", "#6C5CE7", "#FFD700"];
    const particles = Array.from({ length: 30 }, (_, i) => ({
      id: explosionId.current++,
      x: 50, y: 40,
      angle: (i / 30) * Math.PI * 2 + Math.random() * 0.5,
      speed: 5 + Math.random() * 15,
      color: colors[i % colors.length],
    }));
    setExplosionParticles(particles);
  } else {
    // Glitch for 1 second
    setDonutClicks(newCount);
    setIsGlitching(true);
    playGlitchSound();
  }
}, [donutClicks, isGlitching, isExploding, showDonutPopup]);
Enter fullscreen mode Exit fullscreen mode

Tech Stack

Technology Purpose
Next.js React framework
TypeScript Type-safe math operations
Canvas / pre element ASCII character rendering
Web Audio API Glitch and explosion sound synthesis
Framer Motion Particle animations for explosion

Links

Follow the challenge:

Support the challenge:


Originally published at abduarrahman.com

Top comments (0)