GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

ASCII Art JavaScript: 9 Canvas Glyph Scenes

How does ASCII art turn a picture into characters? 01 picks a glyph by each cell's mean brightness, 05 chooses slash or bar by edge direction, and 06

ASCII Art Is Not Drawing Letters but Reading the Brightness of Each Cell and Answering With One Glyph: ASCII Art Starts by Splitting a Picture Into a Grid

Auto-plays · click a tile to jump to its section · all nine in one zip

The nine scenes share one frame. Each scene is drawn on a second canvas that is kept off the page, and every glyph on screen comes only from the mean of that canvas's pixels in each cell. There is no library, WebGL, CDN or random number, and the motion follows a phase taken from the scene clock, so it repeats without a seam after LAP_S 10 seconds.

The scene is drawn on a hidden canvas and glyphs come only from the values read back

setSrc sets the size and scale of the source canvas, and readGrid calls getImageData once to get the mean r, g, b and the brightness of the source pixels in every cell. Brightness is (0.2126 r + 0.7152 g + 0.0722 b) / 255, and the board prints a run of same-colored glyphs per row in one call instead of calling fillText for every cell. The font is a stack of monospace system fonts, and the glyph width is the width of M measured at 100px.

One board runs on a timetable

A demo starts at scene clock 0.6 seconds and the frame gap is capped at 0.05 seconds, so a tab that hides and comes back does not jump. The glyphs live on a canvas, so the way to copy the ASCII art is the copy button or the C key, which puts plain text without trailing spaces on the clipboard, and that text only lines up when it is pasted into a monospace font. 0.6 seconds before a board ends, a role="status" paragraph gets one status sentence.

One layer of computation per scene

Instead of tweening a property, each scene adds one layer of computation. 01 turns brightness into a ramp index, 02 corrects the cell aspect, 03 applies gamma and a contrast curve, 04 uses a repeating threshold table, 05 reads the direction of the brightness slope, 06 sums four sines, 07 shades a surface, 08 measures the distance from a mean color to a palette, and 09 offsets the phase row by row. The CSS dither piece paints pixels with CSS, the generative art piece draws lines and tiles, and the falling text piece does glyph physics, while this one is about reading a picture's pixels to pick glyphs.

ASCII Art in JavaScript: 9 Scenes — A Ramp That Maps Brightness to Glyph Density, Cell Aspect Correction, Gamma and Contrast Curves, Ordered Dither With Two Glyphs, Edge Symbols That Follow Brightness Direction, a Moving Plasma Glyph Field, Shaded Sphere Glyphs, a Palette Tint per Glyph and a Waving Glyph Banner Hero

What nine renders measured for area, frames and intensity

Scene Cumulative area Moving frames Mean intensity
01 Ramp 18.8% 23/23 78.5
02 Cell aspect 10.9% 23/23 67.1
03 Gamma curve 32.5% 23/23 65.5
04 Ordered dither 13.0% 23/23 103.3
05 Edge symbols 15.0% 23/23 75.2
06 Plasma 39.8% 23/23 60.9
07 Sphere shading 15.6% 23/23 73.9
08 Color tint 45.1% 23/23 61.2
09 Wave banner 28.8% 23/23 72.4

08 covers the most area at 45.1% and 02 the least at 10.9%, and 04 uses only two glyphs yet has the highest mean intensity at 103.3. All nine scenes moved in 23/23 frames, so none of them reads as a still picture.

01A Ramp That Picks Glyphs From a Density Line by Cell Brightness

This is the base of any image to text conversion screen: readGrid takes the mean brightness of each of the 38×20 cells and glyphIndex turns it into a ramp index, so one glyph is picked per cell. The board on the left uses a 10 step ramp, and the board on the right calls rampOf(2) to keep only a space and @, which removes the mid tones as the contrast. Cutting the ramp to 5 glyphs uses 5 kinds in one scene on average, 10 steps use 10 and 16 steps use 15.

A Ramp That Picks Glyphs From a Density Line by Cell Brightness
Open in a new tab

01 · A Ramp That Picks Glyphs From a Density Line by Cell Brightness — Measured area 18.842% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

