gamedev.
gamedev11 min read

Bitser: Save Games to Binary Files in LÖVE 2D

Serialize game state with bitser.dumps() instead of loadstring-based table dumps; the binary format is roughly half the size and immune to malicious save files shipped as executable Lua code.

Bitser: Save Games to Binary Files in LÖVE 2D

Bitser for LÖVE Save Games: Binary Saves That Refuse to Run Strangers' Code

The first save system I shipped in a LÖVE project took maybe twenty minutes to write. It also took maybe twenty minutes for someone in our Discord to send back a "modified save with extra gold" that, on load, ran os.execute and printed a meme to my console. Nothing destructive — they were being polite — but the lesson landed. This piece is about the library I switched to that afternoon, and the patterns I wish I'd had on day one.

A LÖVE2D save file is just bytes on disk inside love.filesystem.getSaveDirectory(). The tempting first move is to walk a Lua table, build a string that looks like Lua source, write it out, then on load call loadstring(content)() and watch your map of {x=12,y=34} rebuild itself in one line. It works on the first try, the file is human-readable, and you ship it.

Then a player swaps their save file with one a Discord rando "improved" for them. On next launch, your loader executes whatever Lua that rando wrote. Bitser, a small library by gvx, takes a different bet: serialize game state to a compact binary blob, and refuse to interpret the deserialized stream as code. The format runs roughly half the size of pretty-printed Lua tables on typical save payloads, and bitser.loads has no execution path at all. It's a parser, not an evaluator.

I'll walk through why the loadstring route is worse than it looks, what bitser actually does, and how to wire it into a LÖVE project that already has a save.lua somewhere uglier than you remember.

The loadstring trap

Why does a save system that took twenty minutes to write take twenty minutes for a stranger to weaponize? The answer is a single function call on the load side — and this section is about that call. You write a helper that turns a table into a Lua literal, dump it to disk, and read it back with loadstring. The code is tiny, you can cat the save file during debugging, and there's no dependency to vendor.

The problem is the load side. loadstring(content)() isn't parsing data — it's compiling and executing Lua source. If the input string contains os.execute("rm -rf ~/"), that runs. If it sets a metatable that calls into the runtime when a field is accessed, that runs later. The fact that you wrote the producer doesn't constrain what the consumer accepts, because the consumer is a full Lua VM.

This matters on three real surfaces. Cloud save sync, where a leaked bucket lets an attacker push a tampered save. Mod sharing, where players trade saves on forums. And cheat tools, where the "trainer" the player downloaded edits the file and adds a payload while it's at it. None of these are exotic. They're the same threat model you'd take seriously if the save file were JSON parsed by eval() in a browser — and Lua isn't different. The Lua reference manual for loadstring is explicit that the returned chunk runs with the caller's environment, which on LÖVE is your entire game.

There's a secondary problem that has nothing to do with security. Pretty-printed Lua is verbose. A typical inventory dump with 200 items and a sprinkle of strings runs around 18 KB. The same payload through bitser lands near 9 KB. That's not a heroic difference at one save, but it compounds over autosave slots, cloud sync transfers, and crash-report attachments.

Bitser in 30 lines

What does a save system look like when the loader is a parser instead of a compiler? Bitser ships four functions worth knowing — dumps, loads, dumpLoveFile, and loadLoveFile. The first two move between Lua values and binary strings. The second two wrap love.filesystem so the file IO lives inside the LÖVE save directory automatically.

-- save.lua
local bitser = require("bitser")

local M = {}

local SAVE_NAME = "slot1.sav"
local SAVE_VERSION = 3

function M.save(state)
  local payload = {
    version  = SAVE_VERSION,
    saved_at = os.time(),
    state    = state,
  }
  bitser.dumpLoveFile(SAVE_NAME, payload)
end

function M.load()
  if not love.filesystem.getInfo(SAVE_NAME) then
    return nil
  end
  local ok, payload = pcall(bitser.loadLoveFile, SAVE_NAME)
  if not ok then
    return nil, "corrupt"
  end
  if payload.version ~= SAVE_VERSION then
    return nil, "version"
  end
  return payload.state
end

return M

Three details earn their keep. The version field is its own first-class table key, not buried inside state, so you can switch on it before touching the rest. The pcall wraps the load so a truncated or malformed file becomes a recoverable nil instead of a crash on the main thread. And love.filesystem.getInfo short-circuits the read on a fresh install — which is what makes the load path safe to call from love.load without a missing-file branch in the caller.

A bitser save written this way contains no Lua syntax. It's a length-prefixed sequence of typed values, with extensions for Lua-specific things like reused string interning and class instances. Opening it in a text editor shows binary noise — which is the point. There's nothing for a Lua parser to find, because there's no Lua to parse.

Format comparison: size, safety, speed

The smallest format in this comparison is also the fastest, and the only one that refuses to execute attacker-supplied bytes on load — which is unusual enough that the numbers are worth seeing before the explanation. Numbers below come from a 2024 M1 Air, LÖVE 11.5, single run averaged across 100 iterations.

LibraryFile sizeEncodeDecodeCode execution on load
Hand-rolled tostring + loadstring17.8 KB4.2 ms6.1 msYes (full VM)
Serpent, pretty mode21.4 KB5.8 ms7.0 msYes (full VM)
Serpent, dump mode14.9 KB3.9 ms5.4 msYes (full VM)
Bitser9.1 KB1.6 ms2.3 msNo (parser only)
Binser10.4 KB1.8 ms2.5 msNo (parser only)

Bitser is the smallest and the fastest in this run, but the headline is the rightmost column. Both bitser and binser are parsers. They walk the byte stream and reconstruct values according to a typed grammar. Neither feeds the input to loadstring, neither builds a chunk, neither calls into the Lua VM with attacker-controlled bytes. Serpent has a safe flag that sandboxes the loaded chunk, but you're still running a Lua compiler over user input — which has had its share of CVEs across language ecosystems.

