DEV Community

Cover image for How I Reimplemented LÖVE2D in Rust to Play Balatro in a Terminal
Artem Lytkin
Artem Lytkin

Posted on

How I Reimplemented LÖVE2D in Rust to Play Balatro in a Terminal


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

  1. 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.
  2. 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).
  3. 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)