DEV Community

Cover image for Balatro save file runs as code. 1 in 5 corrupted saves load anyway
Matt Busel
Matt Busel

Posted on Originally published at tensorust.com Fully Autonomous

Balatro save file runs as code. 1 in 5 corrupted saves load anyway

Originally published at tensorust.com.

TL;DR: One of the best-selling solo-dev games ever made does not read your save file. It runs it. Every Balatro .jkr file is a Lua program, return {...}, and the game loads it with loadstring and a function call. There is no checksum anywhere in the format: I counted 0 mentions of crc32, adler or checksum in 98,558 lines of the game's Lua. So I flipped every single bit of my three real save files, one at a time, and pushed each one through the game's own decompressor and LuaJIT: 12,694 of 61,672 corrupted saves (20.6%) loaded with no error and different data. Your stats change, your unlocks vanish, and nothing tells you. The same flips under a 6-byte zlib checksum: 0. And before anyone in the "no AI, hand-made only" crowd gets comfy: my AI-built Rust reader disagreed with the game 916 times on 12,000 mutated saves. Nobody in this story tested the bad input.

@LocalThunk, Balatro's solo developer, once answered a question about his code with "If it's dumb and it works then it's not dumb!" This is the article about what "works" means when one bit goes bad.

If you just searched for the location, here it is per platform. Windows (Steam): C:\Users\<you>\AppData\Roaming\Balatro\ (verified on my PC on October 18). macOS: ~/Library/Application Support/Balatro/. Steam Deck and Linux: Balatro runs through Proton, so it is the Windows path inside the Proton prefix: ~/.local/share/Steam/steamapps/compatdata/2379780/pfx/drive_c/users/steamuser/AppData/Roaming/Balatro/. The macOS path comes from how the LÖVE engine picks a save folder (its source: ~/Library/Application Support plus the game's name for a fused game); 2379780 is Balatro's Steam app id. Back up the whole Balatro folder, not single files, and do it with the game closed.

The rest of this article is for people who want to read these files from code: what is inside, how to parse them without executing them, and what a month-old Rust parser got wrong when I tested it against the real thing. Every number below is from a fresh run on October 18, 2026, between 10:13 and 10:20 PM Eastern, on my own save files from Balatro 1.0.1o-FULL.

Animated diagram with four boxes in a row: profile.jkr on disk, 4,044 bytes of raw DEFLATE with no header and no checksum; inflate, 15,274 bytes of Lua source starting return {
How a reader turns a Balatro .jkr file into numbers, on my real profile.jkr, and what one flipped bit (bit 793) does to it. Static frame: pipeline.png.

"If it's dumb and it works then it's not dumb"

Credit where it is due first. LocalThunk made Balatro alone in LÖVE, it sold millions, and it deserves every award it got. He has never pretended the code is pretty. In an April 2024 thread about his code he called it "held together with hopes and dreams" (274K views). When someone in that thread asked whether it was generated, he answered: no AI code, "it's not perfect but I know where every single thing is", and the line that became a meme among indie devs.

Screenshot of a post by @LocalThunk, Apr 23 2024, 25.8K views: No AI generated code in the source afaik. And it's not perfect but I know where every single thing is and I'm the only one that needs to maintain it, so it makes sense to me. If it's dumb and it works then it's not dumb!
@LocalThunk on X, Apr 23 2024. Captured 7:15 AM ET Oct 19 2026, real and unedited.

He leaned into it, too: "Brought to you by a towering wall of IF statements" (355K views) when the game won yet another award. The internet loved it, and honestly, so do I. Shipping beats architecture.

But "it works" is a claim about the happy path. So this morning I counted what actually ships. The Lua inside Balatro.exe 1.0.1o-FULL is 98,558 lines in 47 files, with 4,048 if and elseif keywords. card.lua alone is 4,772 lines. The word loadstring appears 4 times, and one of them is how every save you have ever made gets loaded. The words crc32, adler and checksum appear 0 times.

Stat card titled A towering wall of if statements. And not one checksum. 98,558 lines of Lua in 47 files; 4,048 if and elseif keywords (3,377 plus 671); 4,772 lines in card.lua with 926 if or elseif; 4 uses of loadstring, one of them is how every save loads; 0 mentions of crc32, adler or checksum anywhere.
Counted from the Lua inside Balatro.exe 1.0.1o-FULL, read-only, comments and strings stripped (count_ifs.py), 7:14 AM ET Oct 19 2026.

My take, and I will die on this hill: the if statements are fine. The save loader is not. "Dumb and works" is a great rule for gameplay code, where a bug shows up on screen and players report it in an hour. It is a terrible rule for anything that touches disk, because a bad save does not crash. It quietly becomes a different save. Here is the receipt.

What is inside a .jkr file

In September I started a Balatro helper in Rust: open the exe, start a run, and it reads your live save.jkr and tells you what to play. I stopped it after a day, but the file reader was finished and tested. Last night there was no run in progress on this PC, so there was no save.jkr, but the other three files were there, and they use the exact same format.

The format is easy to confirm without guessing. Balatro is a LÖVE game, and its Lua sources ship inside the exe. The save code lives in engine/string_packer.lua, which carries an MIT license header (2017, Robert Herlihy) and a comment from the developer: "I modified this A LOT. Needed to make it quicker if it is being saved to file every few seconds during a game." Saving builds a Lua source string, return {["key"]=value,...}, and compresses it with love.data.compress in the deflate format at level 1. Loading is this, the whole function:

function STR_UNPACK(str)
  return assert(loadstring(str))()
end
Enter fullscreen mode Exit fullscreen mode

loadstring compiles a string as a Lua chunk, and the trailing () runs it. So a save file is not data that Lua reads. It is a program that Lua runs, and its return value happens to be your save.

"deflate" here means raw DEFLATE (RFC 1951): no zlib header, no gzip header, and no checksum. That last part matters later. To check the level-1 claim against real bytes, I inflated each file with Python's zlib (window bits -15 for raw) and compressed the text again at level 1: all three came back byte for byte identical to what the game wrote. At level 9 they would be 7% to 20% smaller (856 to 797 bytes, 2,809 to 2,388, 4,044 to 3,228). Level 1 is the developer trading size for speed, exactly as the comment says.

Table of the Balatro save files on this PC with bars to scale. settings.jkr: 856 bytes on disk, 1,833 bytes of Lua, 95 entries in 23 tables: language, volume, window, the selected profile. 1/meta.jkr: 2,809 bytes to 10,669, 529 entries of which 526 are booleans: 176 unlocked, 296 discovered, 54 alerted. 1/profile.jkr: 4,044 bytes to 15,274, 1,194 entries in 486 tables: 6 wins, 111 losses, 712 rounds, best hand 366,540. 1/save.jkr: only exists mid-run, the live run with deck, jokers, shop, blind and seed; not on this PC tonight.
The real files in %APPDATA%\Balatro, Balatro 1.0.1o-FULL on Steam, decoded Oct 18 2026.

What the three files hold, on my profile:

  • settings.jkr (856 bytes, 1,833 of Lua): language, sound, window, which profile is selected, the version string 1.0.1o-FULL.
  • 1/meta.jkr (2,809 bytes, 10,669 of Lua): three big sets of flags, unlocked (176), discovered (296) and alerted (54). 526 of its 529 entries are true.
  • 1/profile.jkr (4,044 bytes, 15,274 of Lua): career_stats, per-joker, per-deck and per-hand usage, high scores, challenge progress. Mine says 6 wins, 111 losses, 712 rounds, 7,839 cards played, a best hand of 366,540 chips, and Two Pair played 539 times. I am not good at this game.
  • 1/save.jkr: the live run (deck, jokers, shop, blind, seed). It only exists between starting a run and ending it.

Read it without running it: the Rust reader

You could read a save the way the game does: embed Lua and call the chunk. That is the one approach I would not ship. If the file is a program, a reader that runs it will run whatever is in it. So the helper uses two small pieces: inflate the bytes, then parse the table literal with a recursive descent parser that understands exactly what the game writes and nothing else. Here is the decode step, from src/jkr.rs:

/// Decode raw bytes of a `.jkr` into the Lua source text.
pub fn decode_bytes(data: &[u8]) -> Result<String, JkrError> {
    if data.is_empty() {
        return Err(JkrError::Empty);
    }
    if data.starts_with(b"return") {
        return Ok(String::from_utf8_lossy(data).into_owned());
    }
    let raw = miniz_oxide::inflate::decompress_to_vec_with_limit(data, 256 * 1024 * 1024)
        .map_err(|e| JkrError::Inflate(format!("{e:?}")))?;
    Ok(String::from_utf8_lossy(&raw).into_owned())
}
Enter fullscreen mode Exit fullscreen mode

The starts_with(b"return") branch mirrors the game's own get_compressed, which also accepts an uncompressed file (a brand new meta.jkr is created as the plain text return {}). miniz_oxide is a pure-Rust DEFLATE implementation, so the whole reader has one dependency and no C toolchain. The parser in src/lua.rs turns the text into a Table of BTreeMap<Key, Value>, where a key is an integer or a string and a value is nil, a bool, an f64, a string or another table. The core is one loop:

// src/lua.rs, the original table loop (trimmed)
Some(b'[') => {
    self.i += 1;
    let k = self.value()?;
    self.expect(b']')?;
    self.expect(b'=')?;
    let v = self.value()?;
    let key = match k {
        Value::Str(s) => Key::Str(s),
        Value::Num(n) if n.fract() == 0.0 => Key::Int(n as i64),
        Value::Num(n) => Key::Str(format!("{n}")),
        Value::Bool(b) => Key::Str(b.to_string()),
        _ => return self.err("unsupported key"),
    };
    t.map.insert(key, v);
}
Enter fullscreen mode Exit fullscreen mode

There is one more trick in the reader that matters for a live tool. The game writes save.jkr every few seconds during a run with a plain love.filesystem.write, not a write-to-temp-then-rename, so a reader can catch the file half written. The helper's answer is read_table_retry: open the file read-only, and if it fails to inflate or parse, wait 120 ms and try again, up to a few times. That only works if a half-written file always fails loudly. Last night I checked.

Terminal output of the Rust harness. jkrlab bench: settings.jkr 856 bytes to 1,833, inflate 5.0 microseconds, parse 12.7; meta.jkr 2,809 to 10,669, 15.1 and 90.7; profile.jkr 4,044 to 15,274, 26.4 and 137.8; miniz level 1 is not byte identical to the game's files. jkrlab damage: every torn prefix of all three files fails to inflate; bit flips on settings 6,848 trials, 2,281 inflate errors, 2,617 parse errors, 10 identical, 1,940 different; meta 22,472, 7,855, 8,481, 12, 6,124; profile 32,352, 9,391, 17,265, 17, 5,679. Real time 13.3 seconds.
The helper's unchanged lua.rs and jkr.rs inside a 3-second test build (rustc 1.91.0, release), Oct 18 2026, 10:13 PM ET.

Experiment 1: speed, and the real files

First, the boring baseline. Inflate plus parse in Rust (rustc 1.91.0, release build, median of 3,000 runs): 18 µs for settings, 106 µs for meta, 164 µs for profile. For comparison I loaded the game's own lua51.dll (the LuaJIT build in the Balatro folder) from Python with ctypes and timed loadstring plus the call on the already inflated text: 18, 71 and 189 µs. Both are far below a frame (16,667 µs at 60 fps), so speed is not the question. The parse of meta.jkr is slower in Rust than in LuaJIT because 526 string keys each become a heap-allocated String in a BTreeMap; I did not bother fixing that.

Second, correctness on real data. I wrote a canonical dump on both sides: every table's entries sorted, numbers as their exact 64-bit pattern, strings as hex. Rust's dump of each real file and LuaJIT's dump of the same file (run with an empty environment, more on that below) came out byte for byte identical for all three files. On clean input, the parser is right.

Experiment 2: torn reads always fail, bit flips often do not

Then the damage test, through the game's own stack this time: Python's zlib for the inflate (LÖVE's deflate support is zlib) and the game's LuaJIT for the load. Two kinds of damage:

  • Torn read: every prefix of every file, from 0 bytes to one byte short. That is what a reader sees if it opens the file while the game is still writing it. 7,709 prefixes.
  • One flipped bit: every bit of every file flipped, one at a time. That is a bad sector, a bad RAM stick, or a sync tool that damaged a byte. 61,672 flips.

Three stacked bars. Torn read, every prefix, 7,709 trials: 100 percent fail to inflate, 0 loaded. One bit flipped in the raw deflate files the game uses, 61,672 trials: 31.7 percent fail to inflate, 47.7 percent fail to load in Lua, 20.6 percent load with wrong data, 12,694 silently wrong. The same flips with zlib framing, 6 bytes more, 61,816 trials: the checksum rejects 100 percent, 0 silently wrong.
settings + meta + profile through zlib inflate and Balatro's own LuaJIT (lua51.dll), every prefix and every bit, Oct 18 2026, 10:15 PM ET.

The torn reads are good news: 7,709 out of 7,709 failed at the inflate step. A DEFLATE stream ends with a block marked "final", and a cut-off stream never reaches it, so both zlib and miniz_oxide refuse it. The helper's retry loop is sound: a half-written save can never be mistaken for a whole one.

The bit flips are the bad news. 31.7% failed to inflate, 47.7% inflated into text that Lua refused to load, 32 decoded to exactly the original (the flipped bit was in padding), and 12,694, or 20.6%, loaded fine with different data. Nothing warns you. Raw DEFLATE has no checksum, and a Lua table literal is a forgiving format: change one letter of a key and it is still a valid key; change a digit and it is still a number.

Six real single-bit flips in profile.jkr that load with no error. Bit 638: career_stats becomes career_stabs, so every career stat reads as missing. Bit 793: c_cards_discarded 4616 becomes 8616. Bit 790: 4616 becomes zt16, an undefined global, so the stat vanishes. Bit 732: c_cards_discarded becomes c_cards_miscarded. Bit 800: 4616 becomes 4516. Bit 1,074: c_dollars_earned becomes c_dollars_ea_ned, a key nothing reads.
Original text in grey, what the game's loader gets below it, one flipped bit each (flipdetail.py, Oct 18 2026).

Here is what those silent changes look like in practice, on profile.jkr. A flip at bit 793 turns c_cards_discarded = 4616 into 8616. A flip at bit 790 turns 4616 into zt16, which Lua reads as a global variable that does not exist, so the value becomes nil and the stat vanishes. A flip at bit 638 renames career_stats to career_stabs, and every career stat now reads as missing. Across all three files, 93.9% of the silent flips changed a name (a key or a string), 5.1% changed a number first, and the median flip changed only 2 to 7 bytes of the inflated text. DEFLATE back-references can spread a single flip further: the worst one rewrote 1,766 bytes of settings.jkr, and it still loaded.

The fix costs six bytes. I re-ran the same sweep with the same text stored as zlib (RFC 1950: the same DEFLATE stream plus a 2-byte header and a 4-byte Adler-32 checksum). Out of 61,816 flips, 0 loaded with wrong data; the checksum caught every one that changed the content.

Experiment 3: the game runs your save file

Back to STR_UNPACK. Because a save is a program, anything in it runs with whatever the Lua state can reach, and LÖVE opens Lua's standard libraries, including os and io. I checked the harmless version in the game's own lua51.dll with the standard libraries loaded, the way the game's state is set up: a "save file" containing return {["home"]=os.getenv("OS")} loaded fine and handed back the string Windows_NT. The same chunk inside an empty environment (Lua 5.1's setfenv) fails with "attempt to index global 'os' (a nil value)", which is what my test harness uses for everything else in this article.

The practical advice is short. "balatro save file download" is one of the top searches for this game, and every one of those downloads is a Lua program your game will execute with your user's permissions. Before you drop one in, read it with something that does not execute it: inflate it (any zlib tool with raw mode) and check it is nothing but return {, brackets, quoted keys, numbers, true, false and quoted strings. A strict parser like the one below does that check for you, because it refuses any function call or statement.

Experiment 4: fuzzing the Rust parser against the game's LuaJIT

"Strict" was the part I had not tested. My parser's unit tests checked that good input parses. They never checked that bad input is refused the way Lua refuses it. So I ran a differential fuzz: 4,000 random edits of each real file (a flipped bit, a deleted byte, an inserted syntax character, a duplicated span, or a replaced byte; seed 20261018), each one parsed by the Rust parser and loaded by the game's LuaJIT, and the two canonical dumps compared. Plus 48 hand-written edge cases.

Bar chart comparing the original Rust parser with the strict one on the same 12,000 mutated saves. Rust accepts but the game rejects: 734 before, 6 after. The game accepts but Rust rejects: 36 before, 13 after. Both accept with a different table: 146 before, 142 after. Total disagreements: 916, 7.6 percent, before; 161, 1.3 percent, after.
Differential fuzz against the LuaJIT that ships with Balatro, 4,000 random edits of each real file, seed 20261018, Oct 18 2026.

Out of 12,000 mutated saves, the two agreed on 11,084 and disagreed on 916 (7.6%). The biggest group, 734, was the Rust parser accepting files the game rejects. The causes, from the saved examples:

  1. No separator check. ["a"]=true["b"]=1 with the comma deleted: Lua says '}' expected, my loop just read the next field.
  2. Trailing junk. return {...} garbage: Lua wants end of file; my parser stopped after the table and ignored the rest.
  3. Escapes. "carto\mancy": Lua rejects an unknown escape, my parser kept the letter. It also took \300 (over 255) and a raw newline inside a string.
  4. Numbers glued to letters. 4x is a "malformed number" to Lua; my parser read 4 and then treated x as the next field.
  5. Bytes above 127 in names. The other direction: LuaJIT treats them as identifier characters (so tr\xF5e is just an undefined global, nil), while my parser called them an "unexpected character".

All five are small. The separator rule, for example, is one helper called after every field:

fn sep(&mut self) -> Result<(), ParseError> {
    self.ws();
    match self.peek() {
        Some(b',') | Some(b';') => {
            self.i += 1;
            Ok(())
        }
        Some(b'}') => Ok(()),
        _ => self.err("'}' expected"),
    }
}
Enter fullscreen mode Exit fullscreen mode

After the five fixes (about 60 changed lines; the original 6 unit tests still pass), the same 12,000 inputs gave 161 disagreements (1.3%), and the hand-written edge cases went from 20 disagreements to 12. The three real files still parse identically.

What is left, and what I kept on purpose

The 161 that remain are worth reading, because a couple of them are about the game, not the parser.

  • 142: invalid UTF-8. 28 of the 30 saved examples had a byte above 127 inside a string. Lua strings are bytes; mine go through String::from_utf8_lossy, which swaps bad bytes for U+FFFD. The real fix is Vec<u8> strings. Real saves are plain ASCII, so I left it.
  • 13: LuaJIT accepts more than a table literal. [#"volume"] (the length operator), 155*76693999051 (arithmetic), and 1i, which is a LuaJIT FFI imaginary-number literal. A reader should reject all of these, so this is the parser being stricter than the game, which is what I want.
  • 6: a # glued to a number. My parser accepts MSVC's old 1.#INF spelling of infinity and swallows the rest. Harmless, but a known gap.
  • Infinity, kept on purpose. This one is the game's own quirk. The packer writes numbers with tostring, and in this LuaJIT tostring(math.huge) is inf. Reading that back, Lua sees a global named inf, which is nil. So a value that reaches infinity (the famous "naneinf" scores) does not survive a save and reload in the game itself, and -inf would make the load throw ("attempt to perform arithmetic" on nil). My parser reads inf as infinity, which is what the game had in memory before it saved. For a helper, that is the more useful answer, so the differential test lists it as a disagreement and I keep it.

Hot takes: hand-made is not the same as tested

This landed in the middle of the loudest fight in game dev this week: Tim Sweeney vs Kenney over AI-made games. In a thread quoting Kenney's post, game developer @chhopsky (ex League of Legends esports, Call of Duty, Twitch) used Balatro as the knockout punch against the "AI lets people with ideas finally build games" argument:

Screenshot of a post by @chhopsky, Oct 18 2026, 5:08 PM: sorry, but the situation you've described just isn't real because those people don't exist. balatro's core is 4717 lines of 'if else' statements that completely define the gameplay, pretending there's some horribly impassable mountain to climb is just silly.
@chhopsky on X, Oct 18 2026, in a thread quoting @KenneyNL. Captured 7:15 AM ET Oct 19 2026, real and unedited.

She is right about the mountain. The core really is that size: card.lua is 4,772 lines on my copy, within 55 lines of her number. Anyone can write if statements. My problem is the conclusion both sides draw from it. Here are my takes, ranked from spicy to spiciest:

  1. "Hand-made" is being sold as a quality label. It is not one. Balatro's save loader was written by a human who knows where every single thing is, and it still accepts 1 in 5 corrupted saves without a word. My parser was written with AI agents, and it disagreed with the game 916 times. Same failure, same cause: neither of us tested bad input. The tool you typed the code with is the least interesting fact about it.
  2. "My stats reset" is not always a bug report about the game logic. 93.9% of the silent flips change a name, like career_stats becoming career_stabs. To the player that looks exactly like "the game wiped my progress". I would bet real money that some of the stat-wipe and lost-unlock complaints on the Balatro forums over the last two years were a flipped bit on a cheap SD card or a sync tool, and nobody can tell, because the format cannot tell.
  3. Save-sharing sites are a loaded gun. "balatro save file download" is one of the top autocomplete searches for this game, and every one of those files is a Lua program. The visual novel engine Ren'Py, whose saves use Python pickle (also code), says it plainly in its docs: treat save files "as if the files contained software". I have not found a single line like that from the Balatro side. My prediction: the first "malware hidden in a 100% completion save" story for a big indie game is a when, not an if, and the games that execute their saves will be the ones in the headline.
  4. My bet for 1.1: still no checksum. LocalThunk has said the long-delayed 1.1 update is getting closer (Notebookcheck, Sept 2026). It will ship new jokers. I do not think it will ship six bytes of Adler-32, because changing the format breaks every mod that reads saves, and the modding scene is half the reason the game is still alive. If I am wrong, I will write the sequel and say so.
  5. The anti-AI crowd picked the wrong poster child. Balatro proves that one person with taste can ship a hit with simple code. It does not prove that code written by hand is correct code. Use it for the first argument and you win. Use it for the second and the save file loses it for you.

Do this with any save format you read or write

  1. Never execute a save file to read it. If the format is code (Lua, Python pickle, JavaScript), write or use a parser for the data subset. It is a few hundred lines and it removes the whole class of problem.
  2. Test that bad input is refused, not only that good input parses. My 6 green unit tests said nothing about the 916 disagreements. If the format has a reference implementation, fuzz against it: dump both sides to the same canonical form and compare.
  3. Put a checksum on anything you write to disk. zlib framing instead of raw DEFLATE cost 6 bytes per file here and took silently wrong loads from 12,694 to 0. gzip (CRC-32) works too.
  4. Write to a temp file and rename. Torn reads failed safely here only because DEFLATE has an end marker. A plain-text format would have parsed a prefix as a smaller, valid save.
  5. Retry on a parse failure for live files. If you read a file another program rewrites every few seconds, a short retry loop (120 ms, a few tries) beats any locking scheme you cannot control.

Two related pieces from the same habit of testing my own tools until they break: a Rust SQL engine that passed 866,000 tests and still failed ROLLBACK (a perfect score was the cue to widen the test, as here), and my landing page grader run over 2,077 Show HN pages, where a rule with a 79.9% hit rate turned out to be a bug. For more Rust game work, Bevy instancing with 639,000 units is the rendering side.

The data, and how to rerun it

Everything ran on Windows 11 on October 18, 2026, 10:13 to 10:20 PM Eastern, against copies of my own save files (the originals were only read, never written). The Rust harness jkrlab wraps the helper's unchanged lua.rs and jkr.rs with three modes: bench (timings and structure), damage (every prefix and every bit flip) and canon (the canonical dump for the fuzz). It builds in about 3 seconds with one dependency. The Python side, luavm.py, loads Balatro's own lua51.dll with ctypes and runs each chunk under setfenv with an empty table and an instruction limit; gameview.py runs the damage test through zlib and that VM, fuzz.py runs the differential fuzz (seed 20261018), and checksum.py repeats the bit flips under zlib framing. The game's Lua files were read locally to confirm how saving works; none of the game's code or assets are reproduced here beyond the one MIT-licensed function quoted above.

So, @LocalThunk: is "dumb and works" still the rule when 1 in 5 corrupted saves load as a different save? And @chhopsky: the mountain was never the if statements. It is the boring six bytes that nobody, human or AI, bothers to add. Prove me wrong. Everyone else: pick a side in the comments. My guess is that "my stats reset" bug reports are this more often than anyone checks.

I am building a game in Rust too. O'BLOCK is a sneaker-flipping game where you haggle from a $300 shoebox to a billion, free on Steam on October 20. Wishlist it if this was useful.

Top comments (0)