
A few weeks ago, I wondered: what would it take to run a full commercial game in a terminal? Not a text-based approximation — the actual game, with real graphics, shaders,
and UI.
The answer turned out to be ~9,800 lines of Rust.
—
The idea
Balatro is built on LÖVE2D, a Lua game framework. The game’s logic is entirely in Lua; LÖVE2D provides the graphics, audio, and input APIs. My idea: reimplement those APIs in
Rust, but instead of rendering to a GPU-backed window, render to a terminal.
—
Architecture
The project has three crates:
love-terminal: The binary — CLI parsing, terminal setup, game loop.
love-api: The core — implements ˷80 LÖVE2D API functions. graphics.rs alone is 3,400+
lines covering a full software rasterizer: anti-aliased rectangles, ellipses, polygons, thick
lines, sprite rendering with bilinear filtering, TTF text via fontdue, transform stack, canvas
system, stencil buffer, and blend modes.
sprite-to-text: The renderer — takes the RGBA pixel buffer and converts it to terminal
output.
—
Three ways to render pixels in a terminal
- Sixel graphics (best quality) The Sixel protocol, dating back to DEC terminals in the 1980s, encodes actual pixel data inline in the terminal stream. Modern terminals (Windows Terminal 1.22+, WezTerm, foot, kitty, mlterm) support it. The internal canvas can be 700×350+ pixels. I control the resolution via TUI_PIXELBUDGET — the canvas auto-scales to stay within a pixel budget. At 250K pixels (default), I get ~707×354 at 50-60 FPS on CPU. Each frame is quantized to 256 colors.
- Unicode octant characters Unicode 13.0 added octant characters (U+1FB00–U+1FB3B) that divide each cell into a 2×4 grid. Each of the 8 sub-cells can be on or off, giving 256 possible patterns per cell. I pair each octant pattern with a foreground and background color, choosing the combination that minimizes error via gamma-correct downsampling. The result: 2×4 sub- pixel resolution per cell, which is dramatically better than half-blocks. Requires Cascadia Code 2404.23+ (or another font with octant support).
- Half-block fallback The classic approach: ▀ (U+2580) with the top pixel as foreground, bottom pixel as background. 2× vertical resolution. Works on any terminal with 24-bit color. —
Shader emulation
Balatro uses several GLSL shaders for visual effects. Since there’s no GPU, every shader is emulated per-pixel in Rust. Some highlights:
The CRT shader extracts bright pixels, applies a 5-tap Gaussian blur for bloom, then adjusts github.com/4RH1T3CT0R7/balatro-port-tui contrast and adds a vignette.
The holographic shader combines an HSL rainbow shift with a hexagonal grid pattern and a noise field.
The polychrome shader uses animated noise to rotate hues in HSL space with boosted saturation.
There are 11 shaders total, all ported from the original GLSL.
—
Compatibility tricks
A few things needed special handling:
love.system.getOS() returns “Linux” to skip Steam initialization. A require "luasteam" stub returns a dummy module. The bit library (present in LuaJIT but missing in Lua 5.1) is implemented in Rust. Balatro’s custom love.run() returns a per-frame closure instead of using standard callbacks.
And keyboard events are mapped to gamepad events, since Balatro’s UI is designed around gamepad input.
—
Try it yourself
You need a copy of Balatro. The engine reads game files from Balatro.exe (which is a zip archive).
cargo build --release && cargo run --release -- "path/to/Balatro.exe"
GitHub: https://github.com/4RH1T3CT0R7/balatro-port-tui
Free and open-source under Apache 2.0. Feedback welcome!

Top comments (0)