birds with rules

September 9, 2026 meta pixelartsveltekit

The birds on this site look like they're just wandering around doing whatever, and that's the entire point, but underneath the "whatever" is a surprising amount of arithmetic. Pool size, a hard cap on how many can be visible at once, three separate zones they're not allowed to leave, and one bird that's built to never quite sync with anything else on the page.

The homepage sky, where the bird pool actually flies

the pool and the cap

There's a pool of eight flying candidates to draw from (puffins, gulls, a tern, a cormorant, one eagle) plus three birds that are always on screen, perched rather than flying (two puffins and an oystercatcher). Every page load draws a random subset of the flying pool rather than showing all eight at once:

export function rollSkyColony(rand: () => number = Math.random): SkyColonyRoll {
	const guaranteed = CANDIDATES.find((c) => c.guaranteed)!;
	const rest = CANDIDATES.filter((c) => !c.guaranteed);
	const shuffled = [...rest].sort(() => rand() - 0.5);
	const extraCount = 2 + Math.floor(rand() * 3); // 2-4
	const chosen = [guaranteed, ...shuffled.slice(0, extraCount)];
	...
}

One candidate is always drawn (the nearest puffin, marked guaranteed, mostly so a mobile visitor, who only ever sees that one flier regardless of viewport, always has something moving in the sky) and then a random two to four more come from the rest. Do the math and the flying count on screen is always somewhere between three and five, never more, never fewer, and combined with the three always-on perched birds the total bird count sitting on the page at once is always six to eight. That's a deliberate ceiling, not a coincidence of how the randomness happens to fall, every possible draw lands inside that range by construction.

why the numbers on each bird look so deliberate

Each candidate carries two independent timing values, a horizontal crossing duration and a vertical bob duration, and I picked them specifically so no two ever share a simple ratio:

// duration 210s / bob 233s, both prime-adjacent-ish and nowhere
// near a simple ratio; tiny 0.4px amplitude, "wanders least".
{
	id: 'eagle',
	species: 'eagle',
	top: '4vh',
	left: '50%',
	width: 32,
	duration: 210,
	bobDuration: 233,
	bobAmp: 0.4,
	opacity: 0.35,
	dir: 'ltr'
}

If a bird's horizontal sweep and its vertical bob happened to share a common factor, say a 60-second crossing and a 20-second bob, the combined motion would repeat every 60 seconds and a patient viewer would eventually notice the loop. Pick durations without a shared factor instead and the true repeat period becomes their least common multiple, which for most of these pairs lands somewhere in the thousands of seconds. Nobody watches a header long enough to catch that. It reads as organic drifting because, on any timescale a person actually experiences, it effectively never repeats.

The eagle specifically gets the smallest bob amplitude and the longest bob period in the whole pool, 0.4 pixels of wander over 233 seconds, against puffins wandering over a full pixel across periods a tenth as long. It sits highest in the sky too, 4vh from the top, furthest from everything else in the pool, both spatially and rhythmically. It's the one bird built to never sync with any other bird's rhythm even by accident, and to visibly wander the least while doing it, which is where "the eagle flies alone" actually comes from, not solitude in a poetic sense, just genuinely uncorrelated motion by construction.

three zones, and a lesson about telling species apart

Birds live above a hard line, the background island silhouette's own measured top edge, expressed in the same vh units as every candidate's vertical position so the relationship holds at any viewport width. Fish live below their own line, the footer horizon strip's waterline, defined as exactly row 3 of its 5-row pixel grid (land occupies rows 0 through 2, sea takes rows 3 and 4). One fish is the deliberate exception, breaching that line briefly on its own long cycle, everything else stays strictly on its own side.

I split those zones for a real reason, not just tidiness. Early in the ambient-scenery work I had a report of a "bird" spotted swimming in the footer strip, and going back through the code, a fish silhouette and a low-flying bird silhouette were similar enough in shape and coloring at a glance that whoever was looking genuinely couldn't tell which one they were seeing. The fix wasn't just enforcing the vertical zones more strictly, it was making the actual silhouette grammars deliberately distinct from each other, a fish reads as a side-profile torpedo shape in a cool blue-grey, a bird reads as a top-down wings-spread shape entirely. Two sprites that are only ever the right shape in the right zone, so even a glance tells you what you're looking at without needing to think about it.

The zone line itself went through its own bad pass before that. First version just picked a top percentage that looked about right on my own screen:

// first attempt: eyeballed the split, no relation to the actual art
const SKY_ZONE_MAX_TOP = '55%';

Looked fine at my own viewport width and fell apart the moment the header art itself changed height, since the number wasn't tied to anything real, just a guess. What actually holds up is measuring the real silhouette instead of guessing at a percentage:

// Zone safety: the island-shadow layer is fixed, bottom-anchored, 42vh
// tall on desktop, so its own topmost silhouette pixel sits at
// (100-42)=58vh from the viewport top. Every candidate's `top` stays
// well clear of that line instead of a guessed percentage.

the part I almost didn't build

The scenery is genuinely the one place on this whole site that animates at all, everything else is static or snaps in place over 70 milliseconds and stops. It would have been much simpler to leave the header and footer as fixed, hand-picked art the way the very first version of this site actually was, five specific birds in five specific poses, no randomness, no persistence logic, no zone math to get right. Some of the code above, the coprime timing comments especially, is the kind of thing nobody will ever consciously notice as a reader. I know it's there, and once a header started actually feeling alive instead of looking like decoration that happened to move, I couldn't talk myself back into shipping the static version again.

the cap still holds on a phone

None of this is desktop-only either. Below a 768px viewport, every candidate except the one guaranteed puffin gets hidden outright in CSS, on purpose, so a small screen isn't stuck rendering four or five overlapping fliers into a header that only has room to show them cramped on top of each other. That single guaranteed bird is exactly the same one always drawn on desktop too, so mobile doesn't need a separate roll or a separate pool, it just quietly discards everything the desktop draw picked beyond that one bird. And the whole pool respects prefers-reduced-motion the same way the rest of the site's scenery does, every sprite still renders at its own resting position, frozen rather than missing, nothing on the page goes empty just because someone's system asked for less motion.

0 comments

Log in to comment.

Log in

No account?