RAMP_LENreadGridglyphIndex
        const cw = cellW(FONT_PX);
        const ch = cellH(FONT_PX);
        const cols = Math.floor(PW2 / cw);
        const rows = Math.floor(PH / ch);
        const g = readGrid(cols, rows, cw, ch);
        const c1 = [];
        const c2 = [];
        for (let i = 0; i < g.lum.length; i += 1) {
            c1.push(RAMP.charAt(glyphIndex(g.lum[i], RAMP_LEN)));
            c2.push(RAMP_2.charAt(glyphIndex(g.lum[i], 2)));
        }
        lastKinds = kinds(usage(c1, RAMP));
        lastKinds2 = kinds(usage(c2, RAMP_2));
        lastCols = cols;
        lastRows = rows;
        plainText = plainOf(g, c1);

02A Correction That Keeps a Circle Round Despite Tall Glyph Cells

When logos, faces and circles must keep their shape, the first thing that bites is the glyph cell: it is 5.9px wide and 12.0px tall, about 2.05 times taller, so sampling square cells stretches the picture vertically. The corrected board makes the sampled cell GLYPH_RATIO times taller, and a circle of radius 40px prints at 80px × 80px. The board on the right reads the same 20 rows from only the top half of the source, so the same circle becomes an ellipse of 80px × about 164px.

A Correction That Keeps a Circle Round Despite Tall Glyph Cells
Open in a new tab

02 · A Correction That Keeps a Circle Round Despite Tall Glyph Cells — Measured area 10.906% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

GLYPH_RATIOSAMPLE_HMEASURE_W
        MEASURE_W = cellW(FONT_PX);
        SAMPLE_H = cellH(FONT_PX);
        GLYPH_RATIO = SAMPLE_H / MEASURE_W;
        setSrc(PW2, PH, 1);
        paintScene(PW2, PH, a);
        const cols = Math.floor(PW2 / MEASURE_W);
        const rows = Math.floor(PH / SAMPLE_H);
        // corrected: the sampled cell height is the line height (GLYPH_RATIO times the width). uncorrected: the sampled cell is square (width = height)
        const gA = readGrid(cols, rows, MEASURE_W, SAMPLE_H);
        const gB = readGrid(cols, rows, MEASURE_W, MEASURE_W);
        const cA = [];
        const cB = [];
        for (let i = 0; i < gA.lum.length; i += 1) {
            cA.push(RAMP.charAt(glyphIndex(gA.lum[i], RAMP_LEN)));
            cB.push(RAMP.charAt(glyphIndex(gB.lum[i], RAMP_LEN)));
        }

03A Tuning That Spreads Glyph Use Evenly With Gamma and Contrast Curves

This is preprocessing so an over or under exposed picture does not clump into one glyph. curve stretches brightness by CONTRAST 2.6 around PIVOT 0.72 and then raises it to the power GAMMA 1.2, which spreads a fog scene packed between 0.5 and 0.95 over the whole ramp. With the curve 10 kinds are used on average and without it 4.9, and the share of the most used glyph is 21% against 42%.

A Tuning That Spreads Glyph Use Evenly With Gamma and Contrast Curves
Open in a new tab

03 · A Tuning That Spreads Glyph Use Evenly With Gamma and Contrast Curves — Measured area 32.474% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

GAMMACONTRASTPIVOT
        const g = readGrid(cols, rows, cw, ch);
        const cA = [];
        const cB = [];
        for (let i = 0; i < g.lum.length; i += 1) {
            cA.push(RAMP.charAt(glyphIndex(curve(g.lum[i]), RAMP_LEN)));
            cB.push(RAMP.charAt(glyphIndex(g.lum[i], RAMP_LEN)));
        }
        useA = usage(cA, RAMP);
        useB = usage(cB, RAMP);

04Ordered Dither That Shows Light and Shade With Only Two Glyphs

Use it when the character set cannot grow and you still need a two tone glyph picture. Each cell's brightness is compared with the ORDER_4 threshold (value + 0.5) / 16 picked by the cell position (r % 4, c % 4), and only @ or . is printed, while the board on the right cuts with one threshold, THRESHOLD 0.5. The share of the dark glyph @ is 45.8% on average with the repeating table and 40.4% with one threshold.

