how the island remembers

September 9, 2026 meta devlogsveltekit

The birds and fish on this site are supposed to feel alive, drifting across the sky and the shoreline strip in the header and footer. What I didn't think through until a reader (me, testing my own site like a normal person for once) pointed it out is what happens when you hit refresh. Every single time, the whole scene reset. Different birds, different starting positions, like the sky got wiped and repopulated from scratch the moment you looked away. It didn't feel alive, it felt randomly regenerated, which is a different thing entirely.

the ask, in my own words

I wrote myself a note at the time that said, more or less, "random if visit in less than 5min... if not track where left off, and hold if change page." Clicking around the site shouldn't touch the scene at all, since the components mount once from the root layout and stay mounted the whole time you're navigating client-side. That part was already free, SvelteKit doesn't remount a component just because the URL changed underneath it. The actual gap was the other case: a hard reload, or opening a fresh tab. That's the moment the component remounts from nothing, and up to that point, "from nothing" meant a brand new random draw every time.

the first version wasn't wrong, it was frozen

My first attempt at fixing this was almost right and completely broken in a way I didn't notice for a day. I saved the drawn scene to localStorage, which sprites got picked and what point in their flight cycle each one was at, and restored it on the next mount instead of rerolling:

// first attempt: save the phase, restore the exact same phase
function restoreScene(saved: ScenePhases): ScenePhases {
	return saved;
}

That part worked, in the sense that the saved data came back intact. What I forgot was time. I saved the exact phase each bird was at when the page unloaded, and on the next load I just... put them back exactly there. Frozen. If you left the tab open in your dock for two minutes and came back, the birds hadn't moved an inch, because nothing was accounting for the two minutes that had actually passed in the real world.

The real fix needed to advance every saved phase forward by however long the visitor was actually away, wrapped correctly around each bird's own cycle length so a bird that flies edge to edge every 75 seconds and has been "away" for 200 seconds comes back partway through its third lap, not stuck at second twelve of lap one forever:

export function advancePhases(
	phases: ScenePhases,
	cycles: Record<string, number>,
	elapsedMs: number
): ScenePhases {
	const elapsedS = Math.max(0, elapsedMs) / 1000;
	const advanced: ScenePhases = {};
	for (const [key, value] of Object.entries(phases)) {
		const cycle = cycles[key];
		if (!cycle || cycle <= 0 || !Number.isFinite(value)) {
			advanced[key] = value;
			continue;
		}
		advanced[key] = (((value + elapsedS) % cycle) + cycle) % cycle;
	}
	return advanced;
}

The double-modulo at the end ((((value + elapsedS) % cycle) + cycle) % cycle) is there because JavaScript's % can return a negative result if the left side is negative, and I wanted this function safe to call with a weird or negative elapsed time too (clock skew, a saved timestamp from the future, whatever) without producing a phase outside its own valid range. Belt and suspenders, but cheap ones.

staleness has a ceiling too

None of this should hold forever. A scene from six hours ago isn't "the same visit continuing," it's a new visit that happens to share a browser. So the saved scene carries its own age check and expires after five minutes:

export const SCENE_MAX_AGE_MS = 5 * 60 * 1000;

Past that, or if nothing was saved, or if the saved data doesn't parse, it just rolls a fresh scene like it always did before any of this existed. I made sure every read here is wrapped in try/catch too, Safari in private browsing throws just touching localStorage at all, and a visitor with storage disabled shouldn't get a broken page, they should just get the ordinary fresh-roll scenery with no memory attached to it. Losing persistence quietly is fine. Throwing an error where a bird was supposed to be is not.

the wrinkle: what actually counts as "reload"

Here's the part that took me the longest to get right, after I already thought I was done. A visitor who reloads the page on purpose, hits the actual refresh button because they want to look at the page fresh, expects a reload to shuffle things, the same as a first visit would. But my restore logic couldn't tell "you hit refresh" apart from "you clicked a link" from inside the component, because by the time it runs, all it knows is "a mount happened." Both cases just look like a mount.

The browser actually tells you the difference, through an API I didn't know existed until I went looking: performance.getEntriesByType('navigation') returns an entry whose type is literally 'reload' for a deliberate reload, versus 'navigate' for basically everything else:

export function safeNavigationType(): NavigationType | undefined {
	try {
		if (typeof performance === 'undefined' || typeof performance.getEntriesByType !== 'function') {
			return undefined;
		}
		const [entry] = performance.getEntriesByType('navigation') as PerformanceNavigationTiming[];
		return entry?.type;
	} catch {
		return undefined;
	}
}

All of this math, the roll, the save, the restore, the phase advance, lives in a plain TypeScript module completely separate from the Svelte component that renders the actual birds, specifically so I could write real unit tests against it without needing a browser or a Svelte runtime spun up at all. Given a fake in-memory storage object and a fixed timestamp instead of the real Date.now(), I can assert exactly what a five-minute-old save restores to, what a six-minute-old one falls back to, and what a corrupted or partially-written save does, all without ever touching an actual page. That separation is what made me confident enough to ship the elapsed-time fix in the first place, I could see the phase numbers land exactly where the math said they should, rather than eyeballing whether a bird "looked about right" after waiting around for a few minutes in a real browser tab.

And then the actual decision, which reads almost boringly simple once the two pieces above exist to support it:

const saved = navigationType === 'reload' ? null : loadScene<unknown>(storage, SKY_COLONY_STORAGE_KEY, now);

A real reload skips the saved scene entirely and rolls fresh, on purpose, every time. Anything else, back button, a fresh tab, just navigating around, restores and advances like normal. It's a small distinction and it took me longer to track down than the persistence logic itself did, but it's the difference between the scene behaving the way an actual visitor expects it to and technically-correct-but-annoying.

0 comments

Log in to comment.

Log in

No account?