Imagine opening Balatro after a crash and finding your career stats gone, your unlocks missing, and no error message anywhere. An independent tester decided to measure that shape of problem on his own files, and his result is stranger than a crash. He flipped one bit at a time through his own three real Balatro save files, and about one in five of the damaged files still loaded, quietly, with different numbers inside. This article reports his third-party test only. It is not a statement from Balatro developer LocalThunk, and it is not an official postmortem.
At a glance
The source: an independent third-party test by Matt Busel, published on DEV on October 19, 2026, originally on tensorust.com, run on his own saves from Balatro 1.0.1o-FULL. It is not a LocalThunk statement and not an official postmortem.
The framing: this article reports what one tester measured on his own files, then turns the result into save practices for your own project. It makes no claim about Balatro players in general.
What the test found: every .jkr file is a Lua table loaded with loadstring, compressed as raw DEFLATE with no checksum, and 12,694 of 61,672 bit-flipped files loaded with different data.
The second finding: a save that is code runs with whatever the Lua state can reach, so the safe pattern is to parse the data subset and never execute a save to read it.
Not claimed here: no official response, no fix, no malware incident, and no estimate of how many players were affected, because the opened test reports none of these. The author's guesses about a future update are left out.
Last verified: October 30, 2026.
The save file is a program, not a data file
Balatro is a poker roguelike by solo developer LocalThunk, built in the LOVE engine, and its save files end in .jkr. In this independent test, the author worked from his own saves from Balatro version 1.0.1o-FULL on Steam, decoded on October 18,
2026. He reports that saving builds a Lua source string shaped like return followed by a table of keys and values, compressed with love.data.compress in the deflate format at level
1. Loading, he writes, runs the whole thing through a small function named STR_UNPACK built on loadstring, which compiles a string as a Lua chunk and then runs it. A save file, in other words, is not data that Lua reads. It is a program that Lua runs, and its return value happens to be the save. The compressed form is raw DEFLATE, with no zlib header, no gzip header, and no checksum. The author searched the game's Lua, 98,558 lines in 47 files, and counted zero mentions of crc32, adler, or checksum. His three files were small: settings.jkr at 856 bytes, meta.jkr at 2,809 bytes, and profile.jkr at 4,044 bytes on disk.
Then came the damage test, run through the game's own decompressor and LuaJIT. First, the author cut every file short, prefix by prefix, 7,709 torn reads in total, to mimic a reader opening a file while the game was still writing it. Every one of them failed at the inflate step, because a cut-off DEFLATE stream never reaches its final block marker. A half-written file, in this test, could never be mistaken for a whole one. The bit-flip sweep was the bad news. Flipping every single bit of the three files, one at a time, produced 61,672 corrupted files. About 31.7 percent failed to inflate, about 47.7 percent inflated into text that Lua refused to load, and 32 decoded back to exactly the original, because the flipped bit sat in padding. The remaining 12,694, or 20.6 percent, loaded with no error and different data. Nothing warned the player. The author repeated the same sweep with the same text stored under zlib framing, which adds a 2-byte header and a 4-byte Adler-32 checksum, six bytes in total. Out of 61,816 flips, zero loaded with wrong data.
Why a flipped bit rewrites your stats instead of failing
The reason is that a Lua table literal is a forgiving format. Change one letter of a key and it is still a valid key. Change one digit and it is still a number. Across all three files, the author reports that 93.9 percent of the silent flips changed a name first, and 5.1 percent changed a number first, while the median flip changed only 2 to 7 bytes of the inflated text. His examples from profile.jkr make the damage concrete. A flip at bit 638 renamed career_stats to career_stabs, so every career stat read as missing. A flip at bit 793 turned a discards count of 4616 into
8616. A flip at bit 790 turned 4616 into zt16, which Lua reads as a global variable that does not exist, so the value became nil and the stat vanished. One flip renamed a dollars-earned key so that nothing in the game reads it anymore. To a player, that looks exactly like the game wiped progress. In this test, though, no progress was wiped by game logic. The file itself had changed, and the format had no way to notice.
There is a second finding in the same test, and it is about reading saves rather than corrupting them. Because a save is a program, anything in it runs with whatever the Lua state can reach, and the author reports that the LOVE engine opens Lua's standard libraries, including os and io. In his check with the game's own Lua build and the standard libraries loaded, a test chunk that read an environment value loaded fine and handed the value back. The same chunk inside an empty environment failed. This article draws no conclusion about any real downloaded file, and it does not claim that any player has been harmed this way. The transferable lesson is narrower: never execute a save file to read it. The author's own reader inflates the bytes and parses only the table literal with a strict parser that refuses function calls and statements, and that shape of reader is the safe pattern for your own project's tools. A downloaded save for any game that loads saves this way deserves the same caution: do not drop an untrusted file into a game and let the game run it.
What to reuse
The lesson for your own project is boring in the best way. A save format needs a way to know it is damaged, a way to fail loudly, and a reader that never runs what it reads. The author's checksum comparison puts a price on the first part: six bytes of zlib framing took silently wrong loads from 12,694 to zero in his sweep. His torn-read result puts a condition on the second part: a format with an end marker fails loudly when cut short, while a plain-text format could parse a prefix as a smaller valid save. His fuzzing result covers the third part: a parser tested only on good input disagreed with the game's loader 916 times out of 12,000 mutated saves, and only tightened checks, separator rules, escapes, and trailing-junk rejection brought that down to
161. Test the bad input, not just the good input.
What to do Monday morning
Back up before you touch anything. Copy your own project's save files to a separate backup before editing or testing, and never work on the only copy.
Tag every save you write. Add a checksum and a version tag to your own format, so a damaged file fails loudly instead of loading as a different save.
Write to a temp file and rename. Never overwrite a live save in place, so a crash mid-write leaves the last good file intact.
Parse, never execute. Read your own saves with a strict parser for the data subset only, and refuse function calls, statements, and trailing junk.
Test the bad input. Flip bits, cut files short, and feed malformed text to your reader, and require it to reject what it should reject.
Never hand-edit a live save. If you must inspect a file, work on a copy with the game or tool closed, and keep the original untouched.

