The Rorschach Lab
An evolutionary lab that breeds generated visuals on one desktop, where source is one genome, the aesthetic directive is a second, and human picks are the fitness function.
The Rorschach Lab is an evolutionary lab that breeds generated visuals: the source of a
rendered artifact is one genome, the aesthetic directive that produced it is a second, and human
picks are the fitness function. It runs on one desktop, listens only on loopback, and reaches the
internet through a single Cloudflare tunnel. Its repository is prettylab, its state is one sqlite
file, and everything it breeds is Apache-2.0.
Tracks and genomes
An organism carries two genomes. The first is its own source, which the breeding operators mutate and recombine. The second is the prompt gene, the aesthetic directive fed to the generator that wrote the source; directives are a population of their own, with their own ratings and their own descent, described under prompt genes.
Neither genome carries the technical rules. Those live in a per-track
contract fixed in src/tracks.js and prepended to the directive at
generation time, so evolution on the prompt side cannot drift into breaking the renderer.
Two tracks are defined:
| Track | Label | Genome | Rendered as | On the public surface |
|---|---|---|---|---|
shader | Shaders | a WebGL2 fragment shader body | a webgl2 canvas | yes |
ui | UI panels | an HTML fragment with a scoped <style> | an iframe with sandbox="" and srcdoc | no |
The ui contract fixes the data every panel must display, a five-row readout with a strain block,
an action and a warning, and forbids inventing numbers. Every organism on that track therefore shows
the same information, and a duel between two of them is a comparison of presentation alone.
The published track set is the single entry shader. Any other track named on a public route is
answered with a 404 rather than an empty result, so an unpublished track cannot become public by
being asked for.
Processes
bin/prettylab.js is the only entry point. The subcommand chooses which halves run.
| Subcommand | Runs |
|---|---|
both (the default) | the arena and the breeding daemon, in one process |
serve | the arena alone |
evolve | the breeding daemon alone |
birth <track> | one birth, then exits |
stats | per-track counts, leaderboard and prompt-gene ratings to the terminal |
cull | one cull pass per track, then exits |
token | prints the current secret URL |
rotate | mints a new token and prints the new URL |
Under both the daemon’s promise is not awaited and its rejections are logged as a daemon-fail
event instead of propagating. An unhandled rejection out of the breeding loop is a process exit,
which would take the arena down with it; a failed birth is recoverable, an arena that dies
mid-session is not.
The daemon runs an endless loop over the configured tracks, culling, attempting one birth and culling again on each pass, and sleeping between passes. The pass is set out under breeding. Population size never halts breeding; death is what holds the pool at capacity.
The arena is an HTTP server built on Node’s standard library with no external dependencies. It
serves the owner’s judging page at /, the owner’s gallery at
/gallery, the anonymous voting page at /vote, and the two API surfaces below.
The perimeter
The server binds the host in lab.config.json, currently 127.0.0.1, on port 7777. Nothing is
listening on the local network. A cloudflared tunnel on the same desktop is the only route in: its
committed ingress template maps one hostname to http://127.0.0.1:7777 and answers every other
hostname with http_status:404. The configured public URL is rorschach.petridish.games.
Every owner request carries the token, in one of three places, checked in this order:
- the
kquery parameter - the
x-lab-keyheader - the
pl_keycookie
The token is 16 random bytes rendered as 32 hex characters, held in data/token.txt and compared
with timingSafeEqual behind a length check. A request carrying none of the three is answered 404
with no body, so an unauthenticated scanner learns nothing about what is here and has no login form
to attack.
A token arriving as k on a page request is traded for a cookie and redirected to the clean path,
which keeps the secret out of the browser’s URL bar and history. The cookie is HttpOnly,
SameSite=Lax, one year long, and marked Secure when the request reached the origin through the
tunnel.
rotate rewrites the token file from a separate process. A running server caches the token keyed on
that file’s modification time and size, so a rotation takes effect on the next authentication check
with no restart, and every cookie minted from the old secret stops working at the same moment.
Two further gates sit behind the token:
- An owner route with a method other than
GETorHEADalso requires anOriginorRefererin a fixed allowlist.SameSite=Laxis scoped to the registrable domain, so any sibling origin underpetridish.gameswould otherwise carry the cookie into a request that reseeds a whole pool. - A request body over 256 KiB is rejected and its socket destroyed.
/healthz is the one route with no token at all. It exists for the tunnel’s health check and returns
nothing about the lab’s contents.
The public surface
The routes under /api/pub/ are a separate, narrower API, not the owner’s routes with the check
taken off. They are unauthenticated and reachable by anyone. The public page on petridish.games is a
pure client of them: it holds no credential, and it renders each shader on its own canvas with the
optional audio input the contract defines, described under the voice.
| Route | Method | Returns or does |
|---|---|---|
/api/pub/config | GET | the shader prelude and the published track list |
/api/pub/pools | GET | the ecosystem plus every lineage with at least two visible members |
/api/pub/pair | GET | two organisms to judge, as id and code only |
/api/pub/gallery | GET | a best-first slice, each item with its code, its prompt gene and its taxonomy |
/api/pub/vote | POST | records a verdict on the public ladder |
/api/pub/seen | POST | reports whether a dealt organism compiled and drew |
/api/pub/cross | POST | breeds one child from two organisms the visitor was shown, see crossing |
Every response is filtered by the same visibility rule the pair endpoint applies, so a stranger can only vote on, or cross, an organism the surface would itself have shown. Ratings, game counts, the prompt population and its lineage are never published. A pair carries no rating, no title and no gene; the gallery publishes the prompt gene behind each card it shows and nothing about the rest of the directive population.
Public verdicts write a second set of columns, and selection reads both ladders through a single blended fitness, an owner’s game weighing more than a stranger’s. What no public route can do is kill: there is no public equivalent of the owner’s invalid-render route, and a shader that fails to draw on a visitor’s device takes an in-memory strike that stops it being dealt again rather than a death.
A token bucket meters each client, keyed on the address Cloudflare reports, and an exhausted bucket is answered 429. Its costs and refill rate are tabulated under judging. The bucket is a budget rather than an access control: the address it keys on is cheap to change, and it exists to bound how many rows one script can write.
Cross-origin access is an explicit allowlist of four origins: https://petridish.games,
https://www.petridish.games, and the two localhost ports the site’s dev and preview servers use.
The allowed origin is echoed back per request rather than answered with a wildcard.
State and licence
All state is one sqlite file, data/lab.db, opened through node:sqlite in WAL mode and created on
first run. Deleting it starts the lab over.
| Table | Holds |
|---|---|
organisms | one row per organism: code, title, prompt gene, operator, parents, generation, both ladders, status, render proof, origin |
prompts | one row per directive: text, generation, parent, both ladders, uses, status |
duels | every verdict, with which judge cast it and which organism was rejected |
events | the lab’s own log |
kv | counters that must survive a restart, including the birth count prompt evolution fires on |
Rows are never deleted. A dead or culled organism keeps its row, which is what lets ancestry be walked back to a founding organism long after the intervening generations are gone.
The repository is licensed Apache-2.0, and the licence covers what the lab breeds as well as the
code that breeds it. The gallery endpoint states Apache-2.0 in its own response, alongside the
shader bodies and the directives it publishes.
Purpose
The lab exists so that the Petri Dish grows its own representation of itself. The population it breeds is the visual language of the studio’s surfaces: an aesthetic arrived at by selection over recorded verdicts rather than chosen once, and held by a population that keeps turning over rather than by a style guide.
The shader track is the part of that population the public judges, and the pool it judges is the
same pool the daemon breeds from. The ui track is the same machinery pointed at an interface
surface, where the fixed data payload makes the contest a contest of presentation.
Bound to
The source this page's claims were read against, named by symbol and pinned by the digest of the file it lives in.
| Symbol | Digest |
|---|---|
| auth.js | 1c1580a0c0a0 |
| db.js | 193eb96a460a |
| lab.config.json | a095dd3b3dab |
| LICENSE | d64569567334 |
| prettylab.js | fa40fd73c4ef |
| public-api.js | f14fffef049d |
| rorschach.astro | b439b3c1f84b |
| runDaemon | b72f4e12e72f |
| server.js | 2ee5143085a9 |
| tracks.js | 979ad190a564 |
What links here
- Breeding sends readers here
- Crossing sends readers here
- Death sends readers here
- Freezing and reseeding sends readers here
- Judging sends readers here
- Organisms sends readers here
- Prompt genes sends readers here
- Taxonomy sends readers here
- The gallery sends readers here
- The voice sends readers here
Near this subject
These are the subjects this one is joined to in the field. Step out of this card and they are the wells around the one you are standing in.
- Crossing each sends you to the other
- Breeding each sends you to the other
- Judging each sends you to the other
- Death each sends you to the other
- Organisms each sends you to the other
- Prompt genes each sends you to the other
- Freezing and reseeding each sends you to the other
- Taxonomy each sends you to the other
- The gallery each sends you to the other
- The voice each sends you to the other