Ordered Dither That Shows Light and Shade With Only Two Glyphs
Open in a new tab

04 · Ordered Dither That Shows Light and Shade With Only Two Glyphs — Measured area 13.044% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

ORDER_NTHRESHOLDTWO_GLYPH
        for (let r = 0; r < rows; r += 1) {
            for (let c = 0; c < cols; c += 1) {
                const v = g.lum[r * cols + c];
                const t = (ORDER_4[(r % ORDER_N) * ORDER_N + (c % ORDER_N)] + 0.5) / (ORDER_N * ORDER_N);
                if (v > t) {
                    cA.push(TWO_GLYPH.charAt(1));
                    nA += 1;
                }
                else {
                    cA.push(TWO_GLYPH.charAt(0));
                }
                if (v > THRESHOLD) {
                    cB.push(TWO_GLYPH.charAt(1));
                    nB += 1;
                }
                else {
                    cB.push(TWO_GLYPH.charAt(0));
                }
            }
        }

05Edges That Pick Slash or Bar by the Direction Brightness Changes

Use it for logos and shapes that need crisp outlines. gradientAt gets the slope from the brightness difference of left and right cells and of cells above and below, and where its strength passes EDGE_MIN 0.012 the tangent angle falls into 4 bins and one of a horizontal dash, backslash, vertical bar or slash is printed. Of 760 cells an average of 275 get an edge symbol and all four symbols appear, while the board on the right reads only the brightness ramp and the outline smears.

Edges That Pick Slash or Bar by the Direction Brightness Changes
Open in a new tab

05 · Edges That Pick Slash or Bar by the Direction Brightness Changes — Measured area 15.004% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

gradientAtEDGE_MINANGLE_BIN
        for (let r = 0; r < rows; r += 1) {
            for (let c = 0; c < cols; c += 1) {
                const v = g.lum[r * cols + c];
                const gr = gradientAt(g, c, r, ch);
                const mag = Math.hypot(gr[0], gr[1]);
                if (mag > EDGE_MIN) {
                    // tangent = gradient direction + 90 degrees, folded into 0 to 180 to get the bin number
                    let phi = Math.atan2(gr[1], gr[0]) + Math.PI / 2;
                    phi = ((phi % Math.PI) + Math.PI) % Math.PI;
                    const bin = Math.floor((phi + Math.PI / (2 * ANGLE_BIN)) / (Math.PI / ANGLE_BIN)) % ANGLE_BIN;
                    cA.push(EDGE_SYMBOLS.charAt(bin));
                    seen[bin] = true;
                    edges += 1;
                }
                else {
                    cA.push(FILL_RAMP.charAt(glyphIndex(v, FILL_RAMP.length)));
                }
                cB.push(RAMP.charAt(glyphIndex(v, RAMP.length)));
            }
        }

06A Glyph Field That Re-reads a Moving Plasma Every Frame

Use it for terminal style landing backgrounds and loading screens. sineSum adds horizontal, vertical and diagonal sines plus a ring sine spreading from a moving center, the values are written to a half resolution source, and readGrid reads it back into 79×20 cells of a 12 step ramp. All 12 glyphs were used on average, and one draw took about 2.1ms on average in Chrome on this PC.

A Glyph Field That Re-reads a Moving Plasma Every Frame
Open in a new tab

06 · A Glyph Field That Re-reads a Moving Plasma Every Frame — Measured area 39.769% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

sineSumSINE_TERMSFIELD_SS
    function sineSum(x, y, a) {
        const s1 = Math.sin(x * 0.04 + a);
        const s2 = Math.sin(y * 0.065 - a);
        const s3 = Math.sin((x + y) * 0.028 + 2 * a);
        const s4 = Math.sin(Math.hypot(x - 232 + 120 * Math.cos(a), y - 126 + 60 * Math.sin(a)) * 0.05 - 2 * a);
        return clamp(((s1 + s2 + s3 + s4) / SINE_TERMS) * 0.5 * 1.7 + 0.5, 0, 1);
    }

07Shading That Moves a Sphere's Light and Shadow Into Glyph Density

