PETRI DISH GAMES

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:

TrackLabelGenomeRendered asOn the public surface
shaderShadersa WebGL2 fragment shader bodya webgl2 canvasyes
uiUI panelsan HTML fragment with a scoped <style>an iframe with sandbox="" and srcdocno

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.

SubcommandRuns
both (the default)the arena and the breeding daemon, in one process
servethe arena alone
evolvethe breeding daemon alone
birth <track>one birth, then exits
statsper-track counts, leaderboard and prompt-gene ratings to the terminal
cullone cull pass per track, then exits
tokenprints the current secret URL
rotatemints 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 k query parameter
  • the x-lab-key header
  • the pl_key cookie

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 GET or HEAD also requires an Origin or Referer in a fixed allowlist. SameSite=Lax is scoped to the registrable domain, so any sibling origin under petridish.games would 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.

RouteMethodReturns or does
/api/pub/configGETthe shader prelude and the published track list
/api/pub/poolsGETthe ecosystem plus every lineage with at least two visible members
/api/pub/pairGETtwo organisms to judge, as id and code only
/api/pub/galleryGETa best-first slice, each item with its code, its prompt gene and its taxonomy
/api/pub/votePOSTrecords a verdict on the public ladder
/api/pub/seenPOSTreports whether a dealt organism compiled and drew
/api/pub/crossPOSTbreeds 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.

TableHolds
organismsone row per organism: code, title, prompt gene, operator, parents, generation, both ladders, status, render proof, origin
promptsone row per directive: text, generation, parent, both ladders, uses, status
duelsevery verdict, with which judge cast it and which organism was rejected
eventsthe lab’s own log
kvcounters 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.

SymbolDigest
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

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.