gamedev.
gamedev6 min read

Aseprite to LÖVE: A Spritesheet Pipeline That Survives Iteration

A repeatable Aseprite spritesheet export pipeline for LÖVE games, with JSON metadata parsing, anim8 integration, and the trade-offs between packed atlases and grid sheets.

Aseprite to L\u00d6VE: A Spritesheet Pipeline That Survives Iteration

Most L\u00d6VE tutorials hand-wave the art pipeline. They tell you to "export a spritesheet from Aseprite" and skip to drawing rectangles with love.graphics.draw. That works for a single idle animation. It collapses the moment you have twelve characters, each with idle, walk, attack, and hurt states, and you discover that adding one new frame to the player sprite has shifted every quad offset in your code.

This piece walks through a spritesheet export pipeline that survives iteration. The pieces are Aseprite's CLI export, a JSON metadata sidecar, a Lua loader that consumes that JSON, and anim8 for playback. The goal is a pipeline where adding frames to a sprite requires zero code changes.

Why metadata-driven beats hardcoded grids

The naive approach is a uniform grid: every frame is 32\u00d732, every animation is N frames at fixed positions, and your Lua code calls love.graphics.newQuad(col * 32, row * 32, 32, 32, sheet) with the indices baked in. It works on day one. It breaks on day five when you decide the attack swing needs an extra wind-up frame.

A metadata-driven pipeline flips the contract. Aseprite exports both a PNG and a JSON file. The JSON describes every frame's pixel rectangle, every tag (animation name) and its frame range, and durations per frame. Your Lua loader reads that JSON once at boot and constructs quads dynamically. When the artist adds a frame in Aseprite, they re-export, and the game picks it up \u2014 no Lua edits required.

The trade-off is honest. Hardcoded grids are 5 lines of Lua. The metadata pipeline is 50 lines plus a JSON parser. For a game jam, hardcoded wins. For anything you'll iterate on for weeks, the metadata pipeline pays for itself the first time an artist asks "can we make the attack 3 frames longer?" and you say "sure, just re-export."

The Aseprite CLI export

Aseprite ships a command-line interface that's stable across versions and scriptable from a Makefile or build step. The invocation that produces both a packed PNG and a JSON sidecar:

aseprite -b player.aseprite \
  --sheet build/player.png \
  --data build/player.json \
  --format json-array \
  --sheet-pack \
  --list-tags

The flags matter:

  • -b is batch mode (no UI). Required for headless builds.
  • --sheet writes the packed PNG.
  • --data writes the JSON sidecar.
  • --format json-array produces an array of frames rather than a frame-name-indexed object. Easier to iterate over in Lua.
  • --sheet-pack runs the rectangle packer instead of laying frames out on a fixed grid. Smaller textures, fewer wasted pixels.
  • --list-tags includes animation tags (the named ranges you set in Aseprite's timeline) in the JSON output. Without this flag, you get frames but no idea which ones form the "walk" animation.

A useful refinement: add --filename-format '{tag}_{tagframe}' if you want frame names that double as documentation. Or skip it \u2014 the array index is fine since anim8 works on contiguous quad ranges.

The JSON shape

Aseprite's JSON output looks like this (trimmed for brevity):

{
  "frames": [
    {
      "filename": "0",
      "frame": { "x": 0, "y": 0, "w": 32, "h": 32 },
      "duration": 100
    },
    {
      "filename": "1",
      "frame": { "x": 32, "y": 0, "w": 32, "h": 32 },
      "duration": 100
    }
  ],
  "meta": {
    "size": { "w": 128, "h": 128 },
    "frameTags": [
      { "name": "idle", "from": 0, "to": 3, "direction": "forward" },
      { "name": "walk", "from": 4, "to": 11, "direction": "forward" }
    ]
  }
}

Two things to notice. First, frame.x and frame.y are pixel offsets in the packed PNG. They are NOT grid coordinates. With --sheet-pack, frames may be in non-obvious positions. Second, frameTags gives you named animation ranges. This is the bridge between "the artist's intent" and "what the game plays."

The Lua loader

L\u00d6VE doesn't ship with a JSON parser. The community standard is rxi/json.lua, a single-file MIT-licensed parser. Drop it into lib/json.lua and require it. From there, the loader is straightforward:

local json = require("lib.json")

local Sprite = {}
Sprite.__index = Sprite

function Sprite.load(name)
  local data_path = "assets/" .. name .. ".json"
  local image_path = "assets/" .. name .. ".png"

  local raw = love.filesystem.read(data_path)
  local data = json.decode(raw)
  local image = love.graphics.newImage(image_path)

  local quads = {}
  for i, frame in ipairs(data.frames) do
    quads[i] = love.graphics.newQuad(
      frame.frame.x, frame.frame.y,
      frame.frame.w, frame.frame.h,
      image:getDimensions()
    )
  end

  local tags = {}
  for _, tag in ipairs(data.meta.frameTags) do
    tags[tag.name] = {
      from = tag.from + 1,  -- Aseprite is 0-indexed, Lua is 1-indexed
      to = tag.to + 1,
      direction = tag.direction,
    }
  end

  return setmetatable({
    image = image,
    quads = quads,
    tags = tags,
    durations = data.frames,
  }, Sprite)
end

return Sprite

The +1 on from and to is the kind of bug that wastes an afternoon if you skip it. Aseprite indexes from 0; Lua tables index from 1. Convert at the boundary.

anim8 for playback

anim8 is the de-facto L\u00d6VE animation library. It expects a grid, which is fine for non-packed sheets, but for --sheet-pack output you want to feed it explicit quads instead. The library accepts a quads array directly:

local anim8 = require("lib.anim8")

local sprite = Sprite.load("player")
local walk_tag = sprite.tags.walk
local walk_quads = {}
for i = walk_tag.from, walk_tag.to do
  table.insert(walk_quads, sprite.quads[i])
end

local durations = {}
for i = walk_tag.from, walk_tag.to do
  table.insert(durations, sprite.durations[i].duration / 1000)
end

local walk_anim = anim8.newAnimation(walk_quads, durations)

Per-frame durations honor what the artist set in Aseprite. If the walk cycle has a slow-fast-slow rhythm, the game plays it that way without the programmer touching numbers. This is the payoff of metadata-driven pipelines: artistic intent flows from Aseprite into the game with no translation step.

When to skip this entirely

If your project is one sprite with two animations, write the 5 lines of grid-quad code and ship. The pipeline above is overhead for solo developers who haven't yet felt the pain of iteration. The signal that you need it is when you find yourself recompiling Lua to accommodate an art change, or when an artist asks for a frame count change and you flinch.

A reasonable middle ground: hardcode for the prototype, swap to metadata when you commit to the project. The Sprite.load function above is 30 lines and migrates a project in an hour. Don't pre-optimize, but don't ignore the pattern either.

Build integration

Wire the export into a Makefile so re-exporting all sprites is one command:

SPRITES := player enemy boss
SHEETS := $(SPRITES:%=build/%.png)
DATA := $(SPRITES:%=build/%.json)

build/%.png build/%.json: art/%.aseprite
\taseprite -b $< --sheet build/$*.png --data build/$*.json \
\t  --format json-array --sheet-pack --list-tags

assets: $(SHEETS) $(DATA)
\tcp build/*.png build/*.json assets/

CI-friendly, dependency-aware, fast. Aseprite's batch mode runs in well under 100ms per sprite on a modern laptop, so even 50 sprites rebuild in under 5 seconds.

References: