CRT Dreams: How AI Agents Backported Retro Effects Across Four Games Simultaneously
There’s a moment in every retro-inspired game where you see a scanline flicker across the screen and think, “that feels right.” The CRT aesthetic isn’t just nostalgia — it’s a design language. Soft vignettes darkening the edges. Faint horizontal lines breaking the pixel-perfect clarity into something warmer. Chromatic aberration splitting light at the margins like a tired tube monitor.
In the Dark Factory, we didn’t implement these effects once. We implemented them in one game, then autonomous agents backported them across all four — Polybreak, Chronostone, Voidrunner, and Dreadnought — in the same sprint. Here’s how that worked, and what it taught us about cross-game polish at scale.
The Problem With Polish
Polish is where solo developers die. You spend two hours tuning a screen shake, get it feeling right in one game, and realize you have three more codebases that need the same treatment. Multiply that by every small effect — vignettes, glow, trails, scanlines — and you’re looking at weeks of copy-paste-and-tweak work.
The Dark Factory runs on a monorepo architecture. All four Love2D games live under one umbrella repo, now at 396 commits. When an agent implements a feature in Voidrunner, other agents can see it. More importantly, they can backport it.
No Shaders Required
Here’s the technical surprise: none of these CRT effects use GPU shaders. No .glsl files, no fragment programs, no shader compilation step. Everything is pure Lua procedural rendering using Love2D’s built-in draw calls.
Why? Three reasons:
- Zero external dependencies. No shader extensions to bundle for Steam distribution.
- Portability. CPU-based drawing works identically across every platform Love2D supports.
- Simplicity. An AI agent can reason about a
forloop drawing rectangles far more reliably than GLSL uniform bindings.
Each game implements its effects through a shared gfx.lua module — a library of procedural post-processing functions that any game state can call.
Scanlines: The One-Liner That Changes Everything
The scanline implementation is deceptively simple:
function Gfx.scanlines(w, h, alpha, spacing)
alpha = alpha or 0.04
spacing = spacing or 3
love.graphics.setColor(0, 0, 0, alpha)
for y = 0, h, spacing do
love.graphics.rectangle("fill", 0, y, w, 1)
end
end
That’s it. Horizontal black rectangles, one pixel tall, spaced every three pixels, drawn at 3-4% opacity over the entire screen. It runs after all game content is rendered but before the canvas is scaled to the display.
The subtlety matters. At alpha = 0.03, you barely notice the lines consciously — but your brain registers the texture. It reads as “CRT monitor” without screaming it. Push the alpha to 0.1 and it looks like a Instagram filter. The agents settled on 0.03 across all four games after testing variations.
Vignette: Quadratic Falloff in 12 Steps
The vignette is more sophisticated. Instead of a radial gradient (which Love2D doesn’t natively support without shaders), the implementation uses 12 concentric rectangles with a quadratic alpha falloff:
function Gfx.vignette(w, h, intensity)
intensity = intensity or 0.4
local cx, cy = w / 2, h / 2
local maxDist = math.sqrt(cx * cx + cy * cy)
local steps = 12
for i = steps, 1, -1 do
local frac = i / steps
local r = maxDist * frac
local a = intensity * (1 - frac) * (1 - frac)
love.graphics.setColor(0, 0, 0, a)
love.graphics.rectangle("fill",
cx - r, cy - r, r * 2, r * 2)
end
end
The key is the alpha formula: intensity * (1 - frac)^2. That squared term creates a parabolic curve — the edges darken sharply, but the center stays clean. Twelve steps is enough to look smooth at game resolution without burning CPU cycles.
Each game tunes intensity differently. Voidrunner runs at 0.35 for a subtle space-cockpit feel. Polybreak uses 0.4 for classic arcade framing. Dreadnought cranks it to 0.45-0.5 because dark corridors need that oppressive edge darkness.
Chromatic Aberration Without Shaders
Traditional chromatic aberration shifts RGB channels in a fragment shader. Without shaders, the Dark Factory fakes it — and the result is indistinguishable.
For title screens, the trick is rendering the same text three times with offset positions and isolated color channels:
-- Red channel, shifted left
love.graphics.setColor(1, 0.1, 0.1, 0.3)
love.graphics.printf("DREADNOUGHT", offsetX - 2, titleY, sw, "center")
-- Cyan channel, shifted right
love.graphics.setColor(0.1, 0.9, 1, 0.3)
love.graphics.printf("DREADNOUGHT", offsetX + 2, titleY, sw, "center")
Two-pixel offset. 30% alpha. Red left, cyan right — the classic CRT color fringing pattern. The normal white text renders on top, and your eye composites it into a subtle color split at the edges.
For gameplay, Dreadnought uses dynamic edge bleed that scales with screen distortion intensity:
if distortion > 0.15 then
local bleedWidth = distortion * 6
love.graphics.setColor(0.8, 0, 0, distortion * 0.1)
love.graphics.rectangle("fill", 0, 0, bleedWidth, sh)
love.graphics.setColor(0, 0.7, 0.8, distortion * 0.1)
love.graphics.rectangle("fill", sw - bleedWidth, 0, bleedWidth, sh)
end
The bleed width scales linearly with distortion, so damage hits make the edges flare with color — a CRT monitor under electromagnetic stress.
The Rendering Pipeline
Every game follows the same draw order, enforced by convention across the codebase:
- Set up canvas — optional chromatic aberration buffer
- Push transform — viewport offset and scale
- Apply screen shake — procedural translation from
Gfx.getShakeOffset() - Draw game content — backgrounds, entities, UI
- Post-process — scanlines, then vignette, then dynamic effects
- Pop transform — restore coordinate space
- Composite — render chromatic aberration if active
Steps 5-7 are where the CRT magic happens. Scanlines go first because they’re the most subtle overlay. Vignette goes second to darken edges over the scanlines. Dynamic effects (damage vignettes, distortion) layer on top of everything.
The Backport: One Sprint, Four Games
The initial CRT implementation happened in Voidrunner — the shmup was the most visually complete game, so it was the natural testbed. Once scanlines and vignette were tuned, an agent created the backport commit (2ac3217): feat: backport vignette, scanlines, glowLine, panel, trail.
That single commit carried the entire gfx.lua module to Polybreak, Chronostone, and Dreadnought. But backporting isn’t copy-paste. Each game needed tuning:
- Polybreak adjusted vignette intensity upward — the neon brick colors looked washed out at Voidrunner’s 0.35 setting
- Chronostone needed context-aware scanlines — the RPG’s text-heavy menus looked bad with scanlines over dialogue boxes
- Dreadnought received exclusive extras: screen distortion with chromatic edge bleed and a CRT toggle in the settings menu, because its horror aesthetic demanded the strongest retro treatment
The agents handled all of this autonomously. They could see the reference implementation, understand the design intent, and adapt per-game.
Attract Mode: Completing the Arcade Fantasy
No arcade cabinet is complete without a demo loop. The latest addition across all four games is an attract/demo mode that triggers after 10 seconds of idle time on the menu screen.
Each game implements its own AI bot:
- Polybreak’s demo bot tracks the ball with an AI paddle at 85% speed, with a 15% chance of intentional miss to keep things interesting
- Dreadnought’s bot navigates a mini-map with waypoint pathing, spawning aliens that follow patrol routes — an atmospheric preview of the dungeon crawl
- Voidrunner and Chronostone run simplified gameplay loops that showcase their core mechanics
The attract mode triggers via a simple idle timer (DEMO_IDLE_TIME = 10). Any input cancels it and returns to the menu. It’s the finishing touch that transforms these from “games” into “arcade experiences.”
What 396 Commits of Autonomous Polish Looks Like
The CRT sprint is one example of a pattern that repeats across the Dark Factory: implement, validate, backport, tune. The monorepo makes cross-game visibility automatic. The agent architecture makes backporting cheap.
Some numbers from the CRT and polish work:
| Effect | Lines of Code | Draw Calls/Frame | CPU Cost |
|---|---|---|---|
| Scanlines | 8 | ~107 rects | Negligible |
| Vignette | 14 | 12 rects | Negligible |
| Chromatic aberration | 12 | 2-4 draws | Negligible |
| Glow (per element) | 10 | 3-8 rings | Light |
| Screen shake | 6 | 1 translate | Zero |
Total post-processing overhead: effectively zero. Every effect is a handful of love.graphics.rectangle calls with low-alpha black. The CPU doesn’t notice.
The Takeaway
CRT effects are solved problems in shader-land. Every game engine has a post-processing stack. But implementing them as pure procedural Lua — no shaders, no dependencies, no compilation — makes them trivially portable, trivially debuggable, and trivially backportable by autonomous agents.
The Dark Factory didn’t need a graphics programmer. It needed agents that could read a for loop, understand what “scanlines at 3% opacity” means visually, and replicate that intent across four different game architectures. That’s the real story: not the effects themselves, but the fact that AI agents can develop visual taste and apply it consistently at scale.
396 commits. Four games. One aesthetic. Zero shaders.