Written in the Stars

04 / 08
three.jsGLSLPythonOpenCVGeminiCloudflare Workers

Date

Feb 2026

Contribution

Individual

Type

Creative coding, a private gift

Written in the Stars is a web page that shows a night sky coming out, star by star, until the stars form a portrait of a woman. Every star is its own light: it ignites at a random moment, twinkles, swells and fades. After the reveal the sky stays alive. Bright stars blink out and reappear elsewhere, a meteor shower crosses the sky, her hair drifts in a light breeze, and she blinks now and then.

The first version, and why it failed

The project started months earlier as a "constellation portrait" that never worked. It grew to 14 Python scripts, a dozen JSON outputs of up to 52 MB each, and a 424 MB folder. Each script sampled points from a photo and drew them as particles.

The lesson: a photo can't become this kind of art by sampling alone. Her real hair hangs straight, so tracing it gave straight lines. The face came out as grey grain, not a glowing star field. The reference I was chasing was a painting, and no amount of sampling turns a photo into a painting.

The Pivot

Instead of fixing the renderer, I changed what went into it.

Repaint with AI

Gemini repainted her with dramatic lighting and flowing hair, guided by her photo.

Let the model do the style

Sending her photo, the repainted version and the reference art together, and asking for her in that style, got the closest result by far.

Edit one thing at a time

Gemini's edits kept the layout almost pixel-perfect, about a 0.05 px offset, so I could change one feature per pass: a blue face, brighter hair. I blended the hair at 30% in code, with a slider instead of another generation.

Finish in code

Removing the blue haze, filling the sky and protecting her face were done with OpenCV, never with another AI pass. That way her face could not drift.

About 26 generations in total, from the first painting to the favicon.

The Trick

The build pipeline is five Python scripts, each feeding the next.

1paint.py
Calls Gemini with her photo, prompts and reference images
2finish.py
Blends the hair, removes the haze, fills the sky
3extend.py
Grows her sky outward to fill any screen, from patches of her own sky
4stars.py
Splits the sky into individual stars plus the glow between them
5eyes.py
Builds the blink frames as patches over the eyes

The key idea is step 4. It splits the image into two layers that add back up to the original exactly: the stars' own pixels, and the glow between them. A third image records which star owns each pixel.

The renderer can then animate each of the roughly 25,000 stars on its own, while the finished frame stays pixel-identical to the painting. The renderer works in two passes. First, at the sky's native resolution, a fragment shader looks up each pixel's star, reads that star's birth time, twinkle and events, and lights the pixel.

Then the lit sky is drawn to the screen, with each screen pixel averaging the full-resolution pixels under it. Standard mipmaps blurred away about half the glitter; this took sharpness from 46% to 90% of a perfect resize.

Making it alive

The reveal

Every star, flare and speck of glow ignites at a random moment between 0.5 and 14 seconds, each with a small flash. She condenses out of the sky.

Random timing
0.5–14s
Per element
Small flash

Star events

After the reveal, about 60% of stars do something every 10 to 28 seconds: swell up to 4x brightness, or fade out and return. Stars on her face only make gentle swells and dips, because a full fade would punch a hole in her face.

Stars trigger events
60%
Event interval
10–28s

Hopping flares

The 61 big diffraction-spike stars blink out after 2 to 3.5 seconds and reappear somewhere else in the empty sky, never on her.

Big stars
61
Blink duration
2–3.5s

Meteor shower

About 33 shooting stars a minute, each with its own speed, length and brightness. 40% are fast and cross the whole screen. Paths that would cross her face are skipped.

Shooting stars
33 / min
Fast ones
40%

Hair in the breeze

46 control points over the free-moving hair, each swaying on its own rhythm, still at the roots and freest at the tips. My first version used one shared wave and looked like a ripple running through all of it at once.

Control points
46
Per strand
Individual rhythm

The blink

Three keyframes made by Gemini editing only her eyes. The lids drop to half in 0.18 seconds and lift by 0.62, every 5 to 10 seconds.

Lids down
0.18s
Lids up
0.62s
Interval
5–10s

The version I threw away

Before settling on stars that ignite in place, I built her out of 500,000 particles that spiral in from a galaxy. It worked technically, but it felt wrong. The final image was softer than the painting, and the motion felt like a screensaver.

The version I kept is simpler. Nothing moves into position. The sky is dark, and each star lights up exactly where it belongs, the way stars come out at dusk. The harder build lost to the one that felt right.

Shipping it

Loading

One breathing star in the dark, with a thin line of light that fills with the real download progress. "Tap to begin" appears only when everything has loaded, so the reveal never starts before anyone is watching.

PIN gate

A Cloudflare Worker of about 110 lines sits in front of every file. Without a valid cookie, visitors only ever get the PIN screen. The PIN is a Cloudflare secret, guesses are limited to 5 a minute per IP, and the comparison runs in constant time. If the secret is missing, the site refuses to serve instead of failing open.

PIN screen

Six faint points ignite into stars as digits are typed. A wrong code shakes them, with no text and no reload.

Deploys

Cloudflare Workers with static assets on a custom domain, deployed by Workers Builds on every merge to main, with no API token stored anywhere. The repo holds only what the site loads; the folder went from 424 MB to 24 MB.

By the numbers

Stars, desktop
24,895 stars and 61 flares on 4096 × 2528 px
Stars, phone
30,270 stars and 74 flares on 2100 × 3675 px
Download, phone / desktop
7.5 MB / 9.2 MB
Frame rate, desktop (Radeon 780M)
about 140 fps

What I learned

  • When the output can't reach the bar, change the input, not the renderer.
  • Use AI for what it's good at, painting and style, and code for what must stay exact: her face, the layout, the stars.
  • Technically harder isn't better. The particle system was the more impressive build and the worse gift.
  • Small, measured fixes beat guessing. The blur, the blink and a PIN failure were each found by checking, not by tweaking.