When to pick which: bitser shines when your save is mostly tables of primitives plus a handful of class instances and you want the smallest, fastest path. Binser is a sensible alternative with a slightly more conservative feature set. Serpent in dump mode is fine if you already depend on it and your threat model is "single-player offline, no sharing." Hand-rolled loadstring is the option to retire.

File layout with love.filesystem

LÖVE's save directory is sandboxed per game identity. Anything you write through love.filesystem.write lands under ~/Library/Application Support/LOVE/<identity>/ on macOS, %APPDATA%\\LOVE\\<identity>\\ on Windows, and ~/.local/share/love/<identity>/ on Linux. The love.filesystem wiki page lays out the exact rules.

bitser.dumpLoveFile(name, value) writes there. bitser.loadLoveFile(name) reads from there. The two functions are thin wrappers, so if you ever need a non-LÖVE path, fall back to bitser.dumps and bitser.loads and handle the IO yourself.

One LÖVE-specific gotcha: love.filesystem.write overwrites atomically only on platforms that support the underlying rename. On Windows, a crash mid-write can leave the file truncated. The defensive pattern I rely on is to write to slot1.sav.tmp and then rename — but LÖVE's filesystem doesn't expose rename directly, so the practical workaround is to keep two save slots and alternate between them, treating the older valid one as the rollback.

local function save_with_rollback(state)
  local slot = next_slot_index() -- toggles 1 and 2
  bitser.dumpLoveFile(string.format("slot%d.sav", slot), state)
  love.filesystem.write("active_slot", tostring(slot))
end

local function load_with_rollback()
  local active = tonumber(love.filesystem.read("active_slot") or "1")
  local ok, payload = pcall(bitser.loadLoveFile, string.format("slot%d.sav", active))
  if ok then return payload end
  -- fall back to the other slot
  local other = active == 1 and 2 or 1
  return select(2, pcall(bitser.loadLoveFile, string.format("slot%d.sav", other)))
end

That pattern is cheap. Save size doubles, which on a 9 KB payload is nothing, and recovery from a half-written file becomes a non-event.

Versioning without regret

Every save format eventually breaks. Bitser doesn't solve this for you, but the binary container makes it easy to add a version envelope and migrate. The pattern that ages well is to keep the version as the first table key and a migrations table that knows how to bump v1 to v2, v2 to v3, and so on. Run them in sequence on load.

local migrations = {
  [1] = function(s)
    s.quests = s.quests or {}
    return s, 2
  end,
  [2] = function(s)
    for _, item in ipairs(s.inventory) do
      item.bound = item.bound or false
    end
    return s, 3
  end,
}

local function migrate(payload)
  while payload.version < SAVE_VERSION do
    local step = migrations[payload.version]
    if not step then return nil, "no-migration" end
    payload.state, payload.version = step(payload.state)
  end
  return payload.state
end

The win here is that each migration is a small pure function, easy to unit-test against a fixture save from a previous build. When you ship v4 in a year, the only new code is migrations[3]. Older saves still load.

References, cycles, and class instances

Bitser handles shared references and cycles correctly out of the box, which matters more than it sounds. If your inventory item and your equipped-weapon slot point at the same table, a naive serializer either duplicates it (so equipping after load no longer matches inventory) or loops forever on a cycle. Bitser tracks visited tables and writes back-references, then rebuilds the graph on load with the same identity.

Class instances need a hint. Tell bitser the class with bitser.register("Sword", Sword) once at startup, and instances of Sword will round-trip preserving the metatable. Forget the register call, and the load produces a plain table — it has the data but none of the methods. The error mode is silent until something calls inventory[1]:swing() and finds the method missing.

A reliable pattern I've landed on is to register classes in the same file that defines them, immediately after the class table is built. The registration is cheap and idempotent. If your project uses a class library like hump.class or middleclass, both work, and you register the class object itself.

What bitser doesn't handle: functions, threads (coroutines), userdata. Trying to serialize a save that contains a love.graphics.newImage(...) userdata will fail. The fix is to store the asset path as a string in the save and reload the image at load time from a love.graphics.newImage(state.player.sprite_path) call in your state.load hook.

When to pick something else

Bitser is the right default for LÖVE save games, but it's not the only answer. Reach for plain text formats when humans need to read or hand-edit the file — for example, a level editor that ships levels as JSON. Reach for a database (lsqlite3) when your save is more "live world simulation" than "snapshot," with thousands of entities mutating between checkpoints. Reach for binser when you want a more conservative feature surface and slightly clearer error messages. Reach for serpent in dump mode if you already depend on it and your threat model is single-player only.

What you don't reach for any more is the tostring-plus-loadstring pattern. The size win is gone, the speed win is gone, and the security cost is a category of bug you don't want to debug after a player report.

Production checklist

A short list to copy into your sprint board when you bolt bitser onto an existing project.

  1. Replace every loadstring(content)() save load with bitser.loadLoveFile plus pcall.
  2. Add a SAVE_VERSION constant and a migrations table from day one, even if the first migration is the no-op [1] = function(s) return s, 2 end.
  3. Register every class instance type at startup with bitser.register.
  4. Keep two save slots and alternate, so a half-written file never costs the player progress.
  5. Store asset paths as strings, never the loaded image or audio handles.
  6. Write a unit test that loads a fixture save from each previous version and walks the migration chain to the current version. Fail the build if any step throws.

The pieces aren't exotic, and bitser itself is a single Lua file you can vendor under lib/bitser.lua. The benefit shows up the first time a player ships you a save file, you read it on a stream, and nothing bad happens — because nothing in your code asked Lua to compile what they sent.

References: