Writing

A browser game that saves progress, no account

· 5 min

Most browser games handle progress one of two ways. Either it lives in localStorage and vanishes the first time someone clears their browser, or you are asked to make an account before you have decided you like the thing.

There is a third option: a browser game can save your progress without an account at all. Give every visitor a real server-side player row, with no signup, and let signing in be an upgrade rather than a gate. Astrototo does this. The interesting parts are the one endpoint that refuses anonymous players, and the compiler error that enforces it.

How a browser game saves progress without an account

The first time you open the game — /play, not the landing page — the browser mints a random UUID, keeps it in localStorage under andromeda:anon, and exchanges it for a session token. The server creates a player row.

An anonymous player is not a special case. It is an ordinary row with a null email and a null google_sub. There is no parallel "anonymous save" table and no branch through the save code. Two tables would have meant two code paths, and the anonymous one would rot.

Sessions last 180 days, because being logged out mid-voyage is hostile in a game you are meant to play in two-minute pieces.

What a server-side save actually buys

Progress survives a reload, a new tab and a closed browser.

Be precise about the mechanism, because it is the opposite of what you might assume: localStorage is still the synchronous write, and the server is a debounced mirror. The push is fire-and-forget on a 1200 ms timer with the error swallowed. A failed push loses nothing the player can see, and a crash inside that window loses the last change server-side.

The server copy is what lets a voyage outlive the page, and what lets us see the game at all. Before it existed the whole run lived in one localStorage key — invisible to us, so we had no idea whether anyone finished an episode or where they stopped.

It also means nothing ever demands an account. There is a sign-in button on the home page and on the Chart, blocking nothing. Exactly one thing requires an identity, and it is the Pay button.

When progress without an account is lost

Clear your site data and the run is gone. The id lives in the browser; when it goes, the server holds a row it can no longer connect to anyone. Private browsing is worse — if localStorage throws, the code falls back and you get a new anonymous player on every load.

This is the trade we mentioned in passing in what a browser game gives up by having no download; here it is the whole post.

We chose it. The alternative is device fingerprinting, and the argument against it is mechanical rather than moral: fingerprints collide often enough to occasionally hand one person another person's save, and the implementations accurate enough to be worth using are a paid third-party service. Signing in fixes it honestly, and the cost of declining is stated rather than hidden.

What happens when an anonymous player signs in

Two cases, wanting opposite behaviour.

First sign-in. The anonymous row gains an email and a Google subject id. It stops being anonymous and becomes the account. Nothing is copied — the save and the run history come along because they never moved. A migration that shuffles records between rows is a migration that can fail halfway, and you find out from someone whose progress vanished.

Signing in to an account that already exists. Now there are two players: the anonymous one from this browser and the real one from before. We leave the anonymous row exactly where it is and adopt the account's own save. No merge, no "keep the further one", and no prompt — asking is a question about consequences the person cannot see yet, at the moment they were trying to do something else.

The server tells the client which of the two happened and returns the authoritative save either way, so the client never guesses. Accounts are keyed on google_sub, never email: an email can change hands, a subject cannot.

A headless harness walks both cases across two simulated browsers — 14 checks, including the abandoned anonymous row surviving a sign-in on a second device.

Why anonymous players cannot buy anything

Checkout returns 403 SIGN_IN_REQUIRED without a google_sub.

This is not a rule the anonymous model invented. The signup gate and the money gate were already the same door; what changed is that playing stopped being behind it.

The part worth stealing is how it is enforced. Because player.email is nullable, the payment-gateway call — which needs a real customer email — does not compile until the anonymous case is handled. The type error is the feature. The obvious shortcut, a placeholder email for anonymous players, would make the column lie and the guard disappear.

You can start a voyage without telling us anything. It will remember where you got to.

Play it — it runs in this tab