ASCII Art: How to Create Stunning Text-Based Art With Code
ASCII art is one of the oldest forms of computer art — images made entirely from text characters. It predates the graphical user interface, born in the era of teletypes and dot-matrix printers when characters were the only pixels available. But far from being a relic, ASCII art is experiencing a renaissance in creative coding, terminal aesthetics, and generative art.
What makes ASCII art fascinating from a programming perspective is the constraint: you have roughly 95 printable ASCII characters to represent an entire visual world. That limitation forces creative solutions — choosing the right character density, mapping brightness to glyphs, finding patterns that trick the eye into seeing curves and gradients in a grid of rectangles.
This guide covers eight working ASCII art systems you can build in your browser. Every example uses vanilla JavaScript — no libraries, no frameworks. Just characters, canvas (for image processing), and the `
` tag.The fundamentals: character density and brightness mapping
The core principle of ASCII art is simple: some characters appear "darker" (denser) than others. The character
@fills more of its cell than.does. By mapping pixel brightness to a sorted string of characters, you can render any image as text.The classic density ramp, from darkest to lightest:
const DENSITY = ' .:-=+*#%@'; // Or reversed (light background): const DENSITY_REV = '@%#*+=-:. ';A longer ramp gives more tonal resolution:
const DENSITY_LONG = ' .`^",:;Il!i><~+_-?][}{1)(|\\/tfjrxnuvczXYUJCLQ0OZmwqpdbkhao*#MW&8%B@$';Each character in the string is assigned to a brightness range. A pixel with brightness 0 (black) maps to
@, and brightness 255 (white) maps to(space). Everything else is linearly interpolated.1. Basic brightness-to-ASCII renderer
The simplest ASCII art generator: draw a gradient and convert each cell to a character based on brightness. This demonstrates the core mapping algorithm without any image processing.
const WIDTH = 80, HEIGHT = 40; const CHARS = ' .:-=+*#%@'; const pre = document.createElement('pre'); pre.style.cssText = 'font:10px monospace;line-height:1;background:#000;color:#0f0;padding:8px'; document.body.appendChild(pre); function render(t) { let text = ''; for (let y = 0; y < HEIGHT; y++) { for (let x = 0; x < WIDTH; x++) { // Animated radial gradient const dx = x / WIDTH - 0.5, dy = y / HEIGHT - 0.5; const dist = Math.sqrt(dx * dx + dy * dy) * 2; const wave = Math.sin(dist * 10 - t * 0.002) * 0.5 + 0.5; const idx = Math.floor(wave * (CHARS.length - 1)); text += CHARS[idx]; } text += '\n'; } pre.textContent = text; requestAnimationFrame(render); } requestAnimationFrame(render);This creates an animated ripple pattern rendered entirely in text characters. The key insight:
Math.floor(brightness * (CHARS.length - 1))is all you need to convert any value between 0 and 1 into an ASCII character.2. Image-to-ASCII converter
The classic ASCII art application: convert a photograph or image into text. This uses an offscreen canvas to sample pixel data, then maps each block of pixels to one ASCII character.
const CHARS = ' .,:;i1tfLCG08@'; const CELL_W = 6, CELL_H = 10; // Approximate monospace char aspect ratio const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); const pre = document.createElement('pre'); pre.style.cssText = 'font:8px monospace;line-height:1;background:#111;color:#ddd;padding:8px'; document.body.appendChild(pre); function imageToAscii(img) { const cols = Math.floor(img.width / CELL_W); const rows = Math.floor(img.height / CELL_H); canvas.width = img.width; canvas.height = img.height; ctx.drawImage(img, 0, 0); const data = ctx.getImageData(0, 0, img.width, img.height).data; let text = ''; for (let row = 0; row < rows; row++) { for (let col = 0; col < cols; col++) { let sum = 0, count = 0; // Average brightness of the cell for (let dy = 0; dy < CELL_H; dy++) { for (let dx = 0; dx < CELL_W; dx++) { const px = (col * CELL_W + dx); const py = (row * CELL_H + dy); if (px < img.width && py < img.height) { const i = (py * img.width + px) * 4; // Luminance formula sum += data[i] * 0.299 + data[i+1] * 0.587 + data[i+2] * 0.114; count++; } } } const brightness = sum / count / 255; const idx = Math.floor(brightness * (CHARS.length - 1)); text += CHARS[idx]; } text += '\n'; } pre.textContent = text; } // Generate a test image procedurally const testCanvas = document.createElement('canvas'); testCanvas.width = 240; testCanvas.height = 200; const tctx = testCanvas.getContext('2d'); // Draw a sphere with shading for (let y = 0; y < 200; y++) { for (let x = 0; x < 240; x++) { const dx = (x - 120) / 80, dy = (y - 100) / 80; const d = dx * dx + dy * dy; if (d < 1) { const nz = Math.sqrt(1 - d); const light = Math.max(0, dx * -0.5 + dy * -0.3 + nz * 0.8); const c = Math.floor(light * 255); tctx.fillStyle = `rgb(${c},${c},${c})`; tctx.fillRect(x, y, 1, 1); } } } const img = new Image(); img.src = testCanvas.toDataURL(); img.onload = () => imageToAscii(img);The critical detail is the cell aspect ratio. Monospace characters are taller than they are wide (roughly 2:1), so each ASCII "pixel" samples a rectangular block of image pixels, not a square one. Without this correction, the output appears horizontally stretched.
3. Edge-detection ASCII art
Instead of mapping brightness to characters, detect edges and use directional characters (
| - / \ +) to trace outlines. This produces a completely different aesthetic — architectural, blueprint-like.const W = 80, H = 50; const canvas = document.createElement('canvas'); canvas.width = W; canvas.height = H; const ctx = canvas.getContext('2d'); const pre = document.createElement('pre'); pre.style.cssText = 'font:10px monospace;line-height:1;background:#001;color:#0cf;padding:8px'; document.body.appendChild(pre); function edgeAscii(t) { // Draw animated shapes to a tiny canvas ctx.fillStyle = '#000'; ctx.fillRect(0, 0, W, H); ctx.fillStyle = '#fff'; // Rotating rectangle ctx.save(); ctx.translate(W / 2, H / 2); ctx.rotate(t * 0.001); ctx.fillRect(-15, -10, 30, 20); ctx.restore(); // Circle ctx.beginPath(); ctx.arc(20 + Math.sin(t * 0.002) * 10, H / 2, 8, 0, Math.PI * 2); ctx.fill(); const data = ctx.getImageData(0, 0, W, H).data; const gray = new Float32Array(W * H); for (let i = 0; i < W * H; i++) { gray[i] = data[i * 4] / 255; } let text = ''; for (let y = 1; y < H - 1; y++) { for (let x = 1; x < W - 1; x++) { // Sobel operator const gx = -gray[(y-1)*W+x-1] + gray[(y-1)*W+x+1] - 2*gray[y*W+x-1] + 2*gray[y*W+x+1] - gray[(y+1)*W+x-1] + gray[(y+1)*W+x+1]; const gy = -gray[(y-1)*W+x-1] - 2*gray[(y-1)*W+x] - gray[(y-1)*W+x+1] + gray[(y+1)*W+x-1] + 2*gray[(y+1)*W+x] + gray[(y+1)*W+x+1]; const mag = Math.sqrt(gx * gx + gy * gy); if (mag > 0.3) { const angle = Math.atan2(gy, gx) * 180 / Math.PI; if (angle > -22.5 && angle <= 22.5) text += '-'; else if (angle > 22.5 && angle <= 67.5) text += '/'; else if (angle > 67.5 && angle <= 112.5) text += '|'; else if (angle > 112.5 && angle <= 157.5) text += '\\'; else if (angle <= -112.5 || angle > 157.5) text += '-'; else if (angle <= -67.5) text += '/'; else if (angle <= -22.5) text += '|'; else text += '\\'; } else { text += ' '; } } text += '\n'; } pre.textContent = text; requestAnimationFrame(edgeAscii); } requestAnimationFrame(edgeAscii);The Sobel operator calculates horizontal and vertical gradients at each pixel. The gradient magnitude tells us "is there an edge here?" and the gradient direction tells us "which way does it face?" — which we map to the closest line character.
4. Matrix rain (digital rain effect)
The iconic falling-characters effect from The Matrix. Each column drops characters at different speeds, with a bright head and fading trail. This is perhaps the most recognizable ASCII art effect in popular culture.
const COLS = 80, ROWS = 40; const pre = document.createElement('pre'); pre.style.cssText = 'font:12px monospace;line-height:1;background:#000;padding:8px;overflow:hidden'; document.body.appendChild(pre); const KATAKANA = 'ヲァィゥェォャュョッーアイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワン'; const DIGITS = '0123456789'; const ALL = KATAKANA + DIGITS; // Per-column state const drops = []; for (let i = 0; i < COLS; i++) { drops.push({ y: Math.random() * -ROWS, speed: 0.3 + Math.random() * 0.7, length: 8 + Math.floor(Math.random() * 20) }); } // Screen buffer: [row][col] = { char, brightness } const screen = []; for (let r = 0; r < ROWS; r++) { screen[r] = []; for (let c = 0; c < COLS; c++) { screen[r][c] = { char: ' ', brightness: 0 }; } } function render() { // Fade all cells for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) screen[r][c].brightness *= 0.92; // Advance drops for (let c = 0; c < COLS; c++) { const d = drops[c]; d.y += d.speed; const headRow = Math.floor(d.y); if (headRow >= 0 && headRow < ROWS) { screen[headRow][c].char = ALL[Math.floor(Math.random() * ALL.length)]; screen[headRow][c].brightness = 1; } // Randomly mutate trail characters for (let t = 1; t < d.length; t++) { const tr = headRow - t; if (tr >= 0 && tr < ROWS && Math.random() < 0.03) { screen[tr][c].char = ALL[Math.floor(Math.random() * ALL.length)]; } } if (headRow - d.length > ROWS) { d.y = Math.random() * -20; d.speed = 0.3 + Math.random() * 0.7; d.length = 8 + Math.floor(Math.random() * 20); } } // Build colored output let html = ''; for (let r = 0; r < ROWS; r++) { for (let c = 0; c < COLS; c++) { const b = screen[r][c].brightness; if (b > 0.9) { html += `${screen[r][c].char}`; } else if (b > 0.05) { const g = Math.floor(b * 200); html += `${screen[r][c].char}`; } else { html += ' '; } } html += '\n'; } pre.innerHTML = html; requestAnimationFrame(render); } requestAnimationFrame(render);The magic of Matrix rain is in the brightness falloff. The leading character is white (maximum brightness), and each character behind it decays exponentially. The trail fading creates the illusion of depth and motion. Random character mutations in the trail add the characteristic "data stream" feel.
5. Animated ASCII landscape
A procedurally generated landscape rendered in ASCII — rolling hills, stars, and moving clouds. This demonstrates how to create scenes with depth using only text characters.
const W = 70, H = 30; const pre = document.createElement('pre'); pre.style.cssText = 'font:11px monospace;line-height:1;background:#000;color:#aaa;padding:8px'; document.body.appendChild(pre); // Simple 1D noise function noise(x) { const i = Math.floor(x); const f = x - i; const smooth = f * f * (3 - 2 * f); const a = Math.sin(i * 127.1 + i * 311.7) * 43758.5453; const b = Math.sin((i+1) * 127.1 + (i+1) * 311.7) * 43758.5453; return (a - Math.floor(a)) * (1 - smooth) + (b - Math.floor(b)) * smooth; } function fbm(x, octaves) { let val = 0, amp = 0.5, freq = 1; for (let i = 0; i < octaves; i++) { val += noise(x * freq) * amp; amp *= 0.5; freq *= 2; } return val; } // Persistent star positions const stars = []; for (let i = 0; i < 40; i++) { stars.push({ x: Math.floor(Math.random() * W), y: Math.floor(Math.random() * (H * 0.4)), char: Math.random() > 0.5 ? '.' : '*' }); } function render(t) { const time = t * 0.0005; const grid = []; for (let y = 0; y < H; y++) { grid[y] = []; for (let x = 0; x < W; x++) grid[y][x] = ' '; } // Sky gradient for (let y = 0; y < H * 0.6; y++) { for (let x = 0; x < W; x++) { const skyBright = 1 - y / (H * 0.6); if (skyBright < 0.1) grid[y][x] = '.'; } } // Stars (twinkle) stars.forEach(s => { if (Math.sin(t * 0.003 + s.x * 7) > 0.3) { grid[s.y][s.x] = s.char; } }); // Clouds for (let x = 0; x < W; x++) { const cx = (x + time * 8) * 0.05; const cloudY = 3 + Math.floor(fbm(cx, 3) * 4); const cloudDensity = fbm(cx + 100, 4); if (cloudDensity > 0.45 && cloudY >= 0 && cloudY < H) { grid[cloudY][x] = cloudDensity > 0.55 ? '█' : '▓'; if (cloudY + 1 < H) grid[cloudY + 1][x] = cloudDensity > 0.5 ? '▒' : '░'; } } // Mountains (back layer) for (let x = 0; x < W; x++) { const mh = Math.floor(fbm(x * 0.03, 4) * 10 + H * 0.45); for (let y = mh; y < H * 0.7; y++) { if (y >= 0 && y < H) grid[y][x] = '▲'; } } // Hills (front layer with scrolling) for (let x = 0; x < W; x++) { const hx = x * 0.05 + time * 0.5; const hh = Math.floor(fbm(hx, 3) * 6 + H * 0.65); const hillChars = ['_', '/', '~', '^']; for (let y = hh; y < H; y++) { if (y >= 0 && y < H) { if (y === hh) grid[y][x] = hillChars[x % 4]; else grid[y][x] = y > H - 3 ? '#' : '.'; } } } // Ground for (let x = 0; x < W; x++) { for (let y = H - 2; y < H; y++) { grid[y][x] = '▓'; } } let text = ''; for (let y = 0; y < H; y++) { text += grid[y].join('') + '\n'; } pre.textContent = text; requestAnimationFrame(render); } requestAnimationFrame(render);This landscape uses layered rendering — sky first, then stars, clouds, mountains, hills, and ground. Each layer overwrites the previous one where it's "in front." The clouds scroll using a time offset in the noise function, creating a parallax effect between the stationary mountains and moving clouds.
6. ASCII fire simulation
A classic demoscene effect: realistic fire using a simple cellular automaton. Each cell averages its neighbors and decays, with random heat injected at the bottom. The result is a convincingly organic flame rendered entirely in text.
const W = 60, H = 25; const pre = document.createElement('pre'); pre.style.cssText = 'font:12px monospace;line-height:1;background:#000;padding:8px'; document.body.appendChild(pre); const FIRE_CHARS = ' .:-=+*#%@'; const FIRE_COLORS = [ '#000', '#200', '#400', '#600', '#800', '#a40', '#c60', '#e80', '#fa0', '#ff0', '#ffa', '#fff' ]; const buffer = new Float32Array(W * H); function render() { // Inject heat at bottom for (let x = 0; x < W; x++) { buffer[(H - 1) * W + x] = Math.random() > 0.4 ? 1 : 0; buffer[(H - 2) * W + x] = Math.random() > 0.5 ? 0.8 : 0; } // Propagate upward: average of neighbors below, minus decay for (let y = 0; y < H - 2; y++) { for (let x = 0; x < W; x++) { const below = buffer[(y + 1) * W + x]; const belowL = buffer[(y + 1) * W + Math.max(0, x - 1)]; const belowR = buffer[(y + 1) * W + Math.min(W - 1, x + 1)]; const below2 = y + 2 < H ? buffer[(y + 2) * W + x] : 0; buffer[y * W + x] = (below + belowL + belowR + below2) / 4.04; } } // Render let html = ''; for (let y = 0; y < H; y++) { for (let x = 0; x < W; x++) { const val = Math.min(1, buffer[y * W + x]); const ci = Math.floor(val * (FIRE_COLORS.length - 1)); const fi = Math.floor(val * (FIRE_CHARS.length - 1)); html += `${FIRE_CHARS[fi]}`; } html += '\n'; } pre.innerHTML = html; requestAnimationFrame(render); } requestAnimationFrame(render);The fire algorithm is elegantly simple: each cell becomes the average of the three cells below it plus one cell two rows below, divided by slightly more than 4 (the 4.04 creates the decay). Heat injected at the bottom propagates upward and fades naturally. The character and color mappings create the visual illusion of dancing flames — hot white/yellow at the base, cooling through orange and red to dark at the tips.
7. 3D rotating ASCII cube
A wireframe cube rendered in ASCII art using 3D projection. This demonstrates how to do basic 3D graphics using nothing but character placement — no canvas, no WebGL, just math and text.
const W = 60, H = 30; const pre = document.createElement('pre'); pre.style.cssText = 'font:11px monospace;line-height:1;background:#000;color:#0f0;padding:8px'; document.body.appendChild(pre); // Cube vertices const verts = [ [-1,-1,-1],[1,-1,-1],[1,1,-1],[-1,1,-1], [-1,-1,1],[1,-1,1],[1,1,1],[-1,1,1] ]; const edges = [ [0,1],[1,2],[2,3],[3,0], // back face [4,5],[5,6],[6,7],[7,4], // front face [0,4],[1,5],[2,6],[3,7] // connecting edges ]; function rotateY(p, a) { return [p[0]*Math.cos(a)-p[2]*Math.sin(a), p[1], p[0]*Math.sin(a)+p[2]*Math.cos(a)]; } function rotateX(p, a) { return [p[0], p[1]*Math.cos(a)-p[2]*Math.sin(a), p[1]*Math.sin(a)+p[2]*Math.cos(a)]; } function project(p) { const z = p[2] + 4; // Camera distance const scale = 12; return [ Math.round(W / 2 + p[0] / z * scale * 2), // *2 for char aspect ratio Math.round(H / 2 + p[1] / z * scale) ]; } function drawLine(grid, x0, y0, x1, y1, ch) { const dx = Math.abs(x1 - x0), dy = Math.abs(y1 - y0); const sx = x0 < x1 ? 1 : -1, sy = y0 < y1 ? 1 : -1; let err = dx - dy; while (true) { if (x0 >= 0 && x0 < W && y0 >= 0 && y0 < H) grid[y0][x0] = ch; if (x0 === x1 && y0 === y1) break; const e2 = 2 * err; if (e2 > -dy) { err -= dy; x0 += sx; } if (e2 < dx) { err += dx; y0 += sy; } } } function render(t) { const grid = []; for (let y = 0; y < H; y++) { grid[y] = []; for (let x = 0; x < W; x++) grid[y][x] = ' '; } const a = t * 0.001; // Transform and project vertices const proj = verts.map(v => { let p = rotateY(v, a); p = rotateX(p, a * 0.7); return project(p); }); // Draw edges edges.forEach(([i, j]) => { const lineChar = '·'; drawLine(grid, proj[i][0], proj[i][1], proj[j][0], proj[j][1], lineChar); }); // Draw vertices with brighter characters proj.forEach(([x, y]) => { if (x >= 0 && x < W && y >= 0 && y < H) grid[y][x] = 'O'; }); let text = ''; for (let y = 0; y < H; y++) text += grid[y].join('') + '\n'; pre.textContent = text; requestAnimationFrame(render); } requestAnimationFrame(render);The 3D-to-ASCII pipeline: define vertices in 3D → apply rotation matrices → perspective-project to 2D screen coordinates → draw lines between connected vertices using Bresenham's algorithm → render the character grid. The aspect ratio correction (
* 2for x) is crucial — without it, the cube appears squashed because characters are taller than they are wide.8. Interactive ASCII art painter
A mouse-driven ASCII art canvas where your cursor paints characters based on drawing speed and direction. Fast strokes create dense characters; slow movements create delicate dots. This turns ASCII art from a rendering technique into an interactive instrument.
const W = 80, H = 40; const pre = document.createElement('pre'); pre.style.cssText = 'font:10px monospace;line-height:1;background:#111;color:#e0e0e0;padding:8px;cursor:crosshair;user-select:none'; document.body.appendChild(pre); const grid = []; const fade = []; for (let y = 0; y < H; y++) { grid[y] = []; fade[y] = []; for (let x = 0; x < W; x++) { grid[y][x] = ' '; fade[y][x] = 0; } } const BRUSH = ' .·:+*#@█'; let mouseX = 0, mouseY = 0, lastX = 0, lastY = 0; let drawing = false; pre.addEventListener('mousemove', e => { const rect = pre.getBoundingClientRect(); const charW = rect.width / W; const charH = rect.height / H; mouseX = Math.floor((e.clientX - rect.left) / charW); mouseY = Math.floor((e.clientY - rect.top) / charH); if (drawing && mouseX >= 0 && mouseX < W && mouseY >= 0 && mouseY < H) { const speed = Math.sqrt((mouseX - lastX) ** 2 + (mouseY - lastY) ** 2); const intensity = Math.min(1, speed / 5); const idx = Math.floor(intensity * (BRUSH.length - 1)); grid[mouseY][mouseX] = BRUSH[idx]; fade[mouseY][mouseX] = 1; // Spread to neighbors based on speed if (speed > 2) { const spread = [[0,1],[0,-1],[1,0],[-1,0]]; spread.forEach(([dx, dy]) => { const nx = mouseX + dx, ny = mouseY + dy; if (nx >= 0 && nx < W && ny >= 0 && ny < H) { const ni = Math.max(0, idx - 2); if (BRUSH.indexOf(grid[ny][nx]) < ni) { grid[ny][nx] = BRUSH[ni]; fade[ny][nx] = 0.7; } } }); } } lastX = mouseX; lastY = mouseY; }); pre.addEventListener('mousedown', () => { drawing = true; }); pre.addEventListener('mouseup', () => { drawing = false; }); pre.addEventListener('mouseleave', () => { drawing = false; }); function render() { // Gentle fade for (let y = 0; y < H; y++) { for (let x = 0; x < W; x++) { fade[y][x] *= 0.998; if (fade[y][x] < 0.01 && grid[y][x] !== ' ') { const ci = BRUSH.indexOf(grid[y][x]); if (ci > 0) grid[y][x] = BRUSH[ci - 1]; else grid[y][x] = ' '; fade[y][x] = 0.3; } } } let text = ''; for (let y = 0; y < H; y++) { text += grid[y].join('') + '\n'; } pre.textContent = text; requestAnimationFrame(render); } requestAnimationFrame(render);This painter maps mouse speed to character density — fast strokes leave bold marks (
█ @ #), slow precise movements leave delicate traces (· : .). The slow fade means your drawings gradually dissolve, encouraging continuous creation rather than careful composition. It's ASCII art as performance, not product.Tips for better ASCII art rendering
- Character aspect ratio matters most. Monospace characters are roughly 1:2 (width:height). If you're converting an image or doing 3D projection, multiply X coordinates by 2 or sample rectangular blocks. Getting this wrong is the #1 reason ASCII art looks distorted.
- Use a longer density ramp for photographs. The 10-character ramp (
.:-=+*#%@) works for simple graphics. For photographic detail, use 40-70 characters to capture subtle tonal gradations. - Color adds dimension. Modern terminals and browsers support ANSI colors and CSS styling. Even simple coloring (warm tones for bright areas, cool for dark) dramatically improves readability.
- Pre-tag styling is critical. Always set
line-height: 1on the<pre>element. Default line-height adds gaps between rows that destroy the image. Also ensure a truly monospace font — proportional fonts make ASCII art impossible. - Performance: textContent vs innerHTML. For plain ASCII,
pre.textContent = textis 10-100x faster than building HTML with spans. Only use innerHTML when you need per-character coloring. For colored output, consider building the string with template literals rather than DOM manipulation.
The enduring appeal of ASCII art
ASCII art thrives because of its constraints, not despite them. Working with 95 characters instead of 16 million colors forces you to think about tone, texture, and shape in fundamentally different ways. Every character choice is a creative decision — is a # the right weight for this shadow, or should it be %?
The techniques here range from classic (image conversion, fire simulation) to contemporary (interactive painting, edge detection). They combine naturally with other creative coding approaches:
- Use Perlin noise to generate organic ASCII textures and terrains
- Apply cellular automata rules to a character grid for emergent text patterns
- Convert particle system positions to ASCII for text-mode visual effects
- Render fractals in text for infinite-zoom ASCII experiences
- Map audio frequencies to character density for sound-reactive text art
- Apply pixel art techniques at the character level for retro text graphics
On Lumitree, ASCII art lives in the visual poetry branches — micro-worlds where text itself becomes the artwork. Poems that dissolve into particle fields, haiku that rearrange themselves with each visit, concrete poetry that responds to mouse movement. Each one fits in under 50KB because text is the most efficient medium there is — every character carries meaning both as content and as visual form.
Start with the brightness renderer (example 1) to understand the mapping. Move to Matrix rain (example 4) for the crowd-pleaser. Try the interactive painter (example 8) to feel how ASCII art can be a live medium, not just a static conversion. Then combine edge detection with fire, or landscapes with 3D cubes, and discover the aesthetic space that opens up when you treat text as pixels.