Use it for glyph logos and hero objects that need depth. Each pixel is treated as a point on the sphere, and normalDot gives brightness only where the dot product of the surface normal and the light direction is positive; the striped sphere turns twice per loop, and the moon is drawn in front only when mz + nz > zBest, so it hides when it passes behind. A 12 step ramp uses 11.6 glyphs on average, and one frame takes about 1.6ms in Chrome on this PC.

Shading That Moves a Sphere's Light and Shadow Into Glyph Density
Open in a new tab

07 · Shading That Moves a Sphere's Light and Shadow Into Glyph Density — Measured area 15.562% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

normalDotLIGHT_ANGLERAMP_SHADE
                if (r2 < SPHERE_R * SPHERE_R) {
                    const nz = Math.sqrt(SPHERE_R * SPHERE_R - r2);
                    const nx = dx / SPHERE_R;
                    const ny = dy / SPHERE_R;
                    const nzn = nz / SPHERE_R;
                    // spin: rotate the normal around the vertical axis to get latitude and longitude and apply the stripe pattern
                    const rx = nx * Math.cos(rot) + nzn * Math.sin(rot);
                    const rz = -nx * Math.sin(rot) + nzn * Math.cos(rot);
                    const lon = Math.atan2(rx, rz);
                    const lat = Math.asin(clamp(ny, -1, 1));
                    const tex = 0.5 + 0.5 * Math.sin(5 * lon + 3 * Math.sin(lat * 3));
                    const alb = 0.42 + 0.58 * smooth(0.35, 0.65, tex);
                    v = 0.05 + 0.95 * normalDot(nx, ny, nzn, lx, ly, lz) * alb;
                    zBest = nz;
                }

08A Palette Tint That Colors Each Glyph With Its Cell's Mean Color

Use it for posters and profile images that add color to a glyph picture. chroma removes brightness from the cell's mean color to get a color direction, and nearestColor picks the closest of the six pastel colors in PALETTE, so even where blobs of light overlap the mixed color lands in one of six. An average of 5.6 colors is used, and at one moment the board printed 169 mint, 153 pink, 0 butter, 341 sky, 533 lavender and 384 pale gray cells.

A Palette Tint That Colors Each Glyph With Its Cell's Mean Color
Open in a new tab

08 · A Palette Tint That Colors Each Glyph With Its Cell's Mean Color — Measured area 45.06% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

PALETTE_NnearestColorchroma
    // color direction: the color scaled so the largest channel is 255
    function chroma(r, g, b) {
        const m = Math.max(r, g, b, 1);
        return [r * 255 / m, g * 255 / m, b * 255 / m];
    }
    const PAL_DIR = PALETTE.map(function (c) { return chroma(c[0], c[1], c[2]); });
    function nearestColor(r, g, b) {
        const c = chroma(r, g, b);
        let best = 0;
        let bd = 1e9;
        for (let i = 0; i < PALETTE_N; i += 1) {
            const dr = c[0] - PAL_DIR[i][0];
            const dg = c[1] - PAL_DIR[i][1];
            const db = c[2] - PAL_DIR[i][2];
            const dd = dr * dr + dg * dg + db * db;
            if (dd < bd) {
                bd = dd;
                best = i;
            }
        }
        return best;
    }

09A Hero Built From a Waving Glyph Banner

Use it for a glyph banner at the top of an intro screen or an event page. Each row is shifted sideways by WAVE_COLS 4 cells times a sine whose phase moves ROW_PHASE 0.5 per row, rounded, and the source gets PAD_COLS 5 extra columns on both sides so shifted rows do not break at the edges. On the 20 row board one loop of loopCols 68 columns flows in 10 seconds, so driftX is 6.8 columns per second.

A Hero Built From a Waving Glyph Banner
Open in a new tab

09 · A Hero Built From a Waving Glyph Banner — Measured area 28.763% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

ROW_PHASEloopColsdriftX
        const sCols = cols + 2 * PAD_COLS;
        setSrc(sCols * cw, PH, 1);
        paintScene(sCols * cw, PH, ph);
        const g = readGrid(sCols, rows, cw, ch);
        const cA = [];
        for (let r = 0; r < rows; r += 1) {
            // each row shifts sideways by a sine whose phase is offset ROW_PHASE per row (rounded to whole cells)
            const off = Math.round(WAVE_COLS * Math.sin(ROW_PHASE * r - a));
            for (let c = 0; c < cols; c += 1) {
                cA.push(RAMP.charAt(glyphIndex(g.lum[r * sCols + c + PAD_COLS + off], RAMP.length)));
            }
        }

