diff --git a/tools/cardgen/build_picker.py b/tools/cardgen/build_picker.py
new file mode 100644
index 0000000..e6ed11c
--- /dev/null
+++ b/tools/cardgen/build_picker.py
@@ -0,0 +1,190 @@
+#!/usr/bin/env python3
+"""Build the cover-art picker page (self-contained, images inlined as webp data URIs).
+
+Judged on the same dark ground the cards actually live on, and at the true
+172px tile size, because that is the only size that matters on the real page.
+"""
+import base64, io, json, os, subprocess, sys, tempfile
+
+CARDS = sys.argv[1]
+CURRENT = sys.argv[2] # dir of existing covers for comparison
+PROFILES = json.load(open(sys.argv[3]))
+OUT = sys.argv[4]
+VARIANTS = ['neon', 'vector', 'comic', 'paint']
+LABELS = {'neon': 'neon box art', 'vector': 'vector side art',
+ 'comic': 'amiga comic', 'paint': '80s airbrush'}
+# tiles the arcade page shows for each game id
+TITLES = {p['id']: p.get('title', p['id']) for p in PROFILES}
+
+
+def webp(path, width=380, q=72):
+ """Downscale to a data URI (macOS sips cannot WRITE webp — use cwebp)."""
+ if not os.path.exists(path):
+ return None
+ with tempfile.TemporaryDirectory() as td:
+ dst = os.path.join(td, 'o.webp')
+ r = subprocess.run(['cwebp', '-q', str(q), '-resize', str(width), '0', path, '-o', dst],
+ capture_output=True)
+ if r.returncode != 0 or not os.path.exists(dst):
+ return None
+ b = open(dst, 'rb').read()
+ return 'data:image/webp;base64,' + base64.b64encode(b).decode()
+
+
+rows = []
+for p in PROFILES:
+ gid = p['id']
+ cells = []
+ wide = p.get('aspect') == 'wide'
+ src_dir = os.path.join(os.path.dirname(CARDS), 'hero') if wide else CARDS
+ cur = webp(os.path.join(CURRENT, p.get('current_cover', gid) + '.jpg'),
+ 620 if wide else 300, 70)
+ for v in VARIANTS:
+ d = webp(os.path.join(src_dir, f'{gid}__{v}.png'), 560 if wide else 380)
+ if d:
+ cells.append({'v': v, 'src': d})
+ if cells:
+ rows.append({'id': gid, 'title': TITLES.get(gid, gid), 'cur': cur,
+ 'cells': cells, 'wide': wide})
+
+total_kb = sum(len(c['src']) for r in rows for c in r['cells']) // 1024
+print(f'{len(rows)} games, {sum(len(r["cells"]) for r in rows)} variants, ~{total_kb//1024}MB inlined')
+
+html = ['''
Cover art — pick one per game
+
+
+
+
Cover art — pick one per game
+
Four directions per game, generated clean on the farm. The old covers had
+ garbled AI lettering baked in; these carry no type at all, because the site already
+ draws every title in HTML. Judge them small — that grey card is the cover you have now.
+
+
+ 0 picked
+
+
+
+
''']
+
+for r in rows:
+ cls = 'game wide' if r['wide'] else 'game'
+ html.append(f'''
+
{r['title']}no pick
+
''')
+ if r['cur']:
+ html.append(f'''
+
current
''')
+ for i, c in enumerate(r['cells'], 1):
+ html.append(f''' ''')
+ html.append('
\n ')
+
+html.append('''
+
pick a cover in each row - your choices collect here
+
+''')
+
+open(OUT, 'w').write('\n'.join(html))
+print(f'→ {OUT} ({os.path.getsize(OUT)//1024}KB)')
diff --git a/tools/cardgen/gen_cards.py b/tools/cardgen/gen_cards.py
new file mode 100644
index 0000000..e5681e5
--- /dev/null
+++ b/tools/cardgen/gen_cards.py
@@ -0,0 +1,62 @@
+#!/usr/bin/env python3
+"""Build the 4-variant card-art spec for monsterrobot.games from profiles.json.
+
+Recipe rules, all learned from the bake-off against the existing covers:
+ * Tiles render 172-240px wide -> silhouette and colour are all that read.
+ * The page draws every title in HTML, so art carries NO lettering. The old
+ covers baked in garbled type ("INFTE! COPFRIICGN") - that is the slop tell.
+ * Busy interiors smuggle text back in via album sleeves/posters and go muddy
+ at thumbnail size. Single hero subject, always.
+"""
+import json, os, sys
+
+OUT = sys.argv[2] if len(sys.argv) > 2 else '/private/tmp/claude-501/-Users-m3ultra-Documents/097e3817-807d-439d-b5f5-95a6ffd4a1f9/scratchpad/cards'
+
+READS = ("single bold hero subject filling the frame, strong clean silhouette, "
+ "dramatic rim light, high contrast, saturated limited palette, "
+ "deep dark background, poster composition, reads clearly at thumbnail size")
+NOTEXT = ("completely wordless, no text, no letters, no words, no title, no logo, "
+ "no signage, no watermark, no signature, blank unlabelled sleeves and "
+ "plain unmarked surfaces")
+
+VARIANTS = [
+ # key model steps style
+ ('neon', 'flux2-klein-4b', 4,
+ "1987 British home-computer game box art, airbrushed gouache, glossy "
+ "highlights, heroic low angle, screaming neon cyan and magenta on pure black"),
+ ('vector', 'flux2-klein-4b', 4,
+ "1980s arcade cabinet side art, bold flat vector shapes, hard edges, thick "
+ "black outlines, blazing neon on black, geometric and graphic, no gradients"),
+ ('comic', 'flux2-klein-4b', 4,
+ "early-1990s Amiga game cover painting, chunky saturated brushwork, bold cel "
+ "shading, comic-book energy, primary colours, dark vignette"),
+ ('paint', 'z-image-turbo', 8,
+ "classic 1980s airbrush poster painting, silhouetted figures against a glowing "
+ "backlit haze, lush deep colour, cinematic, painted on black"),
+]
+
+
+def main():
+ profiles = json.load(open(sys.argv[1]))
+ spec = []
+ for p in profiles:
+ subj = p['hero_subject']
+ pal = p.get('palette') or ''
+ for key, model, steps, style in VARIANTS:
+ params = {
+ 'prompt': f"{style}. {subj}. {pal + '. ' if pal else ''}{READS}. {NOTEXT}",
+ 'model': model, 'steps': steps,
+ 'width': 896, 'height': 1152, 'seed': 7,
+ }
+ if model == 'z-image-turbo':
+ params['quantize'] = '4'
+ spec.append({'out': f"{OUT}/{p['id']}__{key}.png",
+ 'operator': 'flux_local', 'params': params})
+ os.makedirs(OUT, exist_ok=True)
+ path = OUT + '/spec.json'
+ json.dump(spec, open(path, 'w'), indent=1)
+ print(f'{len(spec)} jobs ({len(profiles)} games x {len(VARIANTS)}) -> {path}')
+
+
+if __name__ == '__main__':
+ main()
diff --git a/tools/cardgen/hero_spec.py b/tools/cardgen/hero_spec.py
new file mode 100644
index 0000000..2b41e49
--- /dev/null
+++ b/tools/cardgen/hero_spec.py
@@ -0,0 +1,33 @@
+#!/usr/bin/env python3
+"""Hero banner variants. The slot is 21:9 and the current art is a PORTRAIT
+image cropped to fit, so most of it is thrown away. The page scrims the left
+side for the headline, so the robot is composed hard right with clean dark
+space on the left."""
+import json, os
+
+OUT = '/private/tmp/claude-501/-Users-m3ultra-Documents/097e3817-807d-439d-b5f5-95a6ffd4a1f9/scratchpad/hero'
+SUBJ = ("a colossal chrome monster robot standing menacingly among glowing arcade "
+ "cabinets in a dark neon arcade, reflections on a wet tiled floor, "
+ "composed on the right hand side of a wide frame")
+READS = ("ultra wide cinematic banner, the left third is empty dark negative space, "
+ "strong silhouette, high contrast, saturated neon on near black")
+NOTEXT = ("completely wordless, no text, no letters, no words, no logo, no marquee "
+ "lettering, no watermark, no signature, blank unmarked cabinet panels")
+STYLES = {
+ 'neon': "1987 British home-computer game box art, airbrushed gouache, glossy highlights, screaming neon cyan and magenta on black",
+ 'vector': "1980s arcade cabinet side art, bold flat vector shapes, thick black outlines, blazing neon on black, geometric",
+ 'comic': "early-1990s Amiga game cover painting, chunky saturated brushwork, bold cel shading, dark vignette",
+ 'paint': "classic 1980s airbrush poster painting, silhouettes against a glowing backlit haze, lush deep colour, cinematic",
+}
+spec = []
+for k, st in STYLES.items():
+ p = {'prompt': f'{st}. {SUBJ}. {READS}. {NOTEXT}',
+ 'model': 'z-image-turbo' if k == 'paint' else 'flux2-klein-4b',
+ 'steps': 8 if k == 'paint' else 4,
+ 'width': 1344, 'height': 576, 'seed': 13}
+ if k == 'paint':
+ p['quantize'] = '4'
+ spec.append({'out': f'{OUT}/hero__{k}.png', 'operator': 'flux_local', 'params': p})
+os.makedirs(OUT, exist_ok=True)
+json.dump(spec, open(OUT + '/spec.json', 'w'), indent=1)
+print(len(spec), 'hero jobs')
diff --git a/tools/cardgen/install_picks.py b/tools/cardgen/install_picks.py
new file mode 100644
index 0000000..28f8ade
--- /dev/null
+++ b/tools/cardgen/install_picks.py
@@ -0,0 +1,95 @@
+#!/usr/bin/env python3
+"""Install chosen card art onto monsterrobot.games.
+
+Usage: install_picks.py "cratewars=vector stylus9=neon ..." [--dry]
+
+For each pick it:
+ 1. trims the paper border z-image sometimes paints around 'paint' variants,
+ 2. writes covers/.jpg at the tile's native 896x1152,
+ 3. queues a matching risograph _riso.jpg for the page's hover flip,
+ 4. rsyncs both to the lander (host path ā the nginx mount is read-only).
+"""
+import json, os, subprocess, sys, tempfile
+
+S = os.path.dirname(os.path.abspath(__file__))
+CARDS = f'{S}/cards'
+RISO = f'{S}/riso'
+STAGE = f'{S}/install'
+VPS = 'humanjing@100.71.119.27'
+LANDER = '/home/humanjing/monsterrobot.games/covers'
+PROFILES = {p['id']: p for p in json.load(open(f'{S}/profiles.json'))}
+
+RISO_STYLE = ("risograph screenprint poster, three flat spot colours only, visible halftone "
+ "dot texture, slight misregistration, bold graphic shapes, flat matte inks")
+READS = ("single bold hero subject filling the frame, strong clean silhouette, high contrast, "
+ "poster composition, reads clearly at thumbnail size")
+NOTEXT = ("completely wordless, no text, no letters, no words, no logo, no watermark, "
+ "no signature, blank unlabelled sleeves")
+
+
+def sh(*a):
+ r = subprocess.run(a, capture_output=True)
+ if r.returncode:
+ sys.exit(f'FAILED: {" ".join(a)}\n{r.stderr.decode()[:400]}')
+
+
+def to_cover(src, dst, trim):
+ """896x1152 jpeg, optionally trimming a painted paper border first."""
+ with tempfile.TemporaryDirectory() as td:
+ cur = src
+ if trim:
+ w = int(subprocess.run(['sips', '-g', 'pixelWidth', src], capture_output=True)
+ .stdout.decode().split(':')[-1])
+ h = int(subprocess.run(['sips', '-g', 'pixelHeight', src], capture_output=True)
+ .stdout.decode().split(':')[-1])
+ cur = os.path.join(td, 'c.png')
+ sh('sips', '-c', str(int(h * 0.955)), str(int(w * 0.955)), src, '--out', cur)
+ sh('sips', '-s', 'format', 'jpeg', '-s', 'formatOptions', '82',
+ '-z', '1152', '896', cur, '--out', dst)
+
+
+def main():
+ picks = dict(p.split('=') for p in sys.argv[1].split() if '=' in p)
+ dry = '--dry' in sys.argv
+ os.makedirs(STAGE, exist_ok=True)
+ os.makedirs(RISO, exist_ok=True)
+
+ # 1. main covers from the picked variants
+ riso_spec = []
+ for gid, variant in picks.items():
+ src = f'{CARDS}/{gid}__{variant}.png'
+ if not os.path.exists(src):
+ print(f' ! missing {src}'); continue
+ name = PROFILES[gid].get('current_cover', gid)
+ if name == '__none__':
+ name = gid
+ to_cover(src, f'{STAGE}/{name}.jpg', trim=(variant == 'paint'))
+ print(f' ā {name}.jpg ({variant})')
+ p = PROFILES[gid]
+ riso_spec.append({
+ 'out': f'{RISO}/{name}_riso.png', 'operator': 'flux_local',
+ 'params': {'prompt': f"{RISO_STYLE}. {p['hero_subject']}. {READS}. {NOTEXT}",
+ 'model': 'flux2-klein-4b', 'steps': 4,
+ 'width': 896, 'height': 1152, 'seed': 7}})
+
+ # 2. the hover b-sides
+ json.dump(riso_spec, open(f'{RISO}/spec.json', 'w'), indent=1)
+ if not dry:
+ sh(sys.executable, f'{os.path.expanduser("~")}/Documents/paradramorama/tools/mbgen.py',
+ f'{RISO}/spec.json')
+ for item in riso_spec:
+ base = os.path.basename(item['out'])[:-4]
+ if os.path.exists(item['out']):
+ to_cover(item['out'], f'{STAGE}/{base}.jpg', trim=False)
+ print(f' ā {base}.jpg')
+
+ # 3. ship
+ if dry:
+ print('\n[dry] staged only, nothing uploaded')
+ return
+ sh('rsync', '-az', f'{STAGE}/', f'{VPS}:{LANDER}/')
+ print(f'\nā uploaded {len(os.listdir(STAGE))} files to {LANDER}')
+
+
+if __name__ == '__main__':
+ main()
diff --git a/tools/cardgen/profiles.json b/tools/cardgen/profiles.json
new file mode 100644
index 0000000..9bd6a18
--- /dev/null
+++ b/tools/cardgen/profiles.json
@@ -0,0 +1,164 @@
+[
+ {
+ "id": "hero",
+ "title": "Hero banner",
+ "current_cover": "hero",
+ "aspect": "wide",
+ "hero_subject": "colossal chrome monster robot among glowing arcade cabinets",
+ "palette": ""
+ },
+ {
+ "id": "dj90s",
+ "title": "90s DJ Sim",
+ "current_cover": "dj90s",
+ "hero_subject": "a teenager in a cluttered 1990s Australian bedroom hunched over two belt-drive turntables on a plank-and-crate desk, headphones on one ear, milk crates of records under the bench, late afternoon light through venetian blinds",
+ "palette": "faded teal, warm orange, wood-panel brown"
+ },
+ {
+ "id": "nottonight",
+ "title": "Not Tonight",
+ "current_cover": "nottonight",
+ "hero_subject": "a huge nightclub doorman filling a doorway at night, one arm out flat blocking the way, stamp in his fist, a long queue bunched behind a rope in the rain, red door light on his face",
+ "palette": "night blue, sodium orange, deep red"
+ },
+ {
+ "id": "thriftgod",
+ "title": "Thriftgod",
+ "current_cover": "thriftgod",
+ "hero_subject": "a treasure hunter elbow deep in an overflowing op-shop bin of tangled clothes and dusty records, holding one find aloft in a shaft of fluorescent light",
+ "palette": "fluorescent beige, faded pastel jumble, brown"
+ },
+ {
+ "id": "procity",
+ "title": "Procity",
+ "current_cover": "procity",
+ "hero_subject": "a giant hand lowering a glowing skyscraper into place on a tiny isometric city block, cranes and roads sprawling below, dusk light",
+ "palette": "twilight blue, warm window gold, concrete grey"
+ },
+ {
+ "id": "recordstoreguy",
+ "title": "Record Store Guy",
+ "current_cover": "recordstoreguy",
+ "hero_subject": "a record store clerk diving sideways as an entire shelf of records avalanches down on him, blank sleeves fanning through the air mid-fall, cinematic film grain",
+ "palette": "amber tungsten, deep shadow black, VHS teal"
+ },
+ {
+ "id": "stylus9",
+ "title": "Stylus-9",
+ "current_cover": "stylus9",
+ "hero_subject": "a glowing wireframe diamond stylus ship screaming down a canyon of black vinyl groove walls, tracer fire ahead, a colossal chrome tonearm looming overhead",
+ "palette": "vector black, phosphor cyan, hot magenta"
+ },
+ {
+ "id": "morp",
+ "title": "Beyond Morp",
+ "current_cover": "morp",
+ "hero_subject": "a lone shopper dwarfed at the mouth of an endless maze of record racks curving overhead like canyon walls, one bare bulb swinging above, green terminal light spilling across the floor",
+ "palette": "phosphor green, black, dusty cardboard brown"
+ },
+ {
+ "id": "turncraft",
+ "title": "Turncraft",
+ "current_cover": "turncraft",
+ "hero_subject": "a close low three-quarter view of a pair of chunky DJ turntables and a mixer, one hand pinning the spinning platter mid scratch, tonearm and cartridge in sharp focus",
+ "palette": "matte black, brushed silver, red LED glow"
+ },
+ {
+ "id": "cratewars",
+ "title": "Crate Wars 2",
+ "current_cover": "cratewars",
+ "hero_subject": "two rival crate diggers wrestling over a milk crate stuffed with records in a bare warehouse, blank sleeves spilling and skidding across concrete, a beat-up van idling behind them",
+ "palette": "nicotine amber, concrete grey, blood red"
+ },
+ {
+ "id": "roguelike",
+ "title": "Monster Roguelike",
+ "current_cover": "roguelike",
+ "hero_subject": "a hunched crate digger with a bulging record bag facing a matted wild-eyed neckbeard monster clutching warped melted records like claws, in a torchlit dungeon aisle of record racks",
+ "palette": "dungeon black, gold, sickly green"
+ },
+ {
+ "id": "keyboardwarrior",
+ "title": "Keyboard Warrior",
+ "current_cover": "keyboardwarrior",
+ "hero_subject": "two furious music nerds hunched at facing desks in the dark, faces lit blue by monitors, hammering mechanical keyboards at each other like duelling swordsmen",
+ "palette": "forum blue, monitor glow white, dark grey"
+ },
+ {
+ "id": "vinylgauntlet",
+ "title": "Vinyl Gauntlet",
+ "current_cover": "vinylgauntlet",
+ "hero_subject": "a lone digger swinging a record crate at a swarming mob of collector ghouls in a warehouse aisle, records flying like discs, a glowing pile of gold and vinyl in the corner",
+ "palette": "dungeon purple black, gold, acid green"
+ },
+ {
+ "id": "morpquest",
+ "title": "Morpquest",
+ "current_cover": "morpquest",
+ "hero_subject": "a queue of desperate collectors surging at dawn across a wet car park toward a roller door, one lone digger clutching a blank white label record above his head",
+ "palette": "sky blue, brick orange, black"
+ },
+ {
+ "id": "deadstock",
+ "title": "Deadstock",
+ "current_cover": "deadstock",
+ "hero_subject": "a battered portable suitcase record player open on a stone floor, its spinning platter throwing a blinding shaft of white light up onto a huge sealed vault door",
+ "palette": "cyan, magenta, black"
+ },
+ {
+ "id": "blobbo",
+ "title": "Blobbo",
+ "current_cover": "blobbo",
+ "hero_subject": "a fat translucent jelly blob mid splat against a candy coloured platform, wobbling out of shape, trailing a smear of wet paint and a burst of confetti, cartoon cannons behind",
+ "palette": "sky blue, hot pink, lime green"
+ },
+ {
+ "id": "glytch",
+ "title": "Glytch",
+ "current_cover": "glytch",
+ "hero_subject": "a half grown clone creature floating in a glowing bubbling vat on a factory production line, cables snaking into its back, a chunky CRT monitor bolted beside it flickering with magenta scanlines",
+ "palette": "deep violet black, hot magenta, cyan"
+ },
+ {
+ "id": "gutsy",
+ "title": "Guts",
+ "current_cover": "gutsy",
+ "hero_subject": "a tiny chrome bio-ship banking hard down a slick pink peristaltic tunnel, wet ribbed tissue walls glistening, splashes of green acid and glowing molecule clusters ahead",
+ "palette": "wet salmon pink, bile green, chrome silver"
+ },
+ {
+ "id": "hardyards",
+ "title": "Hard Yards",
+ "current_cover": "hardyards",
+ "hero_subject": "a person hauling a rope tight on a shade sail in a suburban Australian backyard as a black hail supercell rolls over the gum trees, hailstones bouncing off a corrugated carport",
+ "palette": "storm grey blue, dry gum leaf green, rusted corrugated iron"
+ },
+ {
+ "id": "sandoniette",
+ "title": "Sandoniette",
+ "current_cover": "sandoniette",
+ "hero_subject": "a scowling samurai in armour completing a katana slash through a flying sandwich, halved bread cheese egg and cutlet spinning apart mid air, a splash of sauce trailing the blade",
+ "palette": "washi cream, ink black, vermilion red"
+ },
+ {
+ "id": "toastsim",
+ "title": "Toastsim",
+ "current_cover": "toastsim",
+ "hero_subject": "a chrome two slot toaster on a wooden kitchen bench at the moment of ejection, a slice of sourdough launched into the air trailing crumbs and butter, tomatoes garlic and a knife scattered around",
+ "palette": "golden crust brown, tomato red, warm kitchen cream"
+ },
+ {
+ "id": "mollycool",
+ "title": "Molly Cool",
+ "current_cover": "mollycool",
+ "hero_subject": "a glowing white particle creature with two energy hands hauling two brightly coloured atoms together against a straining bond of light, a swirling storm of jittering coloured spheres all around in the dark",
+ "palette": "black, electric white, emission green and blue"
+ },
+ {
+ "id": "paradramorama",
+ "title": "Paradramorama",
+ "current_cover": "__none__",
+ "hero_subject": "a formless crackling ball of electric blue static pouring itself into the front panel of a rack mounted synthesizer, the machine's knobs and meters glowing awake, other dark hardware watching from the studio gloom",
+ "palette": "near black navy, electric cyan, violet"
+ }
+]
\ No newline at end of file