Skip to content
· 5 min read · 0 views

ASCII Donut Math Animation — 30 Days Web Challenge Day 2

The classic donut.c torus rendered in real-time ASCII art with sin/cos math, z-buffer depth, and luminance mapping — with a glitch mode that scrambles into chaos when you click it.

// table of contents (9 sections)

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


Live Demo

The ASCII donut spins on the landing page — click it 5 times to trigger the explosion:


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);
};

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;
}

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",
}}

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]);

Tech Stack

TechnologyPurpose
Next.jsReact framework
TypeScriptType-safe math operations
Canvas / pre elementASCII character rendering
Web Audio APIGlitch and explosion sound synthesis
Framer MotionParticle animations for explosion

Follow the challenge:

Support the challenge:

You might also like

Enjoyed This Post?

Want to discuss the topic, have questions, or looking to collaborate on something similar? Drop a comment below or reach out directly.

Discussion