When You Write an ASCII Art Generator JavaScript Page, How Many Values Decide One Glyph?

One glyph is decided by one mean brightness and one ramp line, and every other computation fixes that brightness before it becomes an index. So the nine scenes share the glyphIndex function and differ only in the value that goes in front of it.

The values a cell carries and what each does

Value Where it comes from What it does
lum readGrid Mean brightness of the cell's source pixels, 0 to 1
RAMP_FULL Source constant 16 glyphs from sparse to dense
glyphIndex Shared function Floors brightness to a ramp index
GLYPH_RATIO 02 Glyph cell height over width, about 2.05
curve 03 Stretches and bends brightness so indexes spread evenly

The ramp line is the 16 glyphs of RAMP_FULL, and rampOf(10) picks ten of them evenly, which gives a space and then , : - = + o # % @ in that order.

Dropping it onto a landing background or a loading screen

06 and 09 keep the board flowing, so as an ASCII art animation JavaScript layer they sit on a terminal style background or a loading screen as they are. 06 is 79 columns by 20 rows for a wide area, and 09 pushes 20 rows of banner glyphs through a wave for an intro text area. The default glyph has 12.0:1 contrast on the board ground #221e38, so the board reads clearly on its own ground.

Turning it into an image to text conversion screen

A conversion screen is the ramp of 01, the aspect fix of 02 and the curve of 03 joined in a line. Fix 02 first so an uploaded picture keeps its proportion, send a picture whose exposure leans to one side through the curve of 03 before picking glyphs, then offer 04 for two glyphs or 05 for outlines only.

Split the Picture Into Cells, Take Each Cell's Mean Brightness, Fix It With a Curve and Pick One Glyph From the Ramp: the Order in Which One ASCII Art Frame Is Computed

The five steps one frame goes through

  1. The scene is drawn on the source canvas, and 06 and 07 write pixels directly and compute at half resolution (FIELD_SS 0.5).
  2. cellW and cellH measure the glyph cell and set the cell count: 79×20 for one board and 38×20 for two.
  3. readGrid reads the mean brightness and mean color of every cell.
  4. The computation the scene adds runs, such as the curve of 03, the threshold of 04 or the slope of 05.
  5. glyphIndex picks the glyph and drawGlyphs prints a run of one color per row in a single call.

A two board scene puts the two boards side by side

In 01 to 05 the left board and the contrast board on the right sit side by side. In 01, 03, 04 and 05 the two boards share one readGrid result and differ only in how the glyph is picked, while 02 calls readGrid twice because the two boards read cells of a different shape. The gap between the two boards is the effect of the computation that scene adds, so a changed value is compared on one screen.

Where ASCII Art Breaks: a Picture Stretched Tall Because Glyph Cells Are Taller Than Wide, Glyphs Piled on One Side Without a Brightness Curve and Edges Smeared by Oversized Cells

Square cells stretch the picture tall

The board on the right of 02 is the case. A glyph cell is 5.9px × 12.0px, so reading square cells makes the same 20 rows cover only the top half of the source, and the circle grows to 80px wide and about 164px tall.

Without a curve only a few glyphs remain

In 03 the board without the curve keeps brightness packed in a narrow band, so only 4.9 kinds of glyph are used on average and the single most used glyph fills 42% of the cells. Through the curve the same scene spreads to 10 kinds on average and that share drops to 21%.

Big cells reach few outline cells

At a cell height of 12px, 05 puts an edge symbol on an average of 275 of 760 cells. At 6px an average of 645 of 3,157 cells become edge symbols, so the lines of the same shape join up more sharply.

To take this code as files, download the zip below and open it with amzukyh6, and inside are the finished sources of all nine scenes.

Change the Cell Size, the Ramp Length and the Gamma and How Much Do Shape and Legibility Shift? Cell, Ramp and Gamma Compared

