The registry

The builder pages are at /6502/builders. They still answer at games.tinymachines.ai/builders too, because nothing has been switched off.

The only stateful thing here, and the boundary is the point. The chip is untouched: every request still carries the whole machine, and running a published ROM still means POSTing it. What is stored is a catalogue. One SQLite file (REGISTRY_DB), a row per thing.

It is not a separate service. The cartridge mint, the console spec and the registry are routes on the same FastAPI app. There is one Python service, not two.

Tokens

There is no sign-up. A token is minted by hand, handed over, and claimed. One token, one builder.

python3 service/registry_admin.py mint --note "who it is for"   # printed once
python3 service/registry_admin.py tokens
python3 service/registry_admin.py builders
python3 service/registry_admin.py revoke <token-or-hash>
python3 service/registry_admin.py grant <token> <handle> <name>  # reserved names

That is deliberately the whole of the auth story for now, and it is a limitation rather than a design.

What it does get right is the part that would hurt to change later: a token is shown once and only its SHA-256 is stored, so a copy of the database is not a copy of everybody's credentials.

A token that is not this builder's gets 404, not 403: it has no business learning whether the builder exists. Revoking leaves the page and its ROMs alone, because revoking is about the credential.

Three rules that shape the rest

The registry measures rather than believes. A cartridge is a file somebody can edit, so its own verify block is a claim by its author. On publish the cartridge is unpacked and run here, and the size, tile count and frame cost printed beside it are what that run produced. A ROM that does not complete its frames is refused rather than listed.

The test publishes a cartridge claiming a 12-half-cycle frame and requires the stored number to be the measured one. See cartridges for what happens when a page reads its own request back.

Art is only ever rows of '0'..'3'. Converting a photograph happens in the browser, so there is no image parser in the request path and what lands on disk is CHR: the same encoding a sprite sheet uses, so the portrait on a builder page is drawn by the same decodeCHR that draws the game.

A PATCH touches only what it names, so a client saving a bio cannot blank an avatar it never loaded.

Pages

A page is /b/<handle>, and a ROM on it is /b/<handle>/<slug>, which is the console with that cartridge already loaded. Both are static documents that read their own path: nginx points a quoted regex location at builder.html and at index.html, so a published ROM has an address of its own rather than a query string.

The regex is quoted because nginx reads { as the start of a block. A location regex containing {2,32} fails with "unknown directive" naming the middle of the pattern.

/6502/manage is the editor: paste a token, edit the page, publish a .cart.gz.

Both halves are under the apex now

The reading half. /6502/builders is this index and /6502/builders/<handle> is a page; /6502/b/<handle> is the address the service itself hands out and redirects there, because those links are not ours to break. Both read the same live registry over CORS, which is what the Access-Control-Allow-Origin: * on that service was for.

The writing half followed on 2026-08-24, and what had held it back is worth recording because it looked like a decision and was a header. A preflight from tinymachines.ai used to come back accepting four headers, none of them Authorization, so a browser on the apex could not send a bearer token to that service at all: not to claim a handle, not to edit a page, not to publish. The request was never made, and the server log had nothing to show for it. tinymachines/6502#12 records it; the service's CORS policy now admits authorization and the registry's own verbs (PATCH, PUT, DELETE), and the editor runs here against it.

Proxying the writes through the apex's own API would work, since there is no browser between two servers and therefore no preflight. It is deliberately not done. The read-only service scope and the identity binding were left out of the listings work on the grounds that they turn into an internal join if games moves under the apex, and a credentialed proxy built now is the same boundary built twice.

Dithering was measured, not chosen

A photograph is converted in the browser (art.js) into the die's four colours and uploaded as a grid of '0'..'3', never as an image.

Dithering is Floyd-Steinberg in RGB rather than in luminance, and that was measured. By Rec.709 the palette is 17, 130, 169, 169, so polysilicon and metal are the same brightness to within 0.2 of 255 and differ only in hue. A luminance ramp has three steps, not four, and throws the warm half of the palette away.

The bug that only showed at depth two

index.html loaded game.js with a relative src. Served at /, that is /game.js; served at /b/tinymachines/die-runner it is /b/tinymachines/game.js, which is a 404.

The page still rendered, because the markup is static and only the JavaScript was missing, so it looked like a console that had failed to boot rather than one whose script was never fetched.

The document's references are absolute now, and the two fetches inside game.js resolve against import.meta.url rather than the page, the same trick the wasm glue uses. Found by driving the real page, not by reading it.