← All articles

Inside the screensavers: canvas, WebGL, and a little chaos

A technical look at how we built thirteen self-contained browser screensavers, one file each, no libraries. Canvas patterns, one WebGL shader, and the math hiding in each effect.

A while back we asked a simple question: what does a screensaver look like when you are not allowed any libraries, no build step, no dependencies?

The answer is the Screensavers collection. Thirteen small interactive worlds, each one a single self-contained HTML file, each under 120 lines of hand-written JavaScript with one outlier closer to 160. The 2D canvas API draws everything except one outlier that uses raw WebGL. No React, no Three.js, no bundler. You open the file and it runs.

This post walks through the architecture we settled on, the rendering techniques, and the math hiding in each effect.

The house template

Every screensaver starts from the same skeleton:

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Lorenz</title>
<style>
  html, body { margin: 0; height: 100%; background: #000; overflow: hidden; }
  canvas { display: block; }
</style>
</head>
<body>
<canvas id="c"></canvas>
<script>
"use strict";
// ... 40-80 lines per effect
</script>
</body>
</html>

Three conventions apply to most files.

DPR-aware resize. The canvas backing store is scaled to the device pixel ratio, capped at 2 so a 3x phone does not render four times the pixels. The context is scaled once with setTransform, so all drawing code works in CSS pixels:

function resize() {
  const dpr = Math.min(devicePixelRatio || 1, 2);
  W = innerWidth; H = innerHeight;
  canvas.width = W * dpr; canvas.height = H * dpr;
  canvas.style.width = W + "px"; canvas.style.height = H + "px";
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}

Skip this and you get the most common canvas bug: everything renders but looks blurry on retina displays.

Alpha-fill trails. Instead of clearing the canvas each frame, we paint a semi-transparent black rectangle over it:

ctx.fillStyle = "rgba(0, 0, 0, 0.045)";
ctx.fillRect(0, 0, W, H);

That line produces the motion trails. The previous frame fades instead of clearing. The opacity is a per-effect knob. Flow Field uses 0.035 for long, smoky trails. Lorenz uses 0.045, tighter. Spiral uses 0.25, so barely any trail and crisp stars. Getting that number right is most of the aesthetic work in these things.

Shared controls. Click toggles fullscreen, space pauses, and r reseeds the scene in most of them. A couple swap click for a per-effect action (a pulse in the mosaic, a surge in the falling letters) and a few add extras, like [ and ] tempo on the metronome. The controls are close enough that you can run all thirteen back to back without relearning much.

The easy ones

Starfield. The canonical 3D projection in 2D. Stars live in (x, y, z). Each frame z decreases, and the screen position is (x / z, y / z). Size and brightness both scale with proximity, so the near stars loom and streak while the distant ones drift.

Falling Letters. Our matrix-style rain. The part people miss: each column is a row of character cells with independent scroll offsets and speeds, and a new drop spawns when a column’s head passes the bottom. We draw characters with fillText from a katakana-plus-ASCII glyph set, tinted per palette.

Pendulum. Twelve pendulums with different lengths and random frequencies, so each swings at its own period. The phase differences build slowly. The pendulums drift apart, interfere, and visibly re-synchronize. The motion is just sin(t * freq + phase) * amp per pendulum. The interest is in the beating between the frequencies.

Metronome. A tempo display made of expanding rings. On each beat, a ring spawns at the center and expands at constant velocity while its alpha decays linearly. One ring per beat at 96 BPM, five rings alive at once, and the spacing between rings encodes the tempo directly. The [ and ] keys adjust the tempo.

Mosaic. A grid of tiles whose brightness is a radial wave traveling outward from the center, and whose hue shifts with distance and time. Clicks add an expanding pulse to the field, so the ambient wave and the click pulses superimpose.

The interesting ones

Flow Field: noise without a noise library

Flow Field examples usually use Perlin or simplex noise, but a noise library breaks the one-file rule. Our workaround is a sum of sines standing in for a smooth pseudo-random field:

function flowAngle(x, y) {
  const s = 0.0022;
  return (
    Math.sin(x * s + t * 0.4) +
    Math.cos(y * s * 1.3 - t * 0.3) +
    Math.sin((x + y) * s * 0.5 + t * 0.2)
  ) * 1.6;
}

Each term has a different spatial frequency and a different temporal phase, so the field is smooth and does not look like it repeats. The field costs three trig calls, plus two more per particle to step along the direction. Nine hundred particles sample this field, move one step along the local direction, and draw a line segment from their old position to their new one. The alpha-trail fade turns 900 independent 1.4-pixel segments into what reads as continuous luminous ribbons.

Lorenz: a strange attractor as a screensaver

The Lorenz system is a three-equation ODE that started life as a simplified convection model. With the classic parameters sigma = 10, rho = 28, beta = 8/3, trajectories never settle and never cross. They orbit a strange attractor, the butterfly.

const SIG = 10, RHO = 28, BET = 8 / 3;
// RK2 (midpoint method), 220 substeps per frame
const k1x = SIG * (y - x);
const k1y = x * (RHO - z) - y;
const k1z = x * y - BET * z;
// midpoint
const mx = x + k1x * dt * 0.5, my = y + k1y * dt * 0.5, mz = z + k1z * dt * 0.5;
const k2x = SIG * (my - mx);
const k2y = mx * (RHO - mz) - my;
const k2z = mx * my - BET * mz;

Two implementation details matter here.

Integration. Euler integration at this system’s stiffness spirals into garbage within a second. We use the midpoint method (RK2) with a small step, dt = 0.003, and 220 substeps per frame. That draws a long, stable orbit per frame at a readable speed.

Projection. The attractor is 3D, but we project it to 2D by drawing (x, z) and ignoring y. The y component is still used, though, to modulate hue and alpha. That is what gives the overlapping wings their sense of depth.

A guard respawns the orbit if the state wanders past 60 on any axis. That should not happen with RK2 at this step size, but floating point drifting over hours of a running screensaver is a real concern.

Mandelbrot: the pixel loop

The Mandelbrot screensaver is the only effect with a serious performance problem: a per-pixel escape-count loop, up to 120 iterations per pixel, per frame. The fix is the only real optimization in the collection. Render at half resolution, then upscale.

const RES = 0.5;  // half-resolution ImageData
// ... compute into a Uint8ClampedArray ...
const img = ctx.createImageData(RW, RH);
// ... fill img ...
ctx.putImageData(img, 0, 0, 0, 0, RW, RH);
ctx.drawImage(canvas, 0, 0, RW, RH, 0, 0, W, H);

The image lands in a corner of the canvas at half resolution, and one drawImage call stretches it over the whole surface. At full resolution on a 1440p display that is about 3.7 million pixels times 120 max iterations per frame. At half resolution it is about 900,000 pixels, and the smooth upscale hides the resolution difference almost entirely. The fractal’s fine structure is higher frequency than the eye notices at that scale.

The palette is a cosine function of the escape count, the same IQ palette idea as the WebGL shader, ported to JS. Cosine mapping is why the depth bands show no banding:

const u = (i * 0.012 + t * 2) % 1;
return [
  Math.floor(128 + 128 * Math.cos(6.2832 * (u + 0.0))),
  Math.floor(128 + 128 * Math.cos(6.2832 * (u + 0.33))),
  Math.floor(128 + 128 * Math.cos(6.2832 * (u + 0.66)))
];

The t in that formula is the animation clock, which is why the colors drift even when the zoom is still.

Plasma: the one WebGL screensaver

Plasma is the only screensaver that does not use the 2D context, and it is the most instructive one to explain. The 90s plasma effect, sin(x) + sin(y) + sin(x+y) + sin(sqrt(x^2+y^2)), is cheap to compute per pixel. Doing it in a JS pixel loop is slow because the pixels live on the CPU. The fix is to move the per-pixel math to the GPU in a fragment shader:

// incommensurate (non-periodic) frequencies -> no visible lattice
vec2 w = 0.15 * vec2(sin(uv.y * 2.3 + t * 0.9) - cos(uv.x * 1.9 - t * 0.7),
                     sin(uv.x * 2.1 + t * 0.6) - cos(uv.y * 1.7 + t * 0.8));
vec2 p = uv + w;
float v =
  sin(p.x * 2.1 + t) +
  sin(p.y * 1.7 - t * 0.83) +
  sin((p.x + p.y) * 1.3 + t * 0.61) +
  sin(length(p - vec2(0.3 * cos(t * 0.3), 0.3 * sin(t * 0.47))) * 3.1 - t * 1.2);
gl_FragColor = vec4(pal(v * 0.25 + 0.06 * dot(uv, uv) + 0.25 + t * 0.015), 1.);

Two things in that shader do the real work.

Incommensurate frequencies. If every sine term shared frequency ratios, the pattern tiles periodically and you can see the lattice. Ratios like 2.3, 1.9, 1.7, and 0.83 share no common factor, so the pattern never visibly repeats.

The domain warp. The w term offsets the sample coordinates by a second, independent set of sines before the main sines are evaluated. That is the difference between four overlapping sine waves and a field that reads as flowing liquid.

The JS side is minimal. We compile two shaders, draw one full-screen triangle, and update three uniforms per frame:

gl.uniform2f(uRes, canvas.width, canvas.height);
gl.uniform1f(uT, t);
gl.uniform1f(uPal, paletteId);
gl.drawArrays(gl.TRIANGLES, 0, 3);

That single drawArrays call renders every pixel on the GPU. On a mid-range laptop this holds 60 fps at full resolution with the CPU nearly idle, where the JS pixel-loop version caps out around 12 fps.

What actually took the longest

One honest piece of advice from building thirteen of these: the math was never the bottleneck. The fade factor was.

Every effect that uses motion trails has exactly one number that decides whether it reads as elegant ambient animation or a blurry mess: the alpha value in the trail-fade rectangle. Too low (0.02) and trails never die, so the screen fills with ghost smearing. Too high (0.3) and the trails vanish before they are interesting. There is no formula for the number. You tune it by watching: run the effect fullscreen, squint, and adjust one value at a time.

The math in all thirteen is small, well-known, and easy to find. The craft is in the parameters: the fade, the hue drift speed, the particle count, the substep count. Those can only be tuned by looking.

How this was actually made

These were not written by one person or one program. They were built through a conversation between a human and an AI coding agent, and the division of labor is the interesting part.

The human side was short. Over a chat window, a few sentences at a time: “make a screensaver of pendulums”, “give me six more, no duplicates”, “remove the hint text from all of them”. No code, no spec, no design doc. That was the entire prompt budget. The prompts were not carefully engineered. They were requests in plain English, and the fact that they produced working screensavers at all is the point.

The agent side did the rest: reading the existing screensavers to match their structure, writing the full HTML and JavaScript, running it in a headless browser, checking the console for errors, and taking screenshots to show what came out. Most of the time was not in the first draft. It was in the loop after: a screenshot comes back, the human says “the trails are too long” or “this one is boring, do something else”, the agent adjusts one parameter or scrubs the whole idea and tries again.

The mistakes were visible, which mattered. The agent got things wrong: a metronome that mixed up its phase variables, a spiral with a typo in the CSS, a mosaic that accidentally declared its frame loop twice. The headless browser caught the broken ones instantly, and the screenshots caught the rest. Nothing in this collection shipped without being run in a browser first. Later, when the blog post about these screensavers was drafted, a separate verification pass checked every number and quoted line of code against the actual source and found twelve statements that were subtly wrong. That pass exists because the agent that writes the article about the code is the same agent that wrote the code, and it is not a neutral witness.

A person supplies the idea and the taste. The agent supplies the syntax and the test loop. The screenshots are the medium between them. The math in the code above is well-known. The point of this post is that a few sentences in a chat window is now a working, deployable piece of software.

Run them

The full collection, all thirteen, is live at digitalcerebro.com/screensavers/. No install, no signup. Pick one, go fullscreen, and let it run.