Name Changed value Feel
A longer ramp 01 RAMP_LEN 10 → 16 Glyphs used in the same scene grow from 10 to 15 on average, so shading splits into finer steps, but neighboring steps differ less in density and are harder to tell apart in small cells. The cell count stays 38×20 = 760
A stronger gamma 03 GAMMA 1.2 → 2.2 The share of the five darkest glyphs among the curved cells grows from 55% to 77% on average, so mid tones sink to dark glyphs and shadows deepen
Smaller cells 05 CELL_PX 12 → 6 The cell count goes from 760 to 3,157, about fourfold, edge symbol cells rise from 275 to 645 on average so lines look crisper, and one draw rose from 0.64ms to 1.04ms on average in Chrome on this PC

What to check before changing a value

A longer ramp is not simply better: the 16 step ramp uses 15 kinds, so one glyph goes unused in this scene, and neighboring steps look alike enough to blur in small cells.

Gamma moves the share of the five darkest glyphs to 17.7% at 0.6, about 55% at 1.2, 70.4% at 1.8 and about 77% at 2.2, so a board can be picked by number alone. Shrinking cells to 6px raised the draw time about 1.6 times, not 4 times, and that figure is a 20 frame average in headless Chrome on this PC, so it differs by device.

One Frozen Glyph Board Under Reduced Motion: Accessibility Through a Scene Description and a Status Line Read as Sentences and a Copy Button That Takes the Board as Text

Pause, step and copy from the keyboard

The stage is a role="application", and once it has focus Enter, Space and P pause, N steps the scene clock 0.4 seconds ahead, R restarts and C copies the board. There are three buttons for pause (aria-pressed), step and copy, and pressing the stage toggles the pause. Copying uses a temporary textarea and execCommand('copy') without navigator, and on success the status line says so for 1.6 seconds.

Glyphs on a canvas are not read aloud, so the numbers come as sentences

The canvas has role="img" with an aria-label describing the scene, but the glyphs are a picture, so a screen reader cannot read them. The status line therefore tells the cell count and the number of glyph kinds as a sentence, and it is aria-live="off" during the demo and becomes polite once the user touches it. The default glyph has 12.0:1 contrast on the board ground #221e38, the darkest glyph step of 06 has 5.4:1, and the 08 palette runs from 8.8:1 to 13.6:1.

Reduced motion and a hidden tab

With prefers-reduced-motion on, no loop runs and showRest draws a single frame of the scene at REST_S seconds, and a change listener reads the setting again when it changes. When the tab hides the loop stops and the first frame gap is dropped on return, and even then pause, step, copy and R still update the screen.

    document.addEventListener('visibilitychange', function () {
        if (document.hidden) {
            stopLoop();
        }
        else if (!paused && !still) {
            startLoop();
        }
    }, { signal: off.signal });
    // when the setting changes: on shows one still scene, off moves again
    reduceQuery.addEventListener('change', function () {
        still = reduceQuery.matches;
        if (still) {
            showRest();
        }
        else {
            sceneT = START_S;
            showNow();
            if (!paused) {
                startLoop();
            }
        }
    }, { signal: off.signal });

ASCII Art FAQ: Building Without a Library, Why It Breaks in a Font With Uneven Glyph Widths and Whether Hangul or Emoji Can Go Into the Ramp

Do I need a library to turn an image into ASCII art with JavaScript?

No. These scenes use only the browser's canvas 2D, and two calls share the work: getImageData reads the pixels and fillText prints the glyphs.

Why does the picture break in a font with uneven glyph widths?

The cell grid assumes every glyph has the same width, so in a proportional font the width changes from row to row and the columns drift apart. This set uses a stack of monospace fonts, takes the width of M at 100px as a ratio, and sets the cell height to LINE_K 1.2 times the glyph size.

Can I put Hangul or emoji into the ramp?

This set used only 16 ASCII glyphs and did not run Hangul or emoji. A glyph wider than one Latin cell breaks the grid, and an emoji pulled with charAt comes out as half of a character, so you would turn rampOf into an array of whole glyphs and measure the widths again.

The reference for the glyph call is the MDN fillText page, with the measureText page for widths, the requestAnimationFrame page for the loop and the prefers-reduced-motion page for the motion setting. For value noise on a canvas terrain, read the canvas noise terrain piece next.

Enter the archive password

The password is inside this article. You will find it as you read.