The visual spec for device/runner.py. Every rule here is traceable to the
reference board the project was built against rather than to taste. If a
change fights this document, change the document first.
Retro-futurist industrial sci-fi: the aesthetic of a security terminal that watches something biological. The lineage is the graphic-realism school — simplified, deconstructed forms at real-world proportions, strong flat colour statements, limited materials, accents doing the work of rendering: F1 livery sheets, Ghost in the Shell (1995), PS2-era Sony ads, Designers Republic on WipEout, Teenage Engineering hardware, Metahaven. Layout habits come from Japanese near-future graphic boards: bands and stripes, machine strings as texture, everything annotated.
Nothing is fetched or generated at runtime; everything the device renders is
baked ahead of time into device/art/.
Defined in runner.py. Eight colours, and that is the whole budget.
| Token | Hex | Role |
|---|---|---|
VOID |
#060806 |
ground |
VOID_2 |
#0C0F0B |
the other half of the dither pair |
GRAPHITE |
#161A16 |
panel fill |
GRAPHITE_2 |
#202620 |
unlit meter segments |
RULE / RULE_HI |
#303828 / #4A5642 |
hairlines, frames, grid |
ACID |
#C6FF1A |
the accent — data, fills, live state |
ACID_D / ACID_XD |
#749812 / #3E520C |
dimmed acid, band tinting |
BONE / BONE_D |
#E8ECE0 / #8C9484 |
prose, secondary readouts |
CYAN / CYAN_D |
#3CDCEB / #1C747E |
one tertiary series (season trace) |
ALERT / ALERT_D |
#FF3A26 |
≥95%, blackout, rate limit |
Rules
- Acid is load-bearing. It marks data, live state and the thing you should press. It is never used because a panel looked empty.
- Flat fills only. No gradients, no glows, no soft shadows, no rounded corners.
- Amber
#FFB028is the only colour not in the token list, used inline for the 75–90% warning step. It is deliberately not promoted to a token — it should stay hard to reach for. - Two accents maximum in one panel: acid + cyan for series separation, acid + alert for danger. Never all three.
Maratype, baked to bitmaps at 10 / 14 / 20 / 32 / 56 px by
tools/bake_font.py. Glyphs are stored as horizontal spans, so drawing is a
few Screen.rect calls per character — no font library on device.
| px | Use |
|---|---|
| 56 | the single most important number on screen (LOAD %), rank glyph |
| 32 | state name, secondary big numbers (season %, core %) |
| 20 | values in lists, tab labels, section headings |
| 14 | panel names, column headings, key/value rows — the floor for anything you must read |
| 10 | technical annotation only, meant to be small and mostly texture |
These are properties of the typeface, not bugs in the baker. All are handled in
bake_font.py; if the font is ever replaced, re-check every one.
- No
%glyph at all. Synthesized on the digit box from a diagonal plus two counters. An earlier version reused the font's own/, which runs the full ascender — that produced a percent taller than the numbers next to it with the counters flung to the corners. X,V,Kare squared forms whose distinguishing notches are one pixel below ~24px.X→H,V→U: "EXTRACT" read as "EHTRACT", "SALVAGE" as "SALUAGE", "BLACKOUT" as "BLACHOUT". All three are redrawn as true diagonals at the font's own stem weight and advance.- No
<or>. Synthesized as chevrons on the digit box. - Caps-only — lowercase codepoints map to the same glyphs. So lowercase in
the UI buys nothing; the one lowercase we use is the
xin "x1.5", which gets a copy of the fixed uppercase.
Stem weight must be measured from a vertical stem (I, else L), never
from the hyphen's height — at 14px the hyphen is 1px tall while stems are 2px
wide, which made the synthesized diagonals a stroke too light.
Synthesized strokes are capped by the room available
(min(stem, (w-1)//2, h//4)): full weight in a 5×7px box just fills the box.
mono=Truefor anything tabular — percentages, credits, window numbers. Maratype is proportional (M=6px,I=2px at 10px) so columns will not line up otherwise.track=1..2on small caps text. The face is condensed and sets tight.- Prose is uppercase. The reference sets prose lowercase, but at 10–14px on a 3.5" panel the caps-only font is more legible, so legibility wins.
640×480, 8px margin, everything on a 32px background grid.
y 4 ┌ 150px ─────┬ 5 tabs x 72 ─────────────┬ clock ──┐ header, h 26
y 30 └────────────┴──────────────────────────┴─────────┘
y 36 ┌ body ───────────────────────────────────────────┐
y 446 └─────────────────────────────────────────────────┘
y 452 ticker lane ────────────────────────┊ control hints footer
- 1px
RULEframe,GRAPHITEfill, no rounded corners. - Top-right corner clipped on a 10px diagonal, cut back to the background template — not to flat black — so the dither reads continuously through the notch.
- Label sits in a solid acid tab overhanging the top edge by 2px. Panel
content must therefore start at
y+16or lower. invert_plate()is the acid slab with the label knocked out in void — the poster "LAUNCH" move. It takes over wherever it lands: one per screen, on the thing you press.
Segmented blocks with gaps, never a smooth fill — the segmentation is what
makes them read as instrumentation. Unlit segments are tinted by band
(LOAD_ZONES) so the productive stretch is visible before you reach it, and
the 95% blackout line is a bone marker.
Slow, mechanical, and mostly not moving. 20fps cap.
- Never move anything horizontally as decoration. These panels have no vsync and horizontal drift reads as tearing.
- Transitions are hard cuts plus a 3-frame band-displacement glitch
(
glitch()), never fades. - The dither shimmer on the hero (4 baked frames at slightly different thresholds) is the only continuous ambient motion.
- Alarm chrome crawls; it is the one thing allowed to draw attention.
- The extract stamp slams down over ~0.15s then holds.
- The larvae undulate. A travelling sine along the body, and the rate is data: the live specimen's speed and amplitude both scale with load, so a hot window visibly writhes and a cold one barely moves. Older specimens in the tray settle to a slow idle.
The devices the style is built from, and where each lives.
| Motif | Where |
|---|---|
| Bayer dither field over near-black | background template, built once, memcpy per frame |
| Halftone/dithered photographic figure | hero portrait |
| Acid slab with knocked-out type | invert_plate() — active tab, EXTRACT, rank |
| Segmented instrument meters | bar(), vbar() |
| Diagonal hazard stripes | hazard() — blackout, rate limit, alarm edges |
| Technical glyph marks (barcode, reticle, checker, X) | glyph() / glyph_row(), plus the baked sigil strip |
| Terminal type-on boot | draw_boot() — "INIT / CORE REVIEW", a poster device |
| Corner registration ticks | background template, plate() notch, rank stamp |
| Coarse 32px grid | background template |
| Machine strings as texture | header identity cell, ticker |
| Seismic column plot | TRACE tab |
| Annotated callouts with leader lines over imagery | hero portrait, LINK specimen, attract mode |
| Vertical type running up an edge | attract mode, RUN tab edge |
| Ribbed specimen larvae | worm() — the DEBRIEF specimen tray and the live one on the RUN strip |
A parallel row of ribbed acid-green grubs on a ribbed bed, glossy and
translucent, each stencilled with its own serial (133083, 133084, …).
They read as a specimen catalogue, which is what a run history is — so
worm() is not decoration, it is the log. Form notes that matter for
redrawing it:
- roughly 8:1, fat through the middle, rounded at both ends. An early pass stretched one body across the full panel width and it became a flat ribbon with no readable taper.
- ~13 ribs, 1px gap between them; the gap is what makes it read as segmented.
- a brighter top third for the gloss. Body and gloss come from
worm_tones(), at 55% and 100% of the base colour — dividing the base by three made them look like wires. - the substrate goes only under an occupied slot. Drawn across the whole panel it read as a barcode field rather than a bed.
A green-blue organic growth on a dark plinth, slowly brightening over
seconds with a reflection beneath it. The interesting part is purely the
timing: a very slow luminance ramp with nothing else moving. Hosted by the
boot sequence: three baked exposure steps (bloom_f0..f2.raw), stepped
through at 0.2s / 1.0s / 1.7s. The ramp lives in dither density — the
.raw format carries no brightness axis — and the reflection is part of the
bake, not a runtime effect.
- Add a ninth colour, a gradient, a glow, or a rounded corner.
- Put text below 14px that the player actually has to read.
- Animate anything horizontally for decoration.
- Use acid on something that is not data, state, or a control.
- Reach for
ALERTbelow 90% load. It means this run is in danger, nothing else. - Add a second
invert_plate()to a screen that already has one.
The art ships pre-baked in device/art/ — the .raw files are the artwork.
The font and the sound effects rebuild from sources in the repo:
py tools\bake_font.py --preview # -> device/art/maratype.fnt (+ tools/fontcheck.png)
py tools\bake_sfx.py # -> device/art/sfx_*.wav
Preview any screen without hardware:
powershell -File pc\sim-runner.ps1 -Tab log -State inband -Runs 22
powershell -File pc\sim-runner.ps1 -Tab run -State critical
-Runs N fabricates N closed windows — use it to check anything whose layout
depends on history depth (the specimen tray holds 18 and then reports
+N OLDER). -State covers cold / inband / critical / blackout / extracted /
extracting / nodata; -Boot plays the boot sequence.
Always judge a font change from the baked output (--preview re-renders
through the device's own decoder), never from what PIL draws. Every glyph
defect in section 3 was invisible in the source render and obvious in the
bake.