Card-art generation pipeline for monsterrobot.games

Four directions per tile (neon box art / vector side art / amiga comic /
80s airbrush) generated on the farm, plus the picker page and the installer
that ships chosen art and its risograph hover b-side.

Recipe rules, all learned the hard way against the existing covers:
tiles render 172-240px so 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); busy interiors smuggle text back via album sleeves and go
muddy small, so it is one hero subject per card.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
m3ultra 2026-07-26 12:05:04 +10:00
parent 8c8bb8cebb
commit f3773919e5
5 changed files with 544 additions and 0 deletions

View File

@ -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 &mdash; 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 = ['''<title>Cover art &mdash; pick one per game</title>
<style>
:root{
--bg:#07080d; --panel:#0d1018; --line:#1b2233; --ink:#dbe6da; --dim:#7b8894;
--faint:#47535f; --phos:#3dff9a; --pink:#ff3ea5; --cyan:#38d6ff;
--mono:ui-monospace,"Cascadia Code","JetBrains Mono",Menlo,monospace;
--disp:"Arial Black","Helvetica Neue",Impact,sans-serif;
}
*{margin:0;padding:0;box-sizing:border-box}
body{background:var(--bg);color:var(--ink);font-family:var(--mono);font-size:14px;line-height:1.5;
background-image:radial-gradient(circle at 12% 0%,rgba(255,62,165,.10),transparent 42%),
radial-gradient(circle at 88% 100%,rgba(56,214,255,.10),transparent 42%),
repeating-linear-gradient(0deg,rgba(255,255,255,.014) 0 1px,transparent 1px 3px)}
.wrap{max-width:1180px;margin:0 auto;padding:0 18px 100px}
header{padding:26px 0 8px}
h1{font-family:var(--disp);text-transform:uppercase;font-size:clamp(1.4rem,3.4vw,2.1rem);
letter-spacing:-.02em;color:#fff;line-height:1}
h1 .g{color:var(--phos)}
.sub{color:var(--dim);margin-top:.5rem;max-width:64ch}
.bar{position:sticky;top:0;z-index:20;display:flex;flex-wrap:wrap;align-items:center;gap:10px;
background:rgba(7,8,13,.93);border-bottom:1px solid var(--line);padding:11px 0;margin:14px 0 4px;
backdrop-filter:blur(8px)}
.count{color:var(--phos);letter-spacing:.1em;text-transform:uppercase;font-size:11px}
button{font-family:var(--mono);font-size:11px;letter-spacing:.12em;text-transform:uppercase;
background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:4px;
padding:7px 12px;cursor:pointer;transition:.15s}
button:hover{border-color:var(--pink);color:#fff}
button:focus-visible{outline:2px solid var(--cyan);outline-offset:2px}
button.on{border-color:var(--phos);color:var(--phos)}
/* 114 inlined covers is ~100MB of decoded bitmap if the browser paints them
all at once, which blanks the page on scroll. Only render rows near the
viewport; the intrinsic size keeps the scrollbar honest. */
.game{border-top:1px solid var(--line);padding:20px 0 24px;
content-visibility:auto;contain-intrinsic-size:auto 360px}
.gh{display:flex;align-items:baseline;gap:12px;margin-bottom:12px}
.gh .t{font-family:var(--disp);text-transform:uppercase;color:#fff;font-size:16px}
.gh .pick{color:var(--phos);font-size:11px;letter-spacing:.1em;text-transform:uppercase}
.gh .pick.none{color:var(--faint)}
.strip{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px;align-items:start}
body.tiny .strip{grid-template-columns:repeat(auto-fill,172px)}
.opt{border:1px solid var(--line);border-radius:9px;overflow:hidden;background:var(--panel);
cursor:pointer;transition:transform .16s,border-color .16s,box-shadow .16s;padding:0;display:block;
text-align:left;width:100%}
.opt:hover{transform:translateY(-4px);border-color:var(--pink);box-shadow:0 12px 30px -14px rgba(255,62,165,.6)}
.opt.sel{border-color:var(--phos);box-shadow:0 0 0 1px var(--phos),0 12px 30px -14px rgba(61,255,154,.6)}
.opt .art{position:relative;aspect-ratio:896/1152;background:#000}
.game.wide .strip{grid-template-columns:repeat(auto-fit,minmax(300px,1fr))}
body.tiny .game.wide .strip{grid-template-columns:1fr}
.game.wide .opt .art{aspect-ratio:21/9}
.opt .art img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;display:block}
.opt .lab{display:flex;align-items:center;gap:6px;padding:7px 9px;font-size:10px;
letter-spacing:.09em;text-transform:uppercase;color:var(--dim)}
.opt.sel .lab{color:var(--phos)}
.opt .lab .k{margin-left:auto;color:var(--faint)}
.opt.cur{opacity:.62}
.opt.cur .lab{color:var(--pink)}
.opt.cur:hover{transform:none;border-color:var(--line);box-shadow:none;cursor:default}
.out{position:fixed;left:0;right:0;bottom:0;z-index:30;background:rgba(7,8,13,.96);
border-top:1px solid var(--line);padding:10px 18px;display:flex;gap:12px;align-items:center;
backdrop-filter:blur(8px)}
.out code{flex:1;color:var(--cyan);font-size:12px;overflow-x:auto;white-space:nowrap;padding:4px 0}
@media(prefers-reduced-motion:reduce){*{transition:none!important}}
</style>
<div class="wrap">
<header>
<h1>Cover art &mdash; <span class="g">pick one</span> per game</h1>
<p class="sub">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 &mdash; that grey card is the cover you have now.</p>
</header>
<div class="bar">
<span class="count" id="count">0 picked</span>
<button id="tiny">actual tile size</button>
<button id="jump">next undecided</button>
<button id="copy">copy my picks</button>
</div>''']
for r in rows:
cls = 'game wide' if r['wide'] else 'game'
html.append(f''' <section class="{cls}" id="g-{r['id']}" data-game="{r['id']}">
<div class="gh"><span class="t">{r['title']}</span><span class="pick none">no pick</span></div>
<div class="strip">''')
if r['cur']:
html.append(f''' <div class="opt cur"><div class="art"><img decoding="async" src="{r['cur']}" alt=""></div>
<div class="lab">current</div></div>''')
for i, c in enumerate(r['cells'], 1):
html.append(f''' <button class="opt" data-v="{c['v']}"><div class="art"><img decoding="async" src="{c['src']}" alt=""></div>
<div class="lab">{LABELS[c['v']]}<span class="k">{i}</span></div></button>''')
html.append(' </div>\n </section>')
html.append('''</div>
<div class="out"><code id="picks">pick a cover in each row - your choices collect here</code>
<button id="copy2">copy</button></div>
<script>
const picks = {};
const games = [...document.querySelectorAll('.game')];
const fmt = () => games.filter(g => picks[g.dataset.game])
.map(g => g.dataset.game + '=' + picks[g.dataset.game]).join(' ');
function sync(){
document.getElementById('count').textContent =
Object.keys(picks).length + ' / ' + games.length + ' picked';
document.getElementById('picks').textContent =
Object.keys(picks).length ? fmt() : 'pick a cover in each row - your choices collect here';
}
games.forEach(g => {
const badge = g.querySelector('.pick');
g.querySelectorAll('.opt:not(.cur)').forEach(o => o.addEventListener('click', () => {
g.querySelectorAll('.opt').forEach(x => x.classList.remove('sel'));
o.classList.add('sel');
picks[g.dataset.game] = o.dataset.v;
badge.textContent = o.dataset.v;
badge.classList.remove('none');
sync();
}));
});
document.getElementById('tiny').addEventListener('click', e => {
document.body.classList.toggle('tiny');
e.currentTarget.classList.toggle('on');
});
document.getElementById('jump').addEventListener('click', () => {
const next = games.find(g => !picks[g.dataset.game]);
if (next) next.scrollIntoView({behavior:'smooth', block:'start'});
});
const copy = () => {
const t = fmt();
if (!t) return;
navigator.clipboard.writeText(t);
[...document.querySelectorAll('#copy,#copy2')].forEach(b => {
const o = b.textContent; b.textContent = 'copied'; setTimeout(()=>b.textContent=o, 1200);
});
};
document.getElementById('copy').addEventListener('click', copy);
document.getElementById('copy2').addEventListener('click', copy);
sync();
</script>''')
open(OUT, 'w').write('\n'.join(html))
print(f'&rarr; {OUT} ({os.path.getsize(OUT)//1024}KB)')

View File

@ -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()

View File

@ -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')

View File

@ -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/<name>.jpg at the tile's native 896x1152,
3. queues a matching risograph <name>_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()

164
tools/cardgen/profiles.json Normal file
View File

@ -